mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Shaper: an arrow widget switches a Shaper Group between Face Selection and Construction mode
A Shaper Group had no arrow widget for switching between Face Selection and Construction mode. With the Shaper or Selection tool active, a selected Shaper Group, or a group with one of its faces selected, now shows an arrow beside the middle of the right side of its source shapes' bounds. A click on it isolates the source shapes (object.isolate). A second click leaves isolation with the group selected (object.exitIsolation). The arrow points right in Face Selection mode and left in Construction mode. In Construction mode a Shaper click selects an original shape of the isolated group. Before, it selected the whole group outside the isolation, and shaper.select with source could pick an original of another Shaper Group lying above it. Docs: docs/shaper.md, docs/target-app-parity.md.
This commit is contained in:
@@ -31,7 +31,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 → {id}",
|
||||
"{point: [x,y], source?: bool} click selects a group, then a face; source enters construction isolation and selects an original; while a group's originals are isolated, it selects one of that group's originals → {id}",
|
||||
has_doc,
|
||||
select
|
||||
),
|
||||
@@ -687,10 +687,13 @@ fn scribble(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"id": id.0, "regions": touched.len(), "strokes": strokes}))
|
||||
}
|
||||
|
||||
fn group_at(d: &Document, point: Point, construction: bool) -> Option<NodeId> {
|
||||
/// The topmost editable Shaper Group at `point`, measured by its source shapes in `construction`.
|
||||
/// When `within` is given, only that group is considered.
|
||||
fn group_at(d: &Document, point: Point, construction: bool, within: Option<NodeId>) -> Option<NodeId> {
|
||||
let mut groups = vec![];
|
||||
d.walk(|n| {
|
||||
if n.extras().shaper.is_some()
|
||||
&& within.is_none_or(|g| g == n.id)
|
||||
&& 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))
|
||||
@@ -704,7 +707,10 @@ 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})) };
|
||||
// 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})) };
|
||||
let node = st.doc.node(group).ok_or(EngineError::NoNode(group))?;
|
||||
let target = if source {
|
||||
sources(node)
|
||||
|
||||
@@ -217,6 +217,86 @@ fn shaper_double_click_selects_an_original_for_construction_editing() {
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![g]);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[test]
|
||||
fn shaper_widget_switches_between_face_selection_and_construction_mode() {
|
||||
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 sources = node(&s, g).children().unwrap()[0].id;
|
||||
let v = ViewInfo { smart_guides: false, ..Default::default() };
|
||||
let widget = |s: &Session| {
|
||||
let st = s.doc().unwrap();
|
||||
vectorcraft_tools::shaperwidget::ShaperWidget::of(&st.doc, &st.selection, st.isolation, v.zoom).unwrap()
|
||||
};
|
||||
let click = |s: &mut Session, p: Point| {
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(kind, p.x, p.y), v).unwrap();
|
||||
}
|
||||
};
|
||||
for tool in ["shaper", "selection"] {
|
||||
s.select_tool(tool, v).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
|
||||
let w = widget(&s);
|
||||
assert!(!w.construction);
|
||||
click(&mut s, w.at);
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(sources), "{tool}");
|
||||
assert!(widget(&s).construction);
|
||||
if tool == "shaper" {
|
||||
click(&mut s, Point::new(25.0, 50.0));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
|
||||
} else {
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
}
|
||||
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(sources), "{tool}: undo");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(sources), "{tool}: redo");
|
||||
assert_eq!(fill(&s, g, 15.0, 50.0).as_deref(), Some("#ff0000"));
|
||||
let w = widget(&s);
|
||||
assert!(w.construction);
|
||||
click(&mut s, w.at);
|
||||
assert_eq!(s.doc().unwrap().isolation, None, "{tool}");
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![g]);
|
||||
s.execute("object.move", &json!({"dx": -10, "dy": 0})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// In Construction mode a Shaper tap picks a source shape of the isolated group only, even where
|
||||
/// another Shaper Group's source shapes lie above it.
|
||||
#[test]
|
||||
fn shaper_construction_taps_stay_in_the_isolated_group() {
|
||||
let mut s = session();
|
||||
let a = 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(),
|
||||
);
|
||||
assert_ne!(lower, upper);
|
||||
let sources = node(&s, lower).children().unwrap()[0].id;
|
||||
s.execute("object.isolate", &json!({"id": lower.0})).unwrap();
|
||||
let v = ViewInfo { smart_guides: false, ..Default::default() };
|
||||
s.select_tool("shaper", v).unwrap();
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(kind, 25.0, 50.0), v).unwrap();
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(sources));
|
||||
// Outside the isolated group's source shapes, a tap changes nothing.
|
||||
s.execute("shaper.select", &json!({"point": [160, 50], "source": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(sources));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaper_lines_keep_free_angles_with_or_without_shift() {
|
||||
for shift in [false, true] {
|
||||
|
||||
@@ -324,6 +324,10 @@ impl Tool for ShaperTool {
|
||||
use vectorcraft_geom::recognize::{Recognized, recognize};
|
||||
match ev.kind {
|
||||
PointerKind::Down => {
|
||||
if let Some(a) = crate::shaperwidget::click(cx, ev.pos) {
|
||||
self.points.clear();
|
||||
return vec![a];
|
||||
}
|
||||
self.points = vec![ev.pos];
|
||||
vec![]
|
||||
}
|
||||
@@ -340,7 +344,14 @@ 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))) {
|
||||
return vec![Action::Exec("shaper.select".into(), json!({"point": [ev.pos.x, ev.pos.y]}))];
|
||||
// In Construction mode a tap selects a source shape.
|
||||
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]});
|
||||
if construction {
|
||||
params["source"] = json!(true);
|
||||
}
|
||||
return vec![Action::Exec("shaper.select".into(), params)];
|
||||
}
|
||||
let r = |x: vectorcraft_geom::Rect, rotation: f64| json!({ "x": x.x0, "y": x.y0, "width": x.width(), "height": x.height(), "rotation": rotation });
|
||||
match recognize(&pts) {
|
||||
@@ -360,6 +371,8 @@ impl Tool for ShaperTool {
|
||||
None => vec![],
|
||||
}
|
||||
}
|
||||
// The mode widget answers each click of a double-click on it.
|
||||
PointerKind::DoubleClick if crate::shaperwidget::over_widget(cx, ev.pos) => vec![],
|
||||
PointerKind::DoubleClick => vec![
|
||||
Action::Exec("shaper.select".into(), json!({"point": [ev.pos.x, ev.pos.y], "source": true})),
|
||||
Action::SwitchTool("selection".into()),
|
||||
|
||||
@@ -31,6 +31,7 @@ pub mod rulerguide;
|
||||
pub mod select;
|
||||
pub mod settings;
|
||||
pub mod shape;
|
||||
pub mod shaperwidget;
|
||||
pub mod slice;
|
||||
pub mod symbolism;
|
||||
pub mod text;
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
//! mode (Double Click To Isolate), Cmd/Ctrl-click to select the object behind (Command Click to Select Objects Behind),
|
||||
//! click or drag a ruler guide ([`crate::rulerguide`]), drag the brackets of type on a path
|
||||
//! ([`crate::pathtype`]), double-click the type widget to convert point type to area type and back
|
||||
//! ([`crate::typewidget`]).
|
||||
//! ([`crate::typewidget`]), click a Shaper Group's mode widget to switch between Face Selection and
|
||||
//! Construction mode ([`crate::shaperwidget`]).
|
||||
//! The bounding box stands at the selection's own angle after a rotation, so its handles scale
|
||||
//! along the objects' axes. A handle drag resizes area type's frame (the type area) instead of
|
||||
//! scaling its type: the text reflows at its size.
|
||||
@@ -22,6 +23,7 @@ use crate::corners::{self, CornerDrag, over_widget};
|
||||
use crate::guides::Targets;
|
||||
use crate::pathtype::{self, BracketDrag, over_bracket};
|
||||
use crate::rulerguide::GuideEdit;
|
||||
use crate::shaperwidget;
|
||||
use crate::typewidget;
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, json_ids};
|
||||
|
||||
@@ -275,6 +277,10 @@ impl Tool for SelectionTool {
|
||||
if let Some(a) = corners::double_click(cx, p, false).or_else(|| typewidget::double_click(cx, p)) {
|
||||
return vec![a];
|
||||
}
|
||||
// The Shaper Group's mode widget answers each click of a double-click on it.
|
||||
if shaperwidget::over_widget(cx, p) {
|
||||
return vec![];
|
||||
}
|
||||
if crate::rulerguide::guide_at(cx, p).is_some() {
|
||||
return vec![];
|
||||
}
|
||||
@@ -303,6 +309,10 @@ impl Tool for SelectionTool {
|
||||
self.state = State::Bracket(b);
|
||||
return vec![];
|
||||
}
|
||||
if let Some(a) = shaperwidget::click(cx, p) {
|
||||
self.state = State::Idle;
|
||||
return vec![a];
|
||||
}
|
||||
// The type widget answers a double-click only: its clicks neither drag nor deselect.
|
||||
if typewidget::over_widget(cx, p) {
|
||||
self.state = State::Idle;
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
//! The Shaper Group's mode widget: a small arrow beside the right side of the bounds of a Shaper
|
||||
//! Group's source shapes. It shows while the group or one of its faces is selected (Face Selection
|
||||
//! mode) and while the group's source shapes are isolated (Construction mode). A click on it
|
||||
//! switches between the two modes: it isolates the source shapes (`object.isolate`), or it leaves
|
||||
//! isolation with the group selected (`object.exitIsolation`). The Shaper and Selection tools hit
|
||||
//! it and the canvas draws it from the same geometry.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::shaper::SOURCES;
|
||||
use vectorcraft_doc::{Document, NodeId, Selection};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use crate::{Action, ToolContext};
|
||||
|
||||
/// The widget's center sits this far (screen px) outside the middle of the bounds' right side.
|
||||
const OFFSET_PX: f64 = 16.0;
|
||||
/// The widget's radius (screen px), as the canvas draws it.
|
||||
pub const RADIUS_PX: f32 = 6.0;
|
||||
/// How near (screen px) the pointer must be to hit it.
|
||||
const HIT_PX: f64 = 7.0;
|
||||
|
||||
/// The mode widget of an editable Shaper Group.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ShaperWidget {
|
||||
pub group: NodeId,
|
||||
/// Center in document coordinates.
|
||||
pub at: Point,
|
||||
/// Construction mode: the group's source shapes are isolated.
|
||||
pub construction: bool,
|
||||
}
|
||||
|
||||
impl ShaperWidget {
|
||||
/// The widget at `zoom` (screen px per document point), beside the bounds of the group's source
|
||||
/// shapes in both modes. Construction mode: `isolation` is a Shaper Group's source shapes. Face
|
||||
/// Selection mode: the selection is one Shaper Group, or one face or stroke of it. It returns
|
||||
/// None otherwise, and when the group, or a layer or group that holds it, is hidden or locked.
|
||||
pub fn of(doc: &Document, selection: &Selection, isolation: Option<NodeId>, zoom: f64) -> Option<Self> {
|
||||
if !zoom.is_finite() || zoom <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let shaper = |id: NodeId| doc.node(id).is_some_and(|n| n.extras().shaper.is_some());
|
||||
let sources = isolation.filter(|i| doc.node(*i).is_some_and(|n| n.name.as_deref() == Some(SOURCES)));
|
||||
let (group, construction) = match sources {
|
||||
Some(s) => (doc.parent_of(s).filter(|g| shaper(*g))?, true),
|
||||
None => {
|
||||
let [id] = selection.objects[..] else { return None };
|
||||
let group = if shaper(id) { id } else { doc.parent_of(id).filter(|g| shaper(*g))? };
|
||||
(group, false)
|
||||
}
|
||||
};
|
||||
if !doc.is_editable(group) {
|
||||
return None;
|
||||
}
|
||||
let b = doc.node(group)?.children()?.first().filter(|n| n.name.as_deref() == Some(SOURCES))?.geometric_bounds()?;
|
||||
Some(Self { group, at: Point::new(b.x1 + OFFSET_PX / zoom, b.center().y), construction })
|
||||
}
|
||||
|
||||
/// The widget of `cx` if it is under `p`.
|
||||
fn at(cx: &ToolContext, p: Point) -> Option<Self> {
|
||||
Self::of(cx.doc, cx.selection, cx.isolation, cx.zoom).filter(|w| w.at.distance(p) <= cx.tol(HIT_PX))
|
||||
}
|
||||
|
||||
/// The command switching to the other mode.
|
||||
pub fn action(&self) -> Action {
|
||||
if self.construction {
|
||||
Action::Exec("object.exitIsolation".into(), json!({}))
|
||||
} else {
|
||||
Action::Exec("object.isolate".into(), json!({ "id": self.group.0 }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `p` over the Shaper Group's mode widget?
|
||||
pub fn over_widget(cx: &ToolContext, p: Point) -> bool {
|
||||
ShaperWidget::at(cx, p).is_some()
|
||||
}
|
||||
|
||||
/// A click on the mode widget switches between Face Selection and Construction mode.
|
||||
pub fn click(cx: &ToolContext, p: Point) -> Option<Action> {
|
||||
ShaperWidget::at(cx, p).map(|w| w.action())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::shaper::FILL_PREFIX;
|
||||
use vectorcraft_doc::{Appearance, Node};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
use super::*;
|
||||
use crate::extra::ShaperTool;
|
||||
use crate::select::SelectionTool;
|
||||
use crate::testutil::*;
|
||||
use crate::{PointerEvent, PointerKind, Tool};
|
||||
|
||||
fn ev(kind: PointerKind, p: Point) -> PointerEvent {
|
||||
PointerEvent::new(kind, p.x, p.y)
|
||||
}
|
||||
|
||||
/// A Shaper Group of two source squares (x from 0 to 100 and from 50 to 150, y from 300 to 400)
|
||||
/// whose visible face is the first square: the document, the group, its source shapes and the
|
||||
/// face.
|
||||
fn doc_with_shaper_group() -> (Document, NodeId, NodeId, NodeId) {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let square = |x: f64| shapes::rectangle(Rect::new(x, 300.0, x + 100.0, 400.0));
|
||||
let a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
|
||||
let b = Node::path(d.alloc_id(), square(50.0), Appearance::default_art());
|
||||
let mut sources = Node::group(d.alloc_id(), vec![Arc::new(a), Arc::new(b)]);
|
||||
sources.name = Some(SOURCES.into());
|
||||
sources.opacity = 0.0;
|
||||
let mut face = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
|
||||
face.name = Some(format!("{FILL_PREFIX}0"));
|
||||
let (sources_id, face_id) = (sources.id, face.id);
|
||||
let mut group = Node::group(d.alloc_id(), vec![Arc::new(sources), Arc::new(face)]);
|
||||
group.extras_mut().shaper = Some(Box::default());
|
||||
let group_id = group.id;
|
||||
d.insert(Some(l), 1, group).unwrap();
|
||||
(d, group_id, sources_id, face_id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_widget_sits_right_of_the_group_and_a_click_switches_the_mode() {
|
||||
let (d, g, sources, face) = doc_with_shaper_group();
|
||||
let p = paint();
|
||||
// Face Selection mode: the group, or one of its faces, selected; the widget beside the
|
||||
// source shapes' bounds, though the visible face ends at 100.
|
||||
for selected in [g, face] {
|
||||
let mut s = Selection::default();
|
||||
s.add(selected);
|
||||
let c = cx(&d, &s, &p);
|
||||
let w = ShaperWidget::of(c.doc, c.selection, c.isolation, c.zoom).unwrap();
|
||||
assert_eq!((w.group, w.construction, w.at), (g, false, Point::new(166.0, 350.0)));
|
||||
let isolate = vec![Action::Exec("object.isolate".into(), json!({ "id": g.0 }))];
|
||||
assert_eq!(SelectionTool::default().pointer(&c, &ev(PointerKind::Down, w.at)), isolate);
|
||||
assert_eq!(ShaperTool::default().pointer(&c, &ev(PointerKind::Down, w.at + vectorcraft_geom::Vec2::new(0.0, 5.0))), isolate);
|
||||
// A double-click on it does nothing more than its two clicks.
|
||||
assert_eq!(SelectionTool::default().pointer(&c, &ev(PointerKind::DoubleClick, w.at)), vec![]);
|
||||
assert_eq!(ShaperTool::default().pointer(&c, &ev(PointerKind::DoubleClick, w.at)), vec![]);
|
||||
}
|
||||
// Construction mode: the source shapes isolated; the widget in the same place, and a click
|
||||
// leaves the mode.
|
||||
let s = Selection::default();
|
||||
let c = ToolContext { isolation: Some(sources), ..cx(&d, &s, &p) };
|
||||
let w = ShaperWidget::of(c.doc, c.selection, c.isolation, c.zoom).unwrap();
|
||||
assert_eq!((w.group, w.construction, w.at), (g, true, Point::new(166.0, 350.0)));
|
||||
let exit = vec![Action::Exec("object.exitIsolation".into(), json!({}))];
|
||||
assert_eq!(SelectionTool::default().pointer(&c, &ev(PointerKind::Down, w.at)), exit);
|
||||
assert_eq!(ShaperTool::default().pointer(&c, &ev(PointerKind::Down, w.at)), exit);
|
||||
assert_eq!(SelectionTool::default().pointer(&c, &ev(PointerKind::DoubleClick, w.at)), vec![]);
|
||||
assert_eq!(ShaperTool::default().pointer(&c, &ev(PointerKind::DoubleClick, w.at)), vec![]);
|
||||
// At 4× zoom the widget sits 4 pt out.
|
||||
let c4 = ToolContext { zoom: 4.0, ..c };
|
||||
assert_eq!(ShaperWidget::of(c4.doc, c4.selection, c4.isolation, c4.zoom).unwrap().at, Point::new(154.0, 350.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_shaper_group_or_its_sources_show_the_widget() {
|
||||
let (mut d, g, _, face) = doc_with_shaper_group();
|
||||
let rect = d.layers[0].children().unwrap()[0].id;
|
||||
let p = paint();
|
||||
let widget = |d: &Document, ids: &[NodeId]| {
|
||||
let mut s = Selection::default();
|
||||
ids.iter().for_each(|id| s.add(*id));
|
||||
ShaperWidget::of(d, &s, None, 1.0)
|
||||
};
|
||||
assert!(widget(&d, &[]).is_none());
|
||||
assert!(widget(&d, &[rect]).is_none(), "a plain path");
|
||||
assert!(widget(&d, &[rect, g]).is_none(), "two objects");
|
||||
assert!(widget(&d, &[g]).is_some());
|
||||
// The widget reaches 7 px from its center.
|
||||
let mut s = Selection::default();
|
||||
s.add(g);
|
||||
let c = cx(&d, &s, &p);
|
||||
let w = ShaperWidget::of(c.doc, c.selection, c.isolation, c.zoom).unwrap();
|
||||
assert!(over_widget(&c, w.at + vectorcraft_geom::Vec2::new(7.0, 0.0)));
|
||||
assert!(!over_widget(&c, w.at + vectorcraft_geom::Vec2::new(8.0, 0.0)));
|
||||
// A locked group has none.
|
||||
d.node_mut(g).unwrap().locked = true;
|
||||
assert!(widget(&d, &[g]).is_none());
|
||||
assert!(widget(&d, &[face]).is_none());
|
||||
}
|
||||
}
|
||||
@@ -480,6 +480,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
draw_overlays(&painter, &xf, &overlays, &t, HandleLook::of(&app.session.prefs));
|
||||
if !revolve_edit {
|
||||
shaper_widget_overlay(app, &painter, &xf);
|
||||
}
|
||||
ime_output(app, ui.ctx(), &xf);
|
||||
crate::place::paint_drop_highlight(app, ui.ctx(), &painter, rect);
|
||||
|
||||
@@ -1836,6 +1839,23 @@ fn print_tiling_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
/// The print tiling's lines: a neutral grey that reads on the paper and on the pasteboard.
|
||||
const PRINT_TILING: Color32 = Color32::from_gray(96);
|
||||
|
||||
/// A Shaper Group's mode widget (Shaper and Selection tools), drawn with selection edges shown or
|
||||
/// hidden: an arrow pointing right in Face Selection mode and left in Construction mode.
|
||||
fn shaper_widget_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
let Some(st) = app.session.active() else { return };
|
||||
if matches!(app.session.tool_id(), "shaper" | "selection")
|
||||
&& let Some(w) = vectorcraft_tools::shaperwidget::ShaperWidget::of(&st.doc, &st.selection, st.isolation, xf.zoom)
|
||||
{
|
||||
let (c, r) = (xf.to_screen(w.at), vectorcraft_tools::shaperwidget::RADIUS_PX);
|
||||
let color = c32(st.doc.layer_color(w.group));
|
||||
p.circle_filled(c, r, Color32::WHITE);
|
||||
p.circle_stroke(c, r, Stroke::new(1.0, color));
|
||||
let d = if w.construction { -1.0 } else { 1.0 };
|
||||
let arrow = vec![c + vec2(3.0 * d, 0.0), c + vec2(-2.0 * d, -3.0), c + vec2(-2.0 * d, 3.0)];
|
||||
p.add(Shape::convex_polygon(arrow, color, Stroke::NONE));
|
||||
}
|
||||
}
|
||||
|
||||
fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf, curve_points_from_tool: bool) {
|
||||
// The Selection tool's bounding box (rotated with the objects after a rotation). It hides while
|
||||
// a drag moves or resizes the selection, so only the art is seen going with the pointer (#712).
|
||||
@@ -2551,6 +2571,47 @@ mod tests {
|
||||
assert_eq!(node_outline(st.doc.node(line).unwrap(), &symbols).bounding_box().x0, 50.0, "an isolated original still shows its full outline");
|
||||
}
|
||||
|
||||
/// A Shaper Group's mode widget, drawn for the Shaper and Selection tools with selection edges
|
||||
/// shown or hidden: its arrow points right in Face Selection mode and left in Construction mode.
|
||||
#[test]
|
||||
fn the_shaper_widget_points_to_the_other_mode() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
for x in [0, 50] {
|
||||
app.session.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 100, "height": 100})).unwrap();
|
||||
}
|
||||
let points = json!({"points": [[25, 20], [75, 35], [30, 50], [125, 65]]});
|
||||
let g = app.session.execute("shaper.scribble", &points).unwrap()["id"].as_u64().unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
// For each widget drawn, whether its arrow's tip is right of the circle's center.
|
||||
let right = |app: &mut VectorcraftApp| -> Vec<bool> {
|
||||
let drawn = shapes(app, &ctx);
|
||||
let r = vectorcraft_tools::shaperwidget::RADIUS_PX;
|
||||
let centers: Vec<egui::Pos2> = drawn
|
||||
.iter()
|
||||
.filter_map(|s| if let Shape::Circle(c) = s { (c.radius == r && c.fill == Color32::WHITE).then_some(c.center) } else { None })
|
||||
.collect();
|
||||
drawn
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
Shape::Path(p) if p.points.len() == 3 => centers.iter().find(|c| c.distance(p.points[0]) <= r).map(|c| p.points[0].x > c.x),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
app.select_tool("shaper");
|
||||
assert_eq!(right(&mut app), vec![true], "the scribble selects the group");
|
||||
app.session.execute("object.isolate", &json!({"id": g})).unwrap();
|
||||
assert_eq!(right(&mut app), vec![false]);
|
||||
app.select_tool("selection");
|
||||
assert_eq!(right(&mut app), vec![false]);
|
||||
// View › Hide Edges keeps the widget, which still answers clicks.
|
||||
app.ui.view.edges = false;
|
||||
assert_eq!(right(&mut app), vec![false]);
|
||||
app.select_tool("pen");
|
||||
assert!(right(&mut app).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_symbol_instance_is_outlined_around_its_art() {
|
||||
// A symbol holding the square of the SVG import tests' `Star` symbol, 20 × 20 from (0, 0),
|
||||
|
||||
+3
-1
@@ -11,8 +11,10 @@ Scribble over overlapping paths to make an editable Shaper Group:
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Object › Shaper › Release restores the original paths. Expand keeps the visible result as ordinary paths. Each scribble and source edit records one undo step. Sources in a composition must share a parent layer/group; Shaper limits a composition to 64 source paths and 4,096 segments.
|
||||
|
||||
The construction-mode arrow widget and finer gesture heuristics remain to be implemented.
|
||||
Finer gesture heuristics remain to be implemented.
|
||||
|
||||
Behavior reference: [public Shaper documentation](https://helpx.adobe.com/illustrator/using/building-new-shapes-using-shape.html). Rotation steps and independent fill/stroke cases follow the contributor’s supplied requirements. No reference images or product assets are included.
|
||||
|
||||
@@ -456,7 +456,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- Shortcut sets other apps save as text (`.kys`) open in Import Shortcut Set from File › Open, from Edit › Keyboard Shortcuts › Import… in the desktop app, or from a drop on the window. It matches each entry to a tool, command or panel by name and decodes the file's key and modifier codes where VectorCraft's default shortcuts agree on them; the user answers the rest, and Apply fills the Keyboard Shortcuts dialog's working set. The answers are kept with the preferences for the next import (`shortcutSet.read`, `importMatches.*`).
|
||||
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one.
|
||||
- **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. Native save/reopen, face recoloring, Release and Expand are covered. The construction-mode widget and finer gesture heuristics remain.
|
||||
- **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.
|
||||
- **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).
|
||||
A key object needs another selected object outside it. A Shift-click or any other change that leaves one object selected clears the key, and `select.key` sets none on one object or on a group selected with only its own members. With one object selected, the Align panel, the Control bar and the Properties panel align it to the artboard whatever Align To is set to.
|
||||
With two or more objects selected and no key object, Align to Key Object makes the topmost of them the key, whatever order they were selected in, and the Align panel, the Control bar and the Properties panel align to it. Align To keeps its Selection or Artboard choice and shows it again once the key is cleared.
|
||||
|
||||
Reference in New Issue
Block a user