Fix #122: Mirror & Cut, Line Cut and Rectangle Cut tools
The issue asked for one-step symmetric shapes, splitting all selected art along a line and cropping art to a rectangle as real geometry. Only the freehand Knife and the Scissors existed, and neither works on compound paths as one shape. New commands path.mirrorCut, path.lineCut and path.rectCut cut the selected paths and compound paths (inside groups too) with pathops booleans against a half-plane or rectangle, and split open paths with the Knife/Path Eraser helpers. Mirror & Cut reflects the kept half and unites it with itself (open halves are joined on the axis). The draw2 piece helpers now rebuild compound paths too. Three tools (Eraser group, original icons) preview the cut live while dragging and commit it as one undo step; Mirror & Cut's axis and kept side are tool options shown in the Control bar. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1c46c4c654
commit
a844503e4c
|
|
@ -143,6 +143,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
|||
| `assets/icons/dc-join-round.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-join.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-knife.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-line-cut.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-line.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-list-view.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-live-bucket.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
|
|
@ -150,6 +151,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
|||
| `assets/icons/dc-mask-none.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-measure.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-mesh.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-mirror-cut.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-new-fill.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-new-item.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-new-stroke.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
|
|
@ -182,6 +184,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
|||
| `assets/icons/dc-pucker.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-puppet.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-rearrange.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-rect-cut.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-rect-grid.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-reference-point.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-remove-brush.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
|||
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso.
|
||||
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join.
|
||||
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools.
|
||||
- **Cutting:** Eraser, Scissors, Knife.
|
||||
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
|
||||
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres), Reshape, Free Transform (distort/perspective).
|
||||
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout.
|
||||
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type.
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21L21 3" stroke-dasharray="2 2"/><path d="M4 4H16.5L4 16.5Z" fill="currentColor" fill-opacity="0.35"/><path d="M20 7.5V20H7.5Z" fill="currentColor" fill-opacity="0.35"/></svg>
|
||||
|
After Width: | Height: | Size: 371 B |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2V22" stroke-dasharray="2 2"/><path d="M12 5C7.5 5 4 8 4 12C4 16 7.5 19 12 19Z" fill="currentColor" fill-opacity="0.35"/><path d="M12 5C16.5 5 20 8 20 12C20 16 16.5 19 12 19" stroke-opacity="0.5"/></svg>
|
||||
|
After Width: | Height: | Size: 400 B |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="14" height="16" stroke-dasharray="2 2"/><path d="M17 6.34A6 6 0 1 0 17 17.66Z" fill="currentColor" fill-opacity="0.35"/><path d="M17 6.34A6 6 0 0 1 17 17.66" stroke-opacity="0.5"/></svg>
|
||||
|
After Width: | Height: | Size: 395 B |
|
|
@ -0,0 +1,397 @@
|
|||
//! Cutting tools' commands: Mirror & Cut, Line Cut and Rectangle Cut.
|
||||
//!
|
||||
//! They work on the selected paths and compound paths (inside groups too; a compound path is cut
|
||||
//! as one shape): filled shapes through `vectorcraft-pathops` booleans against a half-plane or a
|
||||
//! rectangle, open paths by splitting them where they cross the line or the rectangle's edge (the
|
||||
//! Knife and Path Eraser helpers). Each command is one undo step; the tools in
|
||||
//! `vectorcraft_tools::cut` preview it live while dragging.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Anchor, FillRule, PathData, Point, Rect, SubPath, Vec2};
|
||||
use vectorcraft_pathops as po;
|
||||
use vectorcraft_pathops::BoolOp;
|
||||
|
||||
use super::draw2::{components, erase_outline, ids_json, replace_with_pieces};
|
||||
use super::edit::selected_roots;
|
||||
use super::pathops::node_path;
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"path.mirrorCut",
|
||||
"Mirror & Cut",
|
||||
[],
|
||||
None,
|
||||
"{axis?: \"vertical\"|\"horizontal\"|\"free\" (vertical), from?: [x,y], to?: [x,y] (free: the axis through both; vertical/horizontal: the axis through `from`, default the selection's centre), keep?: \"left\"|\"right\"|\"top\"|\"bottom\" (the side kept; default left, or top for a horizontal-ish axis)} cut the selected paths and compound paths at the axis, reflect the kept half across it and join both halves into one closed shape (open paths are joined where they meet the axis); art wholly on the other side is deleted, as one undo step → {ids, removed}",
|
||||
has_selection,
|
||||
mirror_cut
|
||||
),
|
||||
cmd!(
|
||||
"path.lineCut",
|
||||
"Line Cut",
|
||||
[],
|
||||
None,
|
||||
"{from: [x,y], to: [x,y]} split the selected paths and compound paths along the line through both points, extended across the art: filled shapes become their pieces on each side (holes kept), open paths split where they cross; as one undo step → {ids, removed: 0}",
|
||||
has_selection,
|
||||
line_cut
|
||||
),
|
||||
cmd!(
|
||||
"path.rectCut",
|
||||
"Rectangle Cut",
|
||||
[],
|
||||
None,
|
||||
"{rect: [x, y, width, height]} or {from: [x,y], to: [x,y]} (opposite corners) crop the selected paths and compound paths to the rectangle as real geometry (not a clipping mask): filled shapes are intersected with it, open paths keep their parts inside, art wholly outside is deleted; as one undo step → {ids, removed}",
|
||||
has_selection,
|
||||
rect_cut
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Largest coordinate the cut commands accept (keeps the half-plane polygons finite).
|
||||
const MAX_COORD: f64 = 1e7;
|
||||
/// How close an open path's end must be to the axis to be joined to its reflection.
|
||||
const AXIS_TOL: f64 = 1e-3;
|
||||
|
||||
/// A straight line through `p` along the unit vector `d`.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Axis {
|
||||
p: Point,
|
||||
d: Vec2,
|
||||
}
|
||||
|
||||
impl Axis {
|
||||
/// The line through `a` and `b` (`None` when they coincide).
|
||||
fn through(a: Point, b: Point) -> Option<Self> {
|
||||
let v = b - a;
|
||||
let len = v.hypot();
|
||||
(len > 1e-9 && len.is_finite()).then(|| Axis { p: a, d: v / len })
|
||||
}
|
||||
|
||||
/// Signed distance of `q` from the line: positive on the side `d` points to when turned a
|
||||
/// quarter turn clockwise on screen (y pointing down).
|
||||
fn side(&self, q: Point) -> f64 {
|
||||
self.d.cross(q - self.p)
|
||||
}
|
||||
|
||||
/// `q` moved onto the line.
|
||||
fn project(&self, q: Point) -> Point {
|
||||
self.p + self.d * self.d.dot(q - self.p)
|
||||
}
|
||||
|
||||
/// The reflection across the line.
|
||||
fn reflection(&self) -> Affine {
|
||||
let (x, y) = (self.d.x, self.d.y);
|
||||
let (a, b, d) = (2.0 * x * x - 1.0, 2.0 * x * y, 2.0 * y * y - 1.0);
|
||||
let p = self.p;
|
||||
Affine::new([a, b, b, d, p.x - (a * p.x + b * p.y), p.y - (b * p.x + d * p.y)])
|
||||
}
|
||||
|
||||
/// A polygon covering everything in `around` on one side of the line (`sign` > 0: where
|
||||
/// [`Axis::side`] is positive).
|
||||
fn half_plane(&self, sign: f64, around: Rect) -> PathData {
|
||||
let r = around.width() + around.height() + self.p.distance(around.center()) + 10.0;
|
||||
let n = Vec2::new(-self.d.y, self.d.x) * sign.signum() * 2.0 * r;
|
||||
let (a, b) = (self.p - self.d * r, self.p + self.d * r);
|
||||
polygon(&[a, b, b + n, a + n])
|
||||
}
|
||||
}
|
||||
|
||||
fn polygon(pts: &[Point]) -> PathData {
|
||||
PathData::single(SubPath::new(pts.iter().map(|p| Anchor::corner(*p)).collect(), true))
|
||||
}
|
||||
|
||||
/// A point param that must be finite and within [`MAX_COORD`].
|
||||
fn point_req(p: &Value, key: &str, cmd: &str) -> Result<Point> {
|
||||
let q = point_param(p, key).ok_or_else(|| bad(cmd, format!("missing point `{key}` ([x, y])")))?;
|
||||
if q.x.abs() > MAX_COORD || q.y.abs() > MAX_COORD {
|
||||
return Err(bad(cmd, format!("`{key}` is out of range")));
|
||||
}
|
||||
Ok(q)
|
||||
}
|
||||
|
||||
/// The editable paths and compound paths in the selection (inside groups and layers too; a
|
||||
/// compound path is one target; guides and clipping paths are left alone).
|
||||
fn cut_targets(s: &Session) -> Result<Vec<NodeId>> {
|
||||
fn visit(n: &Node, out: &mut Vec<NodeId>) {
|
||||
match &n.kind {
|
||||
NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. } => out.push(n.id),
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| visit(c, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let roots = selected_roots(s)?;
|
||||
let doc = &s.doc()?.doc;
|
||||
let mut out = vec![];
|
||||
for id in roots {
|
||||
if let Some(n) = doc.node(id) {
|
||||
visit(n, &mut out);
|
||||
}
|
||||
}
|
||||
out.dedup();
|
||||
out.retain(|id| doc.is_editable(*id));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What a cut does to one object: its new pieces (none deletes it).
|
||||
type Plan = Vec<(NodeId, Vec<PathData>)>;
|
||||
|
||||
/// Run `cut` on every target's geometry (`None`: unchanged) and apply the result as one undo step.
|
||||
fn run_cut(s: &mut Session, cmd: &str, label: &str, cut: &dyn Fn(&PathData, FillRule) -> Option<Vec<PathData>>) -> Result<Value> {
|
||||
let targets = cut_targets(s)?;
|
||||
if targets.is_empty() {
|
||||
return Err(bad(cmd, "select paths or compound paths"));
|
||||
}
|
||||
let doc = &s.doc()?.doc;
|
||||
let mut plan: Plan = vec![];
|
||||
for id in targets {
|
||||
let Some((pd, rule)) = doc.node(id).and_then(node_path) else { continue };
|
||||
if pd.subpaths.iter().flat_map(|sp| &sp.anchors).any(|a| !(a.p.x.is_finite() && a.p.y.is_finite())) {
|
||||
return Err(bad(cmd, "paths contain invalid coordinates"));
|
||||
}
|
||||
if let Some(pieces) = cut(&pd, rule) {
|
||||
plan.push((id, pieces));
|
||||
}
|
||||
}
|
||||
apply(s, label, plan)
|
||||
}
|
||||
|
||||
fn apply(s: &mut Session, label: &str, plan: Plan) -> Result<Value> {
|
||||
if plan.is_empty() {
|
||||
return Ok(json!({ "ids": [], "removed": 0 }));
|
||||
}
|
||||
let (ids, removed) = s.edit(label, |d: &mut Document, sel| {
|
||||
let (mut all, mut removed) = (vec![], 0);
|
||||
for (id, pieces) in plan {
|
||||
removed += pieces.is_empty() as usize;
|
||||
all.extend(replace_with_pieces(d, id, pieces)?);
|
||||
}
|
||||
sel.set(all.iter().copied());
|
||||
Ok((all, removed))
|
||||
})?;
|
||||
let mut out = ids_json(&ids);
|
||||
out["removed"] = json!(removed);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Bounds of `pd` grown a little (the reference box of the half-planes).
|
||||
fn reach(pd: &PathData) -> Rect {
|
||||
pd.control_bounds().unwrap_or_default().inflate(1.0, 1.0)
|
||||
}
|
||||
|
||||
/// `pd ∩ region`, dropping slivers.
|
||||
fn intersect(pd: &PathData, rule: FillRule, region: &PathData) -> PathData {
|
||||
let r = po::boolean(pd, rule, region, FillRule::NonZero, BoolOp::Intersect);
|
||||
if po::area(&r, FillRule::NonZero) <= 1e-6 { PathData::default() } else { r }
|
||||
}
|
||||
|
||||
// ---------- Line Cut ----------
|
||||
|
||||
fn line_cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.lineCut";
|
||||
let axis = Axis::through(point_req(p, "from", C)?, point_req(p, "to", C)?).ok_or_else(|| bad(C, "`from` and `to` must differ"))?;
|
||||
run_cut(s, C, "Line Cut", &|pd, rule| split_by_line(pd, rule, &axis))
|
||||
}
|
||||
|
||||
/// `pd` split by the line: the pieces on both sides, `None` when the line misses it.
|
||||
fn split_by_line(pd: &PathData, rule: FillRule, axis: &Axis) -> Option<Vec<PathData>> {
|
||||
if pd.is_closed() {
|
||||
let around = reach(pd);
|
||||
let a = intersect(pd, rule, &axis.half_plane(1.0, around));
|
||||
let b = intersect(pd, rule, &axis.half_plane(-1.0, around));
|
||||
if a.is_empty() || b.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return Some(components(&a).into_iter().chain(components(&b)).collect());
|
||||
}
|
||||
// Open paths: the parts on each side (each pass removes the other side).
|
||||
let a = erase_outline(pd, &|q| axis.side(q) < 0.0)?;
|
||||
let b = erase_outline(pd, &|q| axis.side(q) >= 0.0)?;
|
||||
Some(if pd.subpaths.len() == 1 {
|
||||
a.into_iter().chain(b).collect()
|
||||
} else {
|
||||
vec![PathData::new(a.into_iter().chain(b).flat_map(|p| p.subpaths).collect())]
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- Rectangle Cut ----------
|
||||
|
||||
fn rect_cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.rectCut";
|
||||
let rect = match p.get("rect") {
|
||||
Some(v) => {
|
||||
let a: Vec<f64> = v.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
|
||||
let [x, y, w, h] = a[..] else { return Err(bad(C, "rect must be [x, y, width, height]")) };
|
||||
Rect::new(x, y, x + w, y + h).abs()
|
||||
}
|
||||
None => Rect::from_points(point_req(p, "from", C)?, point_req(p, "to", C)?),
|
||||
};
|
||||
let finite = [rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD);
|
||||
if !finite || rect.width() <= 1e-9 || rect.height() <= 1e-9 {
|
||||
return Err(bad(C, "the rectangle must have a width and a height"));
|
||||
}
|
||||
run_cut(s, C, "Rectangle Cut", &|pd, rule| crop(pd, rule, rect))
|
||||
}
|
||||
|
||||
/// `pd` cropped to `rect`: `None` when it lies inside, no pieces when it lies outside.
|
||||
fn crop(pd: &PathData, rule: FillRule, rect: Rect) -> Option<Vec<PathData>> {
|
||||
let b = pd.bounds()?;
|
||||
if rect.inflate(1e-9, 1e-9).contains_rect(b) {
|
||||
return None;
|
||||
}
|
||||
if pd.is_closed() {
|
||||
let r =
|
||||
intersect(pd, rule, &polygon(&[rect.origin(), Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), Point::new(rect.x0, rect.y1)]));
|
||||
return Some(if r.is_empty() { vec![] } else { vec![r] });
|
||||
}
|
||||
let inside = rect.inflate(1e-9, 1e-9);
|
||||
let pieces = erase_outline(pd, &|q| !(q.x >= inside.x0 && q.x <= inside.x1 && q.y >= inside.y0 && q.y <= inside.y1))?;
|
||||
Some(if pd.subpaths.len() == 1 || pieces.is_empty() {
|
||||
pieces
|
||||
} else {
|
||||
vec![PathData::new(pieces.into_iter().flat_map(|p| p.subpaths).collect())]
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- Mirror & Cut ----------
|
||||
|
||||
fn mirror_cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.mirrorCut";
|
||||
let kind = str_param(p, "axis").unwrap_or("vertical");
|
||||
let at = || -> Result<Point> {
|
||||
match p.get("from") {
|
||||
Some(_) => point_req(p, "from", C),
|
||||
None => {
|
||||
let roots = selected_roots(s)?;
|
||||
let b = s.doc()?.doc.bounds_of(&roots, false).ok_or_else(|| bad(C, "the selection has no bounds"))?;
|
||||
Ok(b.center())
|
||||
}
|
||||
}
|
||||
};
|
||||
let axis = match kind {
|
||||
"vertical" => {
|
||||
let a = at()?;
|
||||
Axis { p: a, d: Vec2::new(0.0, 1.0) }
|
||||
}
|
||||
"horizontal" => {
|
||||
let a = at()?;
|
||||
Axis { p: a, d: Vec2::new(1.0, 0.0) }
|
||||
}
|
||||
"free" => Axis::through(point_req(p, "from", C)?, point_req(p, "to", C)?).ok_or_else(|| bad(C, "`from` and `to` must differ"))?,
|
||||
other => return Err(bad(C, format!("unknown axis `{other}` (vertical|horizontal|free)"))),
|
||||
};
|
||||
let sign = keep_sign(&axis, str_param(p, "keep")).map_err(|e| bad(C, e))?;
|
||||
run_cut(s, C, "Mirror & Cut", &|pd, rule| Some(mirror(pd, rule, &axis, sign)))
|
||||
}
|
||||
|
||||
/// The sign of [`Axis::side`] on the side `keep` names (default: left, or top when the axis runs
|
||||
/// more across than down).
|
||||
fn keep_sign(axis: &Axis, keep: Option<&str>) -> std::result::Result<f64, String> {
|
||||
let keep = keep.unwrap_or(if axis.d.x.abs() > axis.d.y.abs() { "top" } else { "left" });
|
||||
let toward = match keep {
|
||||
"left" => Vec2::new(-1.0, 0.0),
|
||||
"right" => Vec2::new(1.0, 0.0),
|
||||
"top" => Vec2::new(0.0, -1.0),
|
||||
"bottom" => Vec2::new(0.0, 1.0),
|
||||
other => return Err(format!("unknown side `{other}` (left|right|top|bottom)")),
|
||||
};
|
||||
let s = axis.d.cross(toward);
|
||||
if s.abs() < 1e-6 {
|
||||
return Err(format!("`{keep}` is not a side of this axis"));
|
||||
}
|
||||
Ok(s.signum())
|
||||
}
|
||||
|
||||
/// The kept half of `pd` and its reflection, joined (no pieces when nothing is on the kept side).
|
||||
fn mirror(pd: &PathData, rule: FillRule, axis: &Axis, sign: f64) -> Vec<PathData> {
|
||||
let m = axis.reflection();
|
||||
if pd.is_closed() {
|
||||
let kept = intersect(pd, rule, &axis.half_plane(sign, reach(pd)));
|
||||
if kept.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let joined = po::boolean(&kept, FillRule::NonZero, &kept.transformed(m), FillRule::NonZero, BoolOp::Union);
|
||||
return if joined.is_empty() { vec![] } else { vec![joined] };
|
||||
}
|
||||
let kept = match erase_outline(pd, &|q| axis.side(q) * sign < 0.0) {
|
||||
Some(pieces) => pieces.into_iter().flat_map(|p| p.subpaths).collect(),
|
||||
None => pd.subpaths.clone(),
|
||||
};
|
||||
let subs: Vec<SubPath> = kept.iter().flat_map(|sp| mirror_open(sp, axis, m)).collect();
|
||||
if subs.is_empty() { vec![] } else { vec![PathData::new(subs)] }
|
||||
}
|
||||
|
||||
/// An open run and its reflection, joined where its ends touch the axis (closed when both do);
|
||||
/// the two apart when neither does.
|
||||
fn mirror_open(sp: &SubPath, axis: &Axis, m: Affine) -> Vec<SubPath> {
|
||||
let on = |a: Option<&Anchor>| a.is_some_and(|a| axis.side(a.p).abs() <= AXIS_TOL);
|
||||
let (start_on, end_on) = (on(sp.anchors.first()), on(sp.anchors.last()));
|
||||
if sp.closed || sp.anchors.len() < 2 || !(start_on || end_on) {
|
||||
return vec![sp.clone(), SubPath::new(sp.anchors.iter().map(|a| a.transform(m)).collect(), sp.closed)];
|
||||
}
|
||||
// The run ends on the axis; the reflection comes back from there.
|
||||
let mut run = sp.clone();
|
||||
if !end_on {
|
||||
run.reverse();
|
||||
}
|
||||
let Some((last, body)) = run.anchors.split_last() else { return vec![] };
|
||||
let q = axis.project(last.p);
|
||||
let mut anchors = body.to_vec();
|
||||
anchors.push(Anchor::with_handles(q, last.h_in, m * last.h_in));
|
||||
anchors.extend(body.iter().rev().map(|a| Anchor::with_handles(m * a.p, m * a.h_out, m * a.h_in)));
|
||||
let closed = start_on && end_on && anchors.len() > 2;
|
||||
if closed && let (Some(first), Some(back)) = (anchors.first().copied(), anchors.pop()) {
|
||||
// The start and its reflection meet on the axis: one closed shape.
|
||||
if let Some(f) = anchors.first_mut() {
|
||||
*f = Anchor::with_handles(axis.project(first.p), back.h_in, first.h_out);
|
||||
}
|
||||
}
|
||||
vec![SubPath::new(anchors, closed)]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reflection_maps_across_the_axis() {
|
||||
let a = Axis::through(Point::new(0.0, 0.0), Point::new(10.0, 10.0)).unwrap();
|
||||
let q = a.reflection() * Point::new(5.0, 0.0);
|
||||
assert!((q.x - 0.0).abs() < 1e-9 && (q.y - 5.0).abs() < 1e-9, "{q:?}");
|
||||
let v = Axis { p: Point::new(100.0, 0.0), d: Vec2::new(0.0, 1.0) };
|
||||
let q = v.reflection() * Point::new(90.0, 7.0);
|
||||
assert!((q.x - 110.0).abs() < 1e-9 && (q.y - 7.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_names_a_side_of_the_axis() {
|
||||
let v = Axis { p: Point::ZERO, d: Vec2::new(0.0, 1.0) };
|
||||
let left = keep_sign(&v, Some("left")).unwrap();
|
||||
assert!(v.side(Point::new(-5.0, 0.0)) * left > 0.0);
|
||||
assert_eq!(keep_sign(&v, None).unwrap(), left);
|
||||
assert!(keep_sign(&v, Some("top")).is_err());
|
||||
let h = Axis { p: Point::ZERO, d: Vec2::new(1.0, 0.0) };
|
||||
let top = keep_sign(&h, None).unwrap();
|
||||
assert!(h.side(Point::new(0.0, -5.0)) * top > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_half_mirrors_into_a_closed_shape() {
|
||||
// An arch from the axis and back to it: its reflection closes it.
|
||||
let axis = Axis { p: Point::new(50.0, 0.0), d: Vec2::new(0.0, 1.0) };
|
||||
let sp = SubPath::new(
|
||||
vec![Anchor::corner(Point::new(50.0, 0.0)), Anchor::corner(Point::new(10.0, 50.0)), Anchor::corner(Point::new(50.0, 100.0))],
|
||||
false,
|
||||
);
|
||||
let out = mirror_open(&sp, &axis, axis.reflection());
|
||||
assert_eq!(out.len(), 1);
|
||||
assert!(out[0].closed);
|
||||
assert_eq!(out[0].anchors.len(), 4);
|
||||
assert!(out[0].anchors.iter().any(|a| (a.p.x - 90.0).abs() < 1e-9));
|
||||
// One end on the axis: one open path through it.
|
||||
let sp = SubPath::new(vec![Anchor::corner(Point::new(10.0, 0.0)), Anchor::corner(Point::new(50.0, 40.0))], false);
|
||||
let out = mirror_open(&sp, &axis, axis.reflection());
|
||||
assert_eq!((out.len(), out[0].closed, out[0].anchors.len()), (1, false, 3));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
//! Commands backing the drawing tools (pencil, curvature, anchor tools, scissors, knife, eraser,
|
||||
//! blob brush, smooth, path eraser, join).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::{ParamCurve, ParamCurveNearest};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
|
|
@ -224,19 +226,43 @@ fn target_paths(doc: &Document, sel: &Selection, area: Rect) -> Vec<NodeId> {
|
|||
out
|
||||
}
|
||||
|
||||
/// A copy of path node `id` (fresh id, no live shape) with geometry `pd`.
|
||||
/// The members of a compound path with geometry `pd`: one path per subpath, with fresh ids.
|
||||
fn compound_members(d: &mut Document, pd: PathData) -> Vec<Arc<Node>> {
|
||||
pd.subpaths.into_iter().map(|sp| Arc::new(Node::path(d.alloc_id(), PathData::single(sp), Appearance::default()))).collect()
|
||||
}
|
||||
|
||||
/// Give path or compound path `id` the geometry `pd` (dropping a live shape; a compound's members
|
||||
/// are rebuilt, one per subpath).
|
||||
pub(super) fn set_geometry(d: &mut Document, id: NodeId, pd: PathData) -> Result<()> {
|
||||
if matches!(d.node(id).map(|n| &n.kind), Some(NodeKind::Compound { .. })) {
|
||||
let members = compound_members(d, pd);
|
||||
if let Some(NodeKind::Compound { children, .. }) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
*children = members;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
*path_mut(d, id)? = pd;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A copy of path or compound path node `id` (fresh ids, no live shape) with geometry `pd`.
|
||||
fn sibling_with(d: &mut Document, id: NodeId, pd: PathData) -> Result<Node> {
|
||||
let mut n = d.node(id).ok_or(EngineError::NoNode(id))?.clone();
|
||||
n.id = d.alloc_id();
|
||||
if let NodeKind::Path { path, live, .. } = &mut n.kind {
|
||||
*path = pd;
|
||||
*live = None;
|
||||
match &mut n.kind {
|
||||
NodeKind::Path { path, live, .. } => {
|
||||
*path = pd;
|
||||
*live = None;
|
||||
}
|
||||
NodeKind::Compound { children, .. } => *children = compound_members(d, pd),
|
||||
_ => {}
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Replace path `id` with `pieces` (first keeps the id, the rest go right above it). Returns ids.
|
||||
fn replace_with_pieces(d: &mut Document, id: NodeId, pieces: Vec<PathData>) -> Result<Vec<NodeId>> {
|
||||
/// Replace path or compound path `id` with `pieces` (first keeps the id, the rest go right above
|
||||
/// it; none removes it). Returns ids.
|
||||
pub(super) fn replace_with_pieces(d: &mut Document, id: NodeId, pieces: Vec<PathData>) -> Result<Vec<NodeId>> {
|
||||
let mut it = pieces.into_iter();
|
||||
let Some(first) = it.next() else {
|
||||
d.remove(id)?;
|
||||
|
|
@ -248,16 +274,16 @@ fn replace_with_pieces(d: &mut Document, id: NodeId, pieces: Vec<PathData>) -> R
|
|||
let n = sibling_with(d, id, pd)?;
|
||||
ids.push(d.insert(parent, idx + 1 + k, n)?);
|
||||
}
|
||||
*path_mut(d, id)? = first;
|
||||
set_geometry(d, id, first)?;
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn ids_json(ids: &[NodeId]) -> Value {
|
||||
pub(super) fn ids_json(ids: &[NodeId]) -> Value {
|
||||
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
|
||||
}
|
||||
|
||||
/// Split a filled path into connected pieces (each outer contour with its holes).
|
||||
fn components(pd: &PathData) -> Vec<PathData> {
|
||||
pub(super) fn components(pd: &PathData) -> Vec<PathData> {
|
||||
let subs: Vec<&SubPath> = pd.subpaths.iter().filter(|s| s.closed && s.anchors.len() >= 2).collect();
|
||||
if subs.len() <= 1 {
|
||||
return if pd.is_empty() { vec![] } else { vec![pd.clone()] };
|
||||
|
|
@ -382,7 +408,7 @@ fn cut_subpath(sp: &SubPath, remove: &dyn Fn(Point) -> bool) -> Option<Vec<SubPa
|
|||
|
||||
/// Erase from an outline path (open or closed) where `remove` holds. Returns the new geometry per
|
||||
/// output object (one object per piece when the path had a single subpath), or None if unchanged.
|
||||
fn erase_outline(pd: &PathData, remove: &dyn Fn(Point) -> bool) -> Option<Vec<PathData>> {
|
||||
pub(super) fn erase_outline(pd: &PathData, remove: &dyn Fn(Point) -> bool) -> Option<Vec<PathData>> {
|
||||
let mut changed = false;
|
||||
let mut subs = vec![];
|
||||
for sp in &pd.subpaths {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ pub mod colormgmt;
|
|||
pub mod colortheme;
|
||||
mod create;
|
||||
mod css;
|
||||
mod cut;
|
||||
pub(crate) mod distortcmds;
|
||||
mod docinfo;
|
||||
mod docmenu;
|
||||
|
|
@ -246,6 +247,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(printpresets::specs());
|
||||
v.extend(printtiling::specs());
|
||||
v.extend(plugin::specs());
|
||||
v.extend(cut::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1138,6 +1138,8 @@ mod tests_containers;
|
|||
#[cfg(test)]
|
||||
mod tests_css;
|
||||
#[cfg(test)]
|
||||
mod tests_cut;
|
||||
#[cfg(test)]
|
||||
mod tests_dashalign;
|
||||
#[cfg(test)]
|
||||
mod tests_distort;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,268 @@
|
|||
//! Tests for the cutting commands: Mirror & Cut, Line Cut and Rectangle Cut.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_geom::{FillRule, PathData, Rect};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn id_of(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||||
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
|
||||
}
|
||||
|
||||
fn path(s: &mut Session, pts: &[(f64, f64)], closed: bool) -> NodeId {
|
||||
let anchors: Vec<Value> = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect();
|
||||
id_of(&s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap())
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, ids: &[NodeId]) {
|
||||
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
}
|
||||
|
||||
fn ids(v: &Value) -> Vec<NodeId> {
|
||||
v["ids"].as_array().unwrap().iter().map(|x| NodeId(x.as_u64().unwrap())).collect()
|
||||
}
|
||||
|
||||
fn geometry(s: &Session, id: NodeId) -> (PathData, FillRule) {
|
||||
let n = s.doc().unwrap().doc.node(id).cloned().unwrap();
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, rule, .. } => (path.clone(), *rule),
|
||||
NodeKind::Compound { children, rule } => {
|
||||
(PathData::new(children.iter().flat_map(|c| c.path_data().unwrap().subpaths.clone()).collect()), *rule)
|
||||
}
|
||||
_ => panic!("not a path"),
|
||||
}
|
||||
}
|
||||
|
||||
fn area(s: &Session, id: NodeId) -> f64 {
|
||||
let (p, r) = geometry(s, id);
|
||||
vectorcraft_pathops::area(&p, r)
|
||||
}
|
||||
|
||||
fn bounds(s: &Session, id: NodeId) -> Rect {
|
||||
geometry(s, id).0.bounds().unwrap()
|
||||
}
|
||||
|
||||
fn near(a: Rect, b: Rect) -> bool {
|
||||
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(p, q)| (p - q).abs() < 0.05)
|
||||
}
|
||||
|
||||
fn undo_depth(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_cut_splits_a_shape_in_one_undo_step() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
select(&mut s, &[r]);
|
||||
let before = undo_depth(&s);
|
||||
let out = s.execute("path.lineCut", &json!({"from": [30, -10], "to": [30, 10]})).unwrap();
|
||||
let pieces = ids(&out);
|
||||
assert_eq!(pieces.len(), 2);
|
||||
let mut areas: Vec<f64> = pieces.iter().map(|id| area(&s, *id)).collect();
|
||||
areas.sort_by(f64::total_cmp);
|
||||
assert!((areas[0] - 3000.0).abs() < 1.0 && (areas[1] - 7000.0).abs() < 1.0, "{areas:?}");
|
||||
// The pieces are selected, and the cut is one undo step.
|
||||
assert_eq!(s.doc().unwrap().selection.len(), 2);
|
||||
assert_eq!(undo_depth(&s), before + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!((area(&s, r) - 10_000.0).abs() < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_cut_keeps_compound_holes_and_splits_open_paths() {
|
||||
let mut s = session();
|
||||
let outer = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
// Wound against the outer rectangle, so the non-zero compound has a hole.
|
||||
let hole = path(&mut s, &[(40.0, 40.0), (40.0, 60.0), (60.0, 60.0), (60.0, 40.0)], true);
|
||||
select(&mut s, &[outer, hole]);
|
||||
let c = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap());
|
||||
assert!((area(&s, c) - 9600.0).abs() < 1.0);
|
||||
let line = path(&mut s, &[(0.0, 200.0), (100.0, 200.0)], false);
|
||||
select(&mut s, &[c, line]);
|
||||
// A vertical line at x = 20 misses the hole: the right piece keeps it.
|
||||
let out = s.execute("path.lineCut", &json!({"from": [20, 0], "to": [20, 300]})).unwrap();
|
||||
let pieces = ids(&out);
|
||||
assert_eq!(pieces.len(), 4, "two compound pieces and two line pieces");
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let compounds: Vec<NodeId> = pieces.iter().copied().filter(|id| matches!(doc.node(*id).unwrap().kind, NodeKind::Compound { .. })).collect();
|
||||
assert_eq!(compounds.len(), 2, "both pieces of a compound path stay compound paths");
|
||||
let mut areas: Vec<f64> = compounds.iter().map(|id| area(&s, *id)).collect();
|
||||
areas.sort_by(f64::total_cmp);
|
||||
assert!((areas[0] - 2000.0).abs() < 1.0 && (areas[1] - 7600.0).abs() < 1.0, "{areas:?}");
|
||||
let open: Vec<Rect> = pieces.iter().filter(|id| !compounds.contains(id)).map(|id| bounds(&s, *id)).collect();
|
||||
assert!(open.iter().any(|b| (b.x1 - 20.0).abs() < 0.01) && open.iter().any(|b| (b.x0 - 20.0).abs() < 0.01), "{open:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_cut_that_misses_changes_nothing() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
select(&mut s, &[r]);
|
||||
let before = undo_depth(&s);
|
||||
let out = s.execute("path.lineCut", &json!({"from": [200, 0], "to": [200, 10]})).unwrap();
|
||||
assert!(ids(&out).is_empty());
|
||||
assert_eq!(undo_depth(&s), before);
|
||||
assert!(s.execute("path.lineCut", &json!({"from": [5, 5], "to": [5, 5]})).is_err());
|
||||
assert!(s.execute("path.lineCut", &json!({"from": [5, 5]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_cut_crops_to_real_geometry() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let outside = rect(&mut s, 300.0, 300.0, 10.0, 10.0);
|
||||
let inside = rect(&mut s, 60.0, 20.0, 10.0, 10.0);
|
||||
let line = path(&mut s, &[(0.0, 50.0), (200.0, 50.0)], false);
|
||||
select(&mut s, &[a, outside, inside, line]);
|
||||
let out = s.execute("path.rectCut", &json!({"rect": [50, 0, 100, 200]})).unwrap();
|
||||
assert_eq!(out["removed"], 1);
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(doc.node(outside).is_none(), "art wholly outside is deleted");
|
||||
assert!(matches!(doc.node(a).unwrap().kind, NodeKind::Path { .. }), "no clipping mask");
|
||||
assert!(near(bounds(&s, a), Rect::new(50.0, 0.0, 100.0, 100.0)));
|
||||
assert!((area(&s, a) - 5000.0).abs() < 1.0);
|
||||
assert!(near(bounds(&s, inside), Rect::new(60.0, 20.0, 70.0, 30.0)), "art inside is untouched");
|
||||
assert!(near(bounds(&s, line), Rect::new(50.0, 50.0, 150.0, 50.0)));
|
||||
// Two corners work too.
|
||||
select(&mut s, &[a]);
|
||||
s.execute("path.rectCut", &json!({"from": [100, 100], "to": [75, 50]})).unwrap();
|
||||
assert!(near(bounds(&s, a), Rect::new(75.0, 50.0, 100.0, 100.0)));
|
||||
assert!(s.execute("path.rectCut", &json!({"rect": [0, 0, 0, 10]})).is_err());
|
||||
assert!(s.execute("path.rectCut", &json!({"rect": [0, 0, 10]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_cut_makes_a_symmetric_shape() {
|
||||
let mut s = session();
|
||||
// A right triangle: mirrored at x = 50 keeping the left, it becomes a house-like pentagon.
|
||||
let t = path(&mut s, &[(0.0, 0.0), (100.0, 0.0), (0.0, 100.0)], true);
|
||||
select(&mut s, &[t]);
|
||||
let out = s.execute("path.mirrorCut", &json!({"axis": "vertical", "from": [50, 0]})).unwrap();
|
||||
assert_eq!(ids(&out), vec![t]);
|
||||
let (pd, _) = geometry(&s, t);
|
||||
assert_eq!(pd.subpaths.len(), 1, "both halves are joined into one closed path");
|
||||
assert!(pd.subpaths[0].closed);
|
||||
assert!((area(&s, t) - 7500.0).abs() < 1.0);
|
||||
assert!(near(bounds(&s, t), Rect::new(0.0, 0.0, 100.0, 100.0)));
|
||||
// Keeping the right half instead.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("path.mirrorCut", &json!({"axis": "vertical", "from": [50, 0], "keep": "right"})).unwrap();
|
||||
assert!((area(&s, t) - 2500.0).abs() < 1.0);
|
||||
assert!(near(bounds(&s, t), Rect::new(0.0, 0.0, 100.0, 50.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_cut_axes_and_sides() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 40.0, 100.0);
|
||||
let gone = rect(&mut s, 300.0, 0.0, 10.0, 10.0);
|
||||
select(&mut s, &[a, gone]);
|
||||
// A free axis at x = 50 (drawn upward): art on the discarded side is deleted.
|
||||
let out = s.execute("path.mirrorCut", &json!({"axis": "free", "from": [50, 100], "to": [50, 0], "keep": "left"})).unwrap();
|
||||
assert_eq!(out["removed"], 1);
|
||||
assert!(s.doc().unwrap().doc.node(gone).is_none());
|
||||
// Wholly on the kept side: the shape and its reflection, one object.
|
||||
let (pd, _) = geometry(&s, a);
|
||||
assert_eq!(pd.subpaths.len(), 2);
|
||||
assert!(near(bounds(&s, a), Rect::new(0.0, 0.0, 100.0, 100.0)));
|
||||
// A horizontal axis through the selection's centre keeps the top by default.
|
||||
let b = rect(&mut s, 0.0, 200.0, 100.0, 60.0);
|
||||
select(&mut s, &[b]);
|
||||
s.execute("path.mirrorCut", &json!({"axis": "horizontal"})).unwrap();
|
||||
assert!(near(bounds(&s, b), Rect::new(0.0, 200.0, 100.0, 260.0)));
|
||||
assert!(s.execute("path.mirrorCut", &json!({"axis": "vertical", "keep": "top"})).is_err());
|
||||
assert!(s.execute("path.mirrorCut", &json!({"axis": "diagonal"})).is_err());
|
||||
assert!(s.execute("path.mirrorCut", &json!({"axis": "free", "from": [1, 1], "to": [1, 1]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_cut_closes_an_open_half() {
|
||||
let mut s = session();
|
||||
let p = path(&mut s, &[(50.0, 0.0), (0.0, 50.0), (50.0, 100.0)], false);
|
||||
select(&mut s, &[p]);
|
||||
s.execute("path.mirrorCut", &json!({"axis": "vertical", "from": [50, 0]})).unwrap();
|
||||
let (pd, _) = geometry(&s, p);
|
||||
assert_eq!(pd.subpaths.len(), 1);
|
||||
assert!(pd.subpaths[0].closed);
|
||||
assert_eq!(pd.subpaths[0].anchors.len(), 4);
|
||||
assert!(near(bounds(&s, p), Rect::new(0.0, 0.0, 100.0, 100.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_commands_need_paths() {
|
||||
let mut s = session();
|
||||
assert!(s.execute("path.lineCut", &json!({"from": [0, 0], "to": [1, 1]})).is_err(), "nothing selected");
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 10, "y": 10, "text": "A"})).unwrap());
|
||||
select(&mut s, &[t]);
|
||||
assert!(s.execute("path.lineCut", &json!({"from": [0, 0], "to": [1, 1]})).is_err());
|
||||
}
|
||||
|
||||
mod tools {
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
|
||||
use super::*;
|
||||
use crate::tooling::ViewInfo;
|
||||
|
||||
fn view() -> ViewInfo {
|
||||
ViewInfo { smart_guides: false, ..Default::default() }
|
||||
}
|
||||
|
||||
fn press(s: &mut Session, kind: PointerKind, x: f64, y: f64) {
|
||||
s.pointer(&PointerEvent::new(kind, x, y).with_mods(Mods::default()), view()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_cut_tool_previews_live_and_commits_one_step() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
select(&mut s, &[r]);
|
||||
let before = undo_depth(&s);
|
||||
s.select_tool("lineCut", view()).unwrap();
|
||||
press(&mut s, PointerKind::Down, 50.0, -20.0);
|
||||
press(&mut s, PointerKind::Drag, 50.0, 40.0);
|
||||
// The preview already shows the cut.
|
||||
assert!(s.in_interaction());
|
||||
assert_eq!(s.doc().unwrap().selection.len(), 2);
|
||||
press(&mut s, PointerKind::Drag, 50.0, 120.0);
|
||||
press(&mut s, PointerKind::Up, 50.0, 120.0);
|
||||
assert!(!s.in_interaction());
|
||||
assert_eq!(undo_depth(&s), before + 1);
|
||||
assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("path.lineCut"));
|
||||
assert!((area(&s, r) - 5000.0).abs() < 1.0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!((area(&s, r) - 10_000.0).abs() < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_and_mirror_tools_cut_and_escape_restores() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
select(&mut s, &[r]);
|
||||
s.select_tool("rectCut", view()).unwrap();
|
||||
press(&mut s, PointerKind::Down, 50.0, 50.0);
|
||||
press(&mut s, PointerKind::Drag, 150.0, 150.0);
|
||||
assert!(near(bounds(&s, r), Rect::new(50.0, 50.0, 100.0, 100.0)));
|
||||
s.tool_key(ToolKey::Escape, Mods::default(), view()).unwrap();
|
||||
assert!(!s.in_interaction());
|
||||
assert!(near(bounds(&s, r), Rect::new(0.0, 0.0, 100.0, 100.0)));
|
||||
// Mirror & Cut with a vertical axis placed by a click at x = 80.
|
||||
s.select_tool("mirrorCut", view()).unwrap();
|
||||
s.set_tool_option("axis", &json!("vertical"));
|
||||
press(&mut s, PointerKind::Down, 80.0, 50.0);
|
||||
press(&mut s, PointerKind::Up, 80.0, 50.0);
|
||||
assert!(near(bounds(&s, r), Rect::new(0.0, 0.0, 160.0, 100.0)));
|
||||
assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("path.mirrorCut"));
|
||||
}
|
||||
}
|
||||
|
|
@ -68,6 +68,9 @@ pub const TOOL_GROUPS: &[&[ToolInfo]] = &[
|
|||
t("eraser", "Eraser Tool", Some("Shift+E"), "tool-eraser"),
|
||||
t("scissors", "Scissors Tool", Some("C"), "tool-scissors"),
|
||||
t("knife", "Knife", None, "tool-knife"),
|
||||
t("mirrorCut", "Mirror & Cut Tool", None, "tool-mirror-cut"),
|
||||
t("lineCut", "Line Cut Tool", None, "tool-line-cut"),
|
||||
t("rectCut", "Rectangle Cut Tool", None, "tool-rect-cut"),
|
||||
],
|
||||
&[t("rotate", "Rotate Tool", Some("R"), "tool-rotate"), t("reflect", "Reflect Tool", Some("O"), "tool-reflect")],
|
||||
&[
|
||||
|
|
|
|||
|
|
@ -0,0 +1,303 @@
|
|||
//! Mirror & Cut, Line Cut and Rectangle Cut: drag across the selected art.
|
||||
//!
|
||||
//! The cut previews live while dragging (`path.mirrorCut`, `path.lineCut` or `path.rectCut`
|
||||
//! re-applied on the snapshot) and the release keeps it as one undo step; Escape cancels.
|
||||
//!
|
||||
//! - Mirror & Cut: the drag draws the axis (Shift: 45° steps from the constrain angle). With the
|
||||
//! `axis` option vertical or horizontal the axis follows the pointer instead, and a click places
|
||||
//! it. `keep` names the side kept; Alt on release keeps the other one.
|
||||
//! - Line Cut: the drag draws the cut line (Shift: 45° steps).
|
||||
//! - Rectangle Cut: the drag draws the rectangle (Shift: a square, Alt: from its centre).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_geom::{Point, Vec2, constrain_angle_from};
|
||||
|
||||
use crate::draw2::FEEDBACK;
|
||||
use crate::shape::drag_rect;
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
/// Create a cutting tool by id (None = not one of ours).
|
||||
pub fn create(id: &str) -> Option<Box<dyn Tool>> {
|
||||
matches!(id, "mirrorCut" | "lineCut" | "rectCut").then(|| Box::new(CutTool::new(id)) as Box<dyn Tool>)
|
||||
}
|
||||
|
||||
pub struct CutTool {
|
||||
id: &'static str,
|
||||
start: Option<Point>,
|
||||
end: Point,
|
||||
mods: Mods,
|
||||
began: bool,
|
||||
/// Mirror & Cut: "free", "vertical" or "horizontal".
|
||||
pub axis: &'static str,
|
||||
/// Mirror & Cut: the side kept, "left", "right", "top" or "bottom" (a side across the axis
|
||||
/// stands for its counterpart: left for top, right for bottom).
|
||||
pub keep: &'static str,
|
||||
}
|
||||
|
||||
/// Drags shorter than this many screen pixels don't cut.
|
||||
const MIN_DRAG_PX: f64 = 3.0;
|
||||
|
||||
impl CutTool {
|
||||
pub fn new(id: &str) -> Self {
|
||||
let id = match id {
|
||||
"mirrorCut" => "mirrorCut",
|
||||
"lineCut" => "lineCut",
|
||||
_ => "rectCut",
|
||||
};
|
||||
Self { id, start: None, end: Point::ZERO, mods: Mods::default(), began: false, axis: "free", keep: "left" }
|
||||
}
|
||||
|
||||
fn label(&self) -> &'static str {
|
||||
match self.id {
|
||||
"mirrorCut" => "Mirror & Cut",
|
||||
"lineCut" => "Line Cut",
|
||||
_ => "Rectangle Cut",
|
||||
}
|
||||
}
|
||||
|
||||
/// A constrained Mirror & Cut axis (it follows the pointer; a click places it).
|
||||
fn constrained(&self) -> bool {
|
||||
self.id == "mirrorCut" && self.axis != "free"
|
||||
}
|
||||
|
||||
/// The drag's end point: Shift snaps lines to 45° steps.
|
||||
fn end_point(&self, cx: &ToolContext, start: Point, p: Point, m: Mods) -> Point {
|
||||
if m.shift && self.id != "rectCut" { start + constrain_angle_from(p - start, 45.0, cx.constrain_angle) } else { p }
|
||||
}
|
||||
|
||||
/// The side Mirror & Cut keeps for an axis running along `d`: the `keep` option named for the
|
||||
/// axis's orientation, the other side with Alt.
|
||||
fn keep_side(&self, d: Vec2, alt: bool) -> &'static str {
|
||||
let upright = match self.axis {
|
||||
"vertical" => true,
|
||||
"horizontal" => false,
|
||||
_ => d.y.abs() >= d.x.abs(),
|
||||
};
|
||||
let first = matches!(self.keep, "left" | "top");
|
||||
match (upright, first != alt) {
|
||||
(true, true) => "left",
|
||||
(true, false) => "right",
|
||||
(false, true) => "top",
|
||||
(false, false) => "bottom",
|
||||
}
|
||||
}
|
||||
|
||||
/// The command for the current drag, `None` while it is too short to cut.
|
||||
pub fn command(&self, cx: &ToolContext) -> Option<(&'static str, Value)> {
|
||||
let start = self.start?;
|
||||
let (a, b) = (start, self.end);
|
||||
let short = a.distance(b) < cx.tol(MIN_DRAG_PX);
|
||||
match self.id {
|
||||
"mirrorCut" if self.constrained() => {
|
||||
Some(("path.mirrorCut", json!({"axis": self.axis, "from": [b.x, b.y], "keep": self.keep_side(Vec2::ZERO, self.mods.alt)})))
|
||||
}
|
||||
_ if short => None,
|
||||
"mirrorCut" => {
|
||||
Some(("path.mirrorCut", json!({"axis": "free", "from": [a.x, a.y], "to": [b.x, b.y], "keep": self.keep_side(b - a, self.mods.alt)})))
|
||||
}
|
||||
"lineCut" => Some(("path.lineCut", json!({"from": [a.x, a.y], "to": [b.x, b.y]}))),
|
||||
_ => {
|
||||
let r = drag_rect(a, b, self.mods);
|
||||
(r.width() > 0.0 && r.height() > 0.0).then(|| ("path.rectCut", json!({"rect": [r.x0, r.y0, r.width(), r.height()]})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.start = None;
|
||||
self.began = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for CutTool {
|
||||
fn id(&self) -> &'static str {
|
||||
self.id
|
||||
}
|
||||
fn busy(&self) -> bool {
|
||||
self.start.is_some()
|
||||
}
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
match ev.kind {
|
||||
PointerKind::Down => {
|
||||
// The tools cut the selection: without one there is nothing to do.
|
||||
if cx.selection.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
self.start = Some(ev.pos);
|
||||
self.end = ev.pos;
|
||||
self.mods = ev.mods;
|
||||
self.began = false;
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Drag | PointerKind::Up => {
|
||||
let Some(start) = self.start else { return vec![] };
|
||||
self.end = self.end_point(cx, start, ev.pos, ev.mods);
|
||||
self.mods = ev.mods;
|
||||
let mut out = vec![];
|
||||
let cmd = self.command(cx);
|
||||
if let Some((cmd, p)) = cmd {
|
||||
if !self.began {
|
||||
self.began = true;
|
||||
out.push(Action::Begin(self.label().into()));
|
||||
}
|
||||
out.push(Action::Preview(cmd.into(), p));
|
||||
}
|
||||
if ev.kind == PointerKind::Up {
|
||||
if self.began {
|
||||
out.push(Action::Commit);
|
||||
}
|
||||
self.reset();
|
||||
}
|
||||
out
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec<Action> {
|
||||
if key == ToolKey::Escape && self.start.is_some() {
|
||||
let began = self.began;
|
||||
self.reset();
|
||||
return if began { vec![Action::Cancel] } else { vec![] };
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
let began = self.began;
|
||||
self.reset();
|
||||
if began { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let Some(a) = self.start else { return vec![] };
|
||||
let b = self.end;
|
||||
if self.id == "rectCut" {
|
||||
return vec![Overlay::Marquee(drag_rect(a, b, self.mods))];
|
||||
}
|
||||
// The axis and the cut line run across the art: draw them across the view.
|
||||
let d = match (self.id, self.axis) {
|
||||
("mirrorCut", "vertical") => Vec2::new(0.0, 1.0),
|
||||
("mirrorCut", "horizontal") => Vec2::new(1.0, 0.0),
|
||||
_ if a.distance(b) < cx.tol(MIN_DRAG_PX) => return vec![],
|
||||
_ => (b - a).normalize(),
|
||||
};
|
||||
let far = d * cx.tol(10_000.0);
|
||||
let mid = if self.constrained() { b } else { a };
|
||||
vec![Overlay::Line { a: mid - far, b: mid + far, color: FEEDBACK, dashed: true }]
|
||||
}
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||
Cursor::Crosshair
|
||||
}
|
||||
fn options(&self) -> Value {
|
||||
match self.id {
|
||||
"mirrorCut" => json!({"axis": self.axis, "keep": self.keep}),
|
||||
_ => Value::Null,
|
||||
}
|
||||
}
|
||||
fn set_option(&mut self, key: &str, v: &Value) {
|
||||
match (key, v.as_str()) {
|
||||
("axis", Some(a)) => {
|
||||
self.axis = match a {
|
||||
"vertical" => "vertical",
|
||||
"horizontal" => "horizontal",
|
||||
"free" => "free",
|
||||
_ => self.axis,
|
||||
}
|
||||
}
|
||||
("keep", Some(k)) => {
|
||||
self.keep = match k {
|
||||
"left" => "left",
|
||||
"right" => "right",
|
||||
"top" => "top",
|
||||
"bottom" => "bottom",
|
||||
_ => self.keep,
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testutil::*;
|
||||
use vectorcraft_doc::Selection;
|
||||
|
||||
fn drag(t: &mut CutTool, cx: &ToolContext, from: (f64, f64), to: (f64, f64), m: Mods) -> Vec<Action> {
|
||||
let mut out = t.pointer(cx, &PointerEvent::new(PointerKind::Down, from.0, from.1));
|
||||
out.extend(t.pointer(cx, &PointerEvent::new(PointerKind::Drag, (from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0).with_mods(m)));
|
||||
out.extend(t.pointer(cx, &PointerEvent::new(PointerKind::Up, to.0, to.1).with_mods(m)));
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_cut_previews_and_commits_once() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = CutTool::new("lineCut");
|
||||
let a = drag(&mut t, &cx, (150.0, 50.0), (150.0, 250.0), Mods::default());
|
||||
assert_eq!(a[0], Action::Begin("Line Cut".into()));
|
||||
assert_eq!(a.iter().filter(|x| matches!(x, Action::Begin(_))).count(), 1);
|
||||
assert!(matches!(&a[a.len() - 2], Action::Preview(c, v) if c == "path.lineCut" && v["to"] == json!([150.0, 250.0])));
|
||||
assert_eq!(a.last(), Some(&Action::Commit));
|
||||
assert!(!t.busy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_a_selection_and_a_drag() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let mut t = CutTool::new("rectCut");
|
||||
assert!(drag(&mut t, &cx(&d, &s, &p), (0.0, 0.0), (300.0, 300.0), Mods::default()).is_empty());
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
let cx = cx(&d, &s, &p);
|
||||
assert!(drag(&mut t, &cx, (10.0, 10.0), (11.0, 11.0), Mods::default()).is_empty(), "a click doesn't cut");
|
||||
let a = drag(&mut t, &cx, (120.0, 120.0), (160.0, 140.0), Mods { shift: true, ..Default::default() });
|
||||
assert!(matches!(&a[a.len() - 2], Action::Preview(c, v) if c == "path.rectCut" && v["rect"] == json!([120.0, 120.0, 40.0, 40.0])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_cut_axis_options_and_alt() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = CutTool::new("mirrorCut");
|
||||
// A free, mostly horizontal axis keeps the top; Alt the bottom.
|
||||
let a = drag(&mut t, &cx, (50.0, 150.0), (250.0, 160.0), Mods::default());
|
||||
assert!(matches!(&a[a.len() - 2], Action::Preview(_, v) if v["axis"] == "free" && v["keep"] == "top"));
|
||||
let a = drag(&mut t, &cx, (50.0, 150.0), (250.0, 160.0), Mods { alt: true, ..Default::default() });
|
||||
assert!(matches!(&a[a.len() - 2], Action::Preview(_, v) if v["keep"] == "bottom"));
|
||||
// A vertical axis follows the pointer, and a click places it.
|
||||
t.set_option("axis", &json!("vertical"));
|
||||
t.set_option("keep", &json!("right"));
|
||||
assert_eq!(t.options(), json!({"axis": "vertical", "keep": "right"}));
|
||||
let mut a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 140.0, 150.0));
|
||||
a.extend(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 140.0, 150.0)));
|
||||
assert_eq!(a.len(), 3);
|
||||
assert!(matches!(&a[1], Action::Preview(_, v) if v["axis"] == "vertical" && v["from"] == json!([140.0, 150.0]) && v["keep"] == "right"));
|
||||
t.set_option("axis", &json!("diagonal"));
|
||||
assert_eq!(t.axis, "vertical");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_cancels_the_preview() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = CutTool::new("lineCut");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0));
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 250.0));
|
||||
assert_eq!(a.len(), 2);
|
||||
assert_eq!(t.overlays(&cx).len(), 1);
|
||||
assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]);
|
||||
assert!(!t.busy() && t.overlays(&cx).is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ pub mod bbox;
|
|||
pub mod builder;
|
||||
pub mod catalog;
|
||||
pub mod corners;
|
||||
pub mod cut;
|
||||
pub mod direct;
|
||||
pub mod distort;
|
||||
pub mod draw2;
|
||||
|
|
@ -364,6 +365,7 @@ pub fn create(id: &str) -> Box<dyn Tool> {
|
|||
.or_else(|| extra::create(other))
|
||||
.or_else(|| slice::create(other))
|
||||
.or_else(|| printtiling::create(other))
|
||||
.or_else(|| cut::create(other))
|
||||
.unwrap_or_else(|| Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection")))),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text));
|
||||
ui.add_space(6.0);
|
||||
crate::place::control_bar_details(app, ui);
|
||||
crate::toolbar::control_bar_options(app, ui);
|
||||
let shown_stroke = crate::panels::current_stroke(app);
|
||||
let mixed = crate::panels::stroke_mixed(app, ui.ctx());
|
||||
let weight = stroke_panel::shown_weight(app, shown_stroke.as_ref(), &mixed);
|
||||
|
|
|
|||
|
|
@ -99,12 +99,14 @@ pub static ICONS: &[(&str, &[u8])] = &[
|
|||
("dc-join-round", include_bytes!("../../../assets/icons/dc-join-round.svg")),
|
||||
("dc-knife", include_bytes!("../../../assets/icons/dc-knife.svg")),
|
||||
("dc-line", include_bytes!("../../../assets/icons/dc-line.svg")),
|
||||
("dc-line-cut", include_bytes!("../../../assets/icons/dc-line-cut.svg")),
|
||||
("dc-list-view", include_bytes!("../../../assets/icons/dc-list-view.svg")),
|
||||
("dc-live-bucket", include_bytes!("../../../assets/icons/dc-live-bucket.svg")),
|
||||
("dc-live-select", include_bytes!("../../../assets/icons/dc-live-select.svg")),
|
||||
("dc-mask-none", include_bytes!("../../../assets/icons/dc-mask-none.svg")),
|
||||
("dc-measure", include_bytes!("../../../assets/icons/dc-measure.svg")),
|
||||
("dc-mesh", include_bytes!("../../../assets/icons/dc-mesh.svg")),
|
||||
("dc-mirror-cut", include_bytes!("../../../assets/icons/dc-mirror-cut.svg")),
|
||||
("dc-new-fill", include_bytes!("../../../assets/icons/dc-new-fill.svg")),
|
||||
("dc-new-item", include_bytes!("../../../assets/icons/dc-new-item.svg")),
|
||||
("dc-new-stroke", include_bytes!("../../../assets/icons/dc-new-stroke.svg")),
|
||||
|
|
@ -137,6 +139,7 @@ pub static ICONS: &[(&str, &[u8])] = &[
|
|||
("dc-pucker", include_bytes!("../../../assets/icons/dc-pucker.svg")),
|
||||
("dc-puppet", include_bytes!("../../../assets/icons/dc-puppet.svg")),
|
||||
("dc-rearrange", include_bytes!("../../../assets/icons/dc-rearrange.svg")),
|
||||
("dc-rect-cut", include_bytes!("../../../assets/icons/dc-rect-cut.svg")),
|
||||
("dc-rect-grid", include_bytes!("../../../assets/icons/dc-rect-grid.svg")),
|
||||
("dc-reference-point", include_bytes!("../../../assets/icons/dc-reference-point.svg")),
|
||||
("dc-remove-brush", include_bytes!("../../../assets/icons/dc-remove-brush.svg")),
|
||||
|
|
|
|||
|
|
@ -64,6 +64,9 @@ pub fn tool_icon(name: &str) -> &'static str {
|
|||
"tool-eraser" => "eraser",
|
||||
"tool-scissors" => "scissors",
|
||||
"tool-knife" => "dc-knife",
|
||||
"tool-mirror-cut" => "dc-mirror-cut",
|
||||
"tool-line-cut" => "dc-line-cut",
|
||||
"tool-rect-cut" => "dc-rect-cut",
|
||||
"tool-rotate" => "rotate-ccw",
|
||||
"tool-reflect" => "flip-horizontal-2",
|
||||
"tool-scale" => "scaling",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ mod tests_background;
|
|||
#[cfg(test)]
|
||||
mod tests_clipboard;
|
||||
#[cfg(test)]
|
||||
mod tests_cut;
|
||||
#[cfg(test)]
|
||||
mod tests_docsetup;
|
||||
#[cfg(test)]
|
||||
mod tests_home;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
//! The cutting tools in the UI: toolbar entries, icons and Mirror & Cut's Control bar options.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use crate::{VectorcraftApp, chrome, icons, tests_labels::painted_text};
|
||||
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cut_tools_have_their_own_icons_and_toolbar_slot() {
|
||||
for (tool, icon) in [("mirrorCut", "dc-mirror-cut"), ("lineCut", "dc-line-cut"), ("rectCut", "dc-rect-cut")] {
|
||||
let info = vectorcraft_tools::tool_info(tool).unwrap();
|
||||
assert_eq!(icons::tool_icon(info.icon), icon);
|
||||
assert!(icons::exists(icon), "{icon}");
|
||||
assert!(crate::toolbar::BASIC.iter().flat_map(|(_, slots)| slots.iter()).any(|s| s.contains(&tool) && s.contains(&"knife")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_control_bar_shows_mirror_and_cut_options() {
|
||||
let mut app = app();
|
||||
app.run("tool.select", json!({"tool": "lineCut"})).unwrap();
|
||||
assert!(!painted_text(&mut app, chrome::control_bar).contains("Axis:"), "only Mirror & Cut has options");
|
||||
app.run("tool.select", json!({"tool": "mirrorCut"})).unwrap();
|
||||
let text = painted_text(&mut app, chrome::control_bar);
|
||||
for s in ["Axis:", "Free", "Keep:", "Left"] {
|
||||
assert!(text.lines().any(|l| l == s), "{s} in {text}");
|
||||
}
|
||||
app.run("tool.setOption", json!({"key": "axis", "value": "horizontal"})).unwrap();
|
||||
app.run("tool.setOption", json!({"key": "keep", "value": "bottom"})).unwrap();
|
||||
let text = painted_text(&mut app, chrome::control_bar);
|
||||
assert!(text.lines().any(|l| l == "Horizontal") && text.lines().any(|l| l == "Bottom"), "{text}");
|
||||
}
|
||||
|
|
@ -29,7 +29,7 @@ pub const BASIC: &[(&str, &[&[&str]])] = &[
|
|||
"Draw",
|
||||
&[
|
||||
&["pencil", "smooth", "pathEraser", "join"],
|
||||
&["eraser", "scissors", "knife"],
|
||||
&["eraser", "scissors", "knife", "mirrorCut", "lineCut", "rectCut"],
|
||||
&["paintbrush", "blobBrush"],
|
||||
&["pen", "addAnchor", "deleteAnchor", "anchorPoint"],
|
||||
&["curvature"],
|
||||
|
|
@ -217,6 +217,32 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<serde_json::
|
|||
}
|
||||
}
|
||||
|
||||
/// The active tool's options in the Control bar: Mirror & Cut's axis and the side it keeps (set
|
||||
/// through `tool.setOption`).
|
||||
pub fn control_bar_options(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
/// (value, label) of each choice.
|
||||
type Choices = &'static [(&'static str, &'static str)];
|
||||
const MIRROR: [(&str, &str, Choices); 2] = [
|
||||
("axis", "Axis:", &[("free", "Free"), ("vertical", "Vertical"), ("horizontal", "Horizontal")]),
|
||||
("keep", "Keep:", &[("left", "Left"), ("right", "Right"), ("top", "Top"), ("bottom", "Bottom")]),
|
||||
];
|
||||
if app.session.tool_id() != "mirrorCut" {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let opts = app.session.tool_options();
|
||||
for (key, label, choices) in MIRROR {
|
||||
ui.label(egui::RichText::new(label).size(12.0).color(t.text));
|
||||
let cur = opts[key].as_str().unwrap_or_default();
|
||||
let shown = choices.iter().find(|(v, _)| *v == cur).map_or(cur, |(_, l)| *l);
|
||||
let labels: Vec<&str> = choices.iter().map(|(_, l)| *l).collect();
|
||||
if let Some((value, _)) = widgets::dropdown(ui, ("cb-tool", key), shown, &labels, 96.0).and_then(|i| choices.get(i)) {
|
||||
app.run("tool.setOption", json!({ "key": key, "value": value })).ok();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
}
|
||||
|
||||
fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens) {
|
||||
ui.vertical_centered(|ui| crate::panels::proxy(app, ui, 36.0));
|
||||
ui.add_space(5.0);
|
||||
|
|
|
|||
|
|
@ -429,3 +429,10 @@ macOS, Ctrl elsewhere), and the app holds them for the frames the input spans, a
|
|||
press, its `text` and its release; a click's press and release; a drag from the press through every move to the
|
||||
release. Every handler sees them (Shift+arrow nudges by the big increment, Alt+arrow nudges a copy, Shift-clicking a
|
||||
Layers selection square adds to the selection); the keyboard's own modifiers apply again afterwards.
|
||||
|
||||
Cutting tools: Mirror & Cut (`mirrorCut`), Line Cut (`lineCut`) and Rectangle Cut (`rectCut`) cut the selected paths
|
||||
and compound paths. `ui.tool.select` one, then drag with `ui.pointer`: the cut previews live and the release keeps it
|
||||
as one undo step (`ui.key` Escape cancels). They run `path.mirrorCut`, `path.lineCut` and `path.rectCut`, which an
|
||||
agent can call directly. Mirror & Cut's tool options (`tool.setOption`, shown in the Control bar) are `axis`
|
||||
(`free` | `vertical` | `horizontal`; a constrained axis follows the pointer and a click places it) and `keep`
|
||||
(`left` | `right` | `top` | `bottom`); Alt on release keeps the other side.
|
||||
|
|
|
|||
Loading…
Reference in New Issue