mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
A Shaper Group keeps its original shapes as its first child, at no opacity, and clicks do not pick them outside Construction mode. The Lasso, the Add Anchor Point, Delete Anchor Point, Anchor Point and Scissors tools still picked their anchor points and segments, also where the group erased the face, and Smart Guides, Snap to Point and the transform tools' reference point snapped to them. The Lasso and the anchor tools also picked art outside the isolated group or object in isolation mode. The Lasso and the anchor tools now visit only the art a click can pick: the isolated object in isolation mode, and a Shaper Group without its original shapes unless they are isolated (Construction mode). Smart Guides and Snap to Point leave the original shapes out of their targets, and Spacing Guides leave out their bounds when a face moves. The transform tools' reference point snaps to an original shape only when that shape is selected, or under the pointer in Construction mode. The Shaper Group test document moves into the shared test helpers. Docs: docs/shaper.md, docs/target-app-parity.md.
157 lines
7.4 KiB
Rust
157 lines
7.4 KiB
Rust
//! 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 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)
|
||
}
|
||
|
||
#[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());
|
||
}
|
||
}
|