mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 08:29:14 +00:00
Merge pull request #6964 from dotCooCoo/fix/shaper-stroke-click-construction
Shaper: a click on an original shape's outline enters Construction mode
This commit is contained in:
@@ -2,7 +2,9 @@
|
||||
//! 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::pathops::Beside;
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, f64_or, has_doc, id_param, object_param, point_param};
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, f64_or, has_doc, id_param, number_or, object_param, point_param,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
@@ -34,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Select Shaper Face",
|
||||
[],
|
||||
None,
|
||||
"{point: [x,y], source?: bool} click selects a group, then a face; source enters construction isolation and selects an original (while an opacity mask or a pattern is edited, it selects the original and the editing layer stays isolated); while a group's originals are isolated, it selects one of that group's originals → {id}",
|
||||
"{point: [x,y], source?: bool, tolerance?: pt (3)} click selects a group, then a face; with the group or one of its faces selected, a click within tolerance of an original shape's outline enters construction isolation and selects that original; source enters construction isolation and selects an original (while an opacity mask or a pattern is edited, it selects the original and the editing layer stays isolated); while a group's originals are isolated, it selects one of that group's originals → {id}",
|
||||
has_doc,
|
||||
select
|
||||
),
|
||||
@@ -734,16 +736,50 @@ fn group_at(d: &Document, point: Point, construction: bool, within: Option<NodeI
|
||||
});
|
||||
groups.last().copied()
|
||||
}
|
||||
/// The topmost editable Shaper Group that is selected, or has a face selected, with a visible,
|
||||
/// unlocked original shape whose outline lies within `tolerance` of `point`, and the topmost such
|
||||
/// shape in it; `None` when a Shaper Group that [`group_at`] finds at `point` lies above it.
|
||||
fn outline_at(d: &Document, selection: &vectorcraft_doc::Selection, point: Point, tolerance: f64) -> Option<(NodeId, NodeId)> {
|
||||
let mut hit = None;
|
||||
let editable = d.editable();
|
||||
d.walk(|n| {
|
||||
if n.extras().shaper.is_none() || !editable(n.id) {
|
||||
return;
|
||||
}
|
||||
let picked = selection.contains(n.id) || selection.objects.iter().any(|id| d.parent_of(*id) == Some(n.id));
|
||||
let on_outline =
|
||||
|s: &&Arc<Node>| b::node_outline(s).is_some_and(|(path, _)| path.nearest(point).is_some_and(|(_, _, _, _, dist)| dist <= tolerance));
|
||||
let source = if picked { sources(n).iter().rev().filter(|s| s.visible && !s.locked).find(on_outline).map(|s| s.id) } else { None };
|
||||
if let Some(source) = source {
|
||||
hit = Some((n.id, source));
|
||||
} else if n.geometric_bounds().is_some_and(|b| b.inflate(6.0, 6.0).contains(point)) {
|
||||
hit = None;
|
||||
}
|
||||
});
|
||||
hit
|
||||
}
|
||||
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 tolerance = number_or(p, "tolerance", 3.0, "shaper.select")?;
|
||||
if !tolerance.is_finite() || !(0.0..=1000.0).contains(&tolerance) {
|
||||
return Err(bad("shaper.select", "invalid tolerance"));
|
||||
}
|
||||
// In Construction mode a source pick stays in the group whose source shapes are isolated.
|
||||
let isolated =
|
||||
st.isolation.and_then(|i| st.doc.parent_of(i)).filter(|g| st.doc.node(*g).and_then(source_group).is_some_and(|s| st.isolation == Some(s.id)));
|
||||
let Some(group) = group_at(&st.doc, point, source, isolated.filter(|_| source)) else { return Ok(json!({"id": null})) };
|
||||
// With a group or one of its faces selected, a click on one of its original shapes' outlines
|
||||
// enters Construction mode with the topmost such shape selected, wherever the outline lies.
|
||||
let outline = if source { None } else { outline_at(&st.doc, &st.selection, point, tolerance) };
|
||||
let Some(group) = outline.map(|(g, _)| g).or_else(|| group_at(&st.doc, point, source, isolated.filter(|_| source))) else {
|
||||
return Ok(json!({"id": null}));
|
||||
};
|
||||
let node = st.doc.node(group).ok_or(EngineError::NoNode(group))?;
|
||||
let target = if source {
|
||||
let source = source || outline.is_some();
|
||||
let target = if let Some((_, id)) = outline {
|
||||
id
|
||||
} else if source {
|
||||
sources(node)
|
||||
.iter()
|
||||
.rev()
|
||||
|
||||
@@ -259,6 +259,93 @@ fn shaper_double_click_selects_an_original_for_construction_editing() {
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![g]);
|
||||
}
|
||||
|
||||
/// With a Shaper Group or one of its faces selected, a Shaper click on an original shape's outline
|
||||
/// enters Construction mode with that shape selected, even where the outline lies inside a merged
|
||||
/// face. A click away from every outline still selects a face, and a click on an outline of a group
|
||||
/// that is not selected selects the group.
|
||||
#[test]
|
||||
fn shaper_click_on_an_original_outline_enters_construction_mode() {
|
||||
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]]);
|
||||
let sources = node(&s, g).children().unwrap()[0].id;
|
||||
let v = ViewInfo { smart_guides: false, ..Default::default() };
|
||||
s.select_tool("shaper", v).unwrap();
|
||||
let click = |s: &mut Session, x: f64, y: f64| {
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(kind, x, y), v).unwrap();
|
||||
}
|
||||
};
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
click(&mut s, 101.0, 50.0);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![g]);
|
||||
assert_eq!(s.doc().unwrap().isolation, None);
|
||||
// The right side of the back shape runs through the merged face.
|
||||
click(&mut s, 101.0, 50.0);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(sources));
|
||||
s.execute("object.exitIsolation", &json!({})).unwrap();
|
||||
// A click inside the face, away from the outlines, selects the face.
|
||||
click(&mut s, 75.0, 50.0);
|
||||
let face = s.doc().unwrap().selection.objects.clone();
|
||||
assert_eq!(face.len(), 1);
|
||||
assert_ne!(face, vec![g]);
|
||||
assert_eq!(s.doc().unwrap().isolation, None);
|
||||
// With the face selected, a click on the front shape's outline picks that shape.
|
||||
click(&mut s, 149.0, 50.0);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![b]);
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(sources));
|
||||
s.execute("object.exitIsolation", &json!({})).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
for tolerance in [json!("3"), json!(-1), json!(1e9)] {
|
||||
assert!(s.execute("shaper.select", &json!({"point": [101, 50], "tolerance": tolerance})).is_err(), "{tolerance}");
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![g]);
|
||||
assert_eq!(s.doc().unwrap().isolation, None);
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
s.execute("shaper.select", &json!({"point": [104, 50], "tolerance": 5})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
|
||||
s.execute("object.exitIsolation", &json!({})).unwrap();
|
||||
// A wide tolerance (a zoomed-out view) reaches an outline from well outside the group's bounds.
|
||||
s.execute("shaper.select", &json!({"point": [165, 50], "tolerance": 20})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![b]);
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(sources));
|
||||
// A locked or hidden original shape is not picked by its outline.
|
||||
for hide in ["object.lock", "object.hide"] {
|
||||
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
|
||||
s.execute(hide, &json!({})).unwrap();
|
||||
s.execute("object.exitIsolation", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
|
||||
s.execute("shaper.select", &json!({"point": [149, 50]})).unwrap();
|
||||
assert_ne!(s.doc().unwrap().selection.objects, vec![b], "{hide}");
|
||||
assert_eq!(s.doc().unwrap().isolation, None, "{hide}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A click on a selected Shaper Group's original outline where another Shaper Group lies above it
|
||||
/// selects the group above, as a click there did before.
|
||||
#[test]
|
||||
fn shaper_outline_click_under_another_group_selects_the_group_above() {
|
||||
let mut s = session();
|
||||
rectangle(&mut s, 0.0, "#ff0000");
|
||||
rectangle(&mut s, 50.0, "#0000ff");
|
||||
let lower = scribble(&mut s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]);
|
||||
let c = rectangle(&mut s, 20.0, "#00ff00");
|
||||
let d = rectangle(&mut s, 70.0, "#ffff00");
|
||||
let upper = NodeId(
|
||||
s.execute("shaper.scribble", &json!({"points": [[45, 20], [95, 35], [50, 50], [145, 65]], "ids": [c.0, d.0]})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
);
|
||||
s.execute("select.set", &json!({"ids": [lower.0]})).unwrap();
|
||||
s.execute("shaper.select", &json!({"point": [100, 50]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![upper]);
|
||||
assert_eq!(s.doc().unwrap().isolation, None);
|
||||
}
|
||||
|
||||
/// The arrow widget beside a selected Shaper Group switches it to Construction mode and back, with
|
||||
/// the Shaper tool and with the Selection tool. In Construction mode a Shaper tap selects a source
|
||||
/// shape, and the mode lasts through undo and redo of an edit made in it.
|
||||
|
||||
@@ -559,10 +559,11 @@ impl Tool for ShaperTool {
|
||||
}
|
||||
let pts = std::mem::take(&mut self.points);
|
||||
if pts.first().is_some_and(|first| pts.iter().all(|p| p.distance(*first) < cx.tol(3.0))) {
|
||||
// In Construction mode a tap selects a source shape.
|
||||
// In Construction mode a tap selects a source shape. On a selected group, a tap on a
|
||||
// source shape's outline enters Construction mode.
|
||||
let construction =
|
||||
cx.isolation.and_then(|id| cx.doc.node(id)).is_some_and(|n| n.name.as_deref() == Some(vectorcraft_doc::shaper::SOURCES));
|
||||
let mut params = json!({"point": [ev.pos.x, ev.pos.y]});
|
||||
let mut params = json!({"point": [ev.pos.x, ev.pos.y], "tolerance": cx.tol(3.0).min(1000.0)});
|
||||
if construction {
|
||||
params["source"] = json!(true);
|
||||
}
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ Scribble over overlapping paths to make an editable Shaper Group:
|
||||
- End the scribble outside the artwork to remove the touched fills and exposed boundary strokes. Shared edges along surviving filled areas remain, closing their contours. Compatible stroke pieces join at the new corners, so the selected stroke’s join style applies there.
|
||||
- Scribble over an unfilled stroke segment to remove that piece. A line passing through a shape is split at the intersections, so its protruding ends can be removed separately.
|
||||
|
||||
Click once to select the group; click again to select its visible fill and change its color. Double-click with Shaper to enter construction editing and select an original shape. The Selection tool then moves or resizes it normally, and the visible composition updates. Escape leaves construction editing. The original live shapes and edit recipes survive native save/reopen. Outside construction editing, selection highlights and bounds show only the visible result. Outside construction editing, a marquee selects the group or its faces only where it reaches the visible result, and Direct Selection's clicks and marquee select only the anchor points of the visible result.
|
||||
Click once to select the group; click again to select its visible fill and change its color. Double-click with Shaper to enter construction editing and select an original shape. With the group or one of its faces selected, a Shaper click on an original shape's outline also enters construction editing and selects that shape, even where the outline runs through a merged face ([help page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/create-shapes-with-the-shaper-tool.html)). The click counts within 3 screen pixels of the outline, a VectorCraft value. The Selection tool then moves or resizes it normally, and the visible composition updates. Escape leaves construction editing. The original live shapes and edit recipes survive native save/reopen. Outside construction editing, selection highlights and bounds show only the visible result. Outside construction editing, a marquee selects the group or its faces only where it reaches the visible result, and Direct Selection's clicks and marquee select only the anchor points of the visible result.
|
||||
|
||||
While the group or one of its faces is selected (Face Selection mode) and the Shaper or Selection tool is active, an arrow widget sits beside the middle of the right side of the original shapes’ bounds. Click it to enter Construction mode: the original shapes are isolated and nothing is selected, and a click with Shaper selects the group’s original shape under the pointer. Click the widget again to leave Construction mode with the group selected. The arrow points right in Face Selection mode and left in Construction mode.
|
||||
|
||||
|
||||
@@ -944,6 +944,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Tools:**
|
||||
- **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position. Rectangles/squares and ellipses snap to 45° steps, hexagons to 90°, and triangles point up or down; lines keep their free angle even with Shift. Shaper Groups retain the live originals, merge regions with the gesture-origin fill, punch fills independently from strokes, remove boundaries on exiting scribbles and trim protruding stroke pieces. Source moves/resizes re-evaluate the result in the same undo step; double-click enters construction editing, and with the Shaper or Selection tool a click on the arrow widget beside a Shaper Group's original shapes switches the group between Face Selection and Construction mode. Native save/reopen, face recoloring, Release and Expand are covered. Finer gesture heuristics remain.
|
||||
In Construction mode, a Selection-tool drag that drops an original shape wholly outside the bounds of the group's original shapes takes it out of the group in the move's undo step (Remove from Shaper Group, `shaper.releaseSource`): the shape becomes a path above the group and stays selected, and a group left with no shapes is removed. Engine tests drag a shape out of a two-shape group and back across the bounds, take the last shape out, undo, and save the result.
|
||||
With a Shaper Group or one of its faces selected, a Shaper click within 3 screen pixels of an original shape's outline enters Construction mode with that shape selected, even where the outline runs through a merged face (`shaper.select`'s `tolerance`; [help page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/create-shapes-with-the-shaper-tool.html)). Test: `tests::shaper::shaper_click_on_an_original_outline_enters_construction_mode`.
|
||||
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it).
|
||||
The search for the segment of a path nearest a point, which Direct Selection runs to pick the cursor over a segment, skips each segment whose anchors and handles lie in a box farther from the point than a segment already measured, starting with a segment at the anchor nearest the point. The segment found is the same as before. Over a segment of an open path of 200,000 anchors, the cursor takes 51 ms instead of 59 ms in a release build (`cargo run --release -p vectorcraft-engine --example anchor_bench`).
|
||||
A Direct Selection marquee over part of a segment, with neither of its anchors inside, selects that segment as a click on it does (Shift toggles it), as [Selecting objects](http://web.archive.org/web/20211230015528/https://helpx.adobe.com/illustrator/using/selecting-objects.html) describes (#4654).
|
||||
|
||||
Reference in New Issue
Block a user