mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Add interactive Mirror Repeat axis controls
This commit is contained in:
committed by
bflatastic
parent
5906f53ca5
commit
63cebd392a
@@ -763,3 +763,89 @@ fn inner_glow_on_a_pattern_fill_stays_inside_the_object_on_the_threaded_renderer
|
||||
let [red, ..] = out.pixel(123, 200);
|
||||
assert!(red > 0x40, "no glow along the edge: red {red}");
|
||||
}
|
||||
|
||||
fn mirror_axis(s: &Session) -> vectorcraft_tools::mirror::MirrorAxis {
|
||||
let st = s.active().unwrap();
|
||||
vectorcraft_tools::mirror::MirrorAxis::of(&st.doc, &st.selection, 1.0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_axis_drag_previews_once_and_undo_redo_preserve_source() {
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
let mut s = session();
|
||||
rect(&mut s, 100.0, 100.0, 60.0, 80.0);
|
||||
let id = id_of(&s.execute("object.repeat.mirror", &json!({})).unwrap());
|
||||
let before = node(&s, id);
|
||||
let axis = mirror_axis(&s);
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
let h = super::tests::history(&s);
|
||||
for (kind, dx) in [(PointerKind::Down, 0.0), (PointerKind::Drag, 20.0), (PointerKind::Drag, 30.0), (PointerKind::Up, 40.0)] {
|
||||
s.pointer(&PointerEvent::new(kind, axis.center.x + dx, axis.center.y), v).unwrap();
|
||||
assert!(close(mirror_axis(&s).center, axis.center + vectorcraft_geom::Vec2::new(dx, 0.0)));
|
||||
}
|
||||
assert_eq!(super::tests::history(&s).0, h.0 + 1);
|
||||
let after = node(&s, id);
|
||||
let (NodeKind::Repeat(a), NodeKind::Repeat(b)) = (&before.kind, &after.kind) else { panic!() };
|
||||
assert_eq!(a.source, b.source, "moving the mirror axis leaves the source artwork in place");
|
||||
assert_ne!(a.transforms(), b.transforms(), "the reflected copy updates live");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, id), before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, id), after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_axis_rotation_snaps_and_escape_restores_document_and_history() {
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
let mut s = session();
|
||||
rect(&mut s, 100.0, 100.0, 60.0, 80.0);
|
||||
let id = id_of(&s.execute("object.repeat.mirror", &json!({})).unwrap());
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
let before = node(&s, id);
|
||||
let h = super::tests::history(&s);
|
||||
let axis = mirror_axis(&s);
|
||||
let end = axis.ends[1];
|
||||
let destination = axis.center + vectorcraft_geom::Vec2::new(70.0, 60.0);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, end.x, end.y), v).unwrap();
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, destination.x, destination.y).with_mods(shift), v).unwrap();
|
||||
assert!((mirror_axis(&s).angle - 45.0).abs() < 1e-6);
|
||||
assert_eq!(mirror_axis(&s).center, axis.center);
|
||||
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
|
||||
assert!(!s.tool_busy());
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, destination.x, destination.y), v).unwrap();
|
||||
assert_eq!(node(&s, id), before);
|
||||
assert_eq!(super::tests::history(&s), h);
|
||||
// A click is not an edit, and a subsequent real drag still works after cancellation.
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, end.x, end.y), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, end.x, end.y), v).unwrap();
|
||||
assert_eq!(super::tests::history(&s), h);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, end.x, end.y), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, destination.x, destination.y).with_mods(shift), v).unwrap();
|
||||
assert!((mirror_axis(&s).angle - 45.0).abs() < 1e-6);
|
||||
assert_eq!(super::tests::history(&s).0, h.0 + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_axis_hidden_controls_and_other_repeat_kinds_do_not_capture_drags() {
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
for command in ["object.repeat.mirror", "object.repeat.radial", "object.repeat.grid"] {
|
||||
let mut s = session();
|
||||
rect(&mut s, 100.0, 100.0, 60.0, 80.0);
|
||||
let id = id_of(&s.execute(command, &json!({})).unwrap());
|
||||
let before = node(&s, id);
|
||||
let st = s.active().unwrap();
|
||||
if command != "object.repeat.mirror" {
|
||||
assert!(vectorcraft_tools::mirror::MirrorAxis::of(&st.doc, &st.selection, 1.0).is_none());
|
||||
}
|
||||
let v = ViewInfo { show_bbox: false, ..Default::default() };
|
||||
s.select_tool("selection", v).unwrap();
|
||||
// Away from every instance: the hidden axis's top handle cannot edit the repeat.
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 170.0, 76.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 80.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 200.0, 80.0), v).unwrap();
|
||||
assert_eq!(node(&s, id), before);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ pub mod guides;
|
||||
pub mod intertwine;
|
||||
pub mod meshblend;
|
||||
pub mod meshedit;
|
||||
pub mod mirror;
|
||||
pub mod params;
|
||||
pub mod pathtype;
|
||||
pub mod pen;
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Mirror Repeat's on-canvas axis. Drawing and hit testing share document-space geometry;
|
||||
//! gestures preview the existing Repeat Options command and commit as one undo step.
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{Document, NodeId, NodeKind, Selection, pattern::RepeatKind};
|
||||
use vectorcraft_geom::{Point, Vec2};
|
||||
|
||||
use crate::{Action, Cursor, Mods, ToolContext};
|
||||
|
||||
pub const HANDLE_RADIUS: f32 = 6.0;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct MirrorAxis {
|
||||
pub id: NodeId,
|
||||
pub center: Point,
|
||||
pub angle: f64,
|
||||
pub ends: [Point; 2],
|
||||
}
|
||||
|
||||
impl MirrorAxis {
|
||||
/// A single editable mirror repeat selected whole. Multi-selection keeps ordinary transforms.
|
||||
pub fn of(doc: &Document, selection: &Selection, zoom: f64) -> Option<Self> {
|
||||
let [id] = selection.objects.as_slice() else { return None };
|
||||
if !selection.anchors.is_empty() || !selection.segments.is_empty() || !doc.is_editable(*id) || !zoom.is_finite() || zoom <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let n = doc.node(*id)?;
|
||||
let NodeKind::Repeat(spec) = &n.kind else { return None };
|
||||
let RepeatKind::Mirror { angle, center } = spec.kind else { return None };
|
||||
if !angle.is_finite() || !finite(center) {
|
||||
return None;
|
||||
}
|
||||
let bounds = n.geometric_bounds_with(&doc.symbol_boxes())?;
|
||||
let (sin, cos) = angle.to_radians().sin_cos();
|
||||
let direction = Vec2::new(cos, sin);
|
||||
let mut extent = 0.0_f64;
|
||||
for p in
|
||||
[Point::new(bounds.x0, bounds.y0), Point::new(bounds.x1, bounds.y0), Point::new(bounds.x1, bounds.y1), Point::new(bounds.x0, bounds.y1)]
|
||||
{
|
||||
let along = (p - center).dot(direction);
|
||||
if !along.is_finite() {
|
||||
return None;
|
||||
}
|
||||
extent = extent.max(along.abs());
|
||||
}
|
||||
// Keep the center move handle separate from both rotation handles, even on tiny artwork.
|
||||
extent = (extent + 24.0 / zoom).max(36.0 / zoom);
|
||||
let ends = [center - direction * extent, center + direction * extent];
|
||||
ends.iter().all(|p| finite(*p)).then_some(Self { id: *id, center, angle, ends })
|
||||
}
|
||||
|
||||
fn hit(cx: &ToolContext, p: Point) -> Option<(Self, bool)> {
|
||||
if !cx.show_bbox || !finite(p) {
|
||||
return None;
|
||||
}
|
||||
let axis = Self::of(cx.doc, cx.selection, cx.zoom)?;
|
||||
if axis.ends.iter().any(|q| q.distance(p) <= cx.tol(9.0)) {
|
||||
return Some((axis, true));
|
||||
}
|
||||
let d = axis.ends[1] - axis.ends[0];
|
||||
let length = d.hypot();
|
||||
if !length.is_finite() || length <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let unit = d / length;
|
||||
let along = (p - axis.ends[0]).dot(unit).clamp(0.0, length);
|
||||
((axis.ends[0] + unit * along).distance(p) <= cx.tol(7.0)).then_some((axis, false))
|
||||
}
|
||||
}
|
||||
|
||||
fn finite(p: Point) -> bool {
|
||||
p.x.is_finite() && p.y.is_finite()
|
||||
}
|
||||
|
||||
pub fn cursor(cx: &ToolContext, p: Point) -> Option<Cursor> {
|
||||
let (axis, rotate) = MirrorAxis::hit(cx, p)?;
|
||||
Some(if rotate { Cursor::rotate_about_in_view(axis.center, p, cx.view_angle()) } else { Cursor::Move })
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MirrorDrag {
|
||||
axis: MirrorAxis,
|
||||
start: Point,
|
||||
rotate: bool,
|
||||
began: bool,
|
||||
}
|
||||
|
||||
impl MirrorDrag {
|
||||
pub fn hit(cx: &ToolContext, p: Point, mods: Mods) -> Option<Self> {
|
||||
if mods.cmd || mods.ctrl || mods.alt {
|
||||
return None;
|
||||
}
|
||||
let (axis, rotate) = MirrorAxis::hit(cx, p)?;
|
||||
Some(Self { axis, start: p, rotate, began: false })
|
||||
}
|
||||
|
||||
pub fn adjusting(&self) -> bool {
|
||||
self.began
|
||||
}
|
||||
|
||||
pub fn cursor(&self, cx: &ToolContext, p: Point) -> Cursor {
|
||||
if self.rotate { Cursor::rotate_about_in_view(self.axis.center, p, cx.view_angle()) } else { Cursor::Move }
|
||||
}
|
||||
|
||||
pub fn drag(&mut self, cx: &ToolContext, p: Point, mods: Mods) -> Vec<Action> {
|
||||
if !finite(p) || (!self.began && p.distance(self.start) < cx.tol(3.0)) {
|
||||
return vec![];
|
||||
}
|
||||
let (center, angle) = if self.rotate {
|
||||
let from = self.start - self.axis.center;
|
||||
let to = p - self.axis.center;
|
||||
if to.hypot() < cx.tol(2.0) {
|
||||
return vec![];
|
||||
}
|
||||
let mut angle = self.axis.angle + (to.y.atan2(to.x) - from.y.atan2(from.x)).to_degrees();
|
||||
if mods.shift {
|
||||
angle = (angle / 45.0).round() * 45.0;
|
||||
}
|
||||
(self.axis.center, angle.rem_euclid(360.0))
|
||||
} else {
|
||||
(self.axis.center + (p - self.start), self.axis.angle)
|
||||
};
|
||||
if !finite(center) || !angle.is_finite() {
|
||||
return vec![];
|
||||
}
|
||||
let mut out = vec![];
|
||||
if !self.began {
|
||||
self.began = true;
|
||||
out.push(Action::Begin("Repeat Options".into()));
|
||||
}
|
||||
out.push(Action::Preview("object.repeat.options".into(), json!({"center": [center.x, center.y], "angle": angle})));
|
||||
out
|
||||
}
|
||||
|
||||
pub fn finish(self) -> Vec<Action> {
|
||||
if self.began { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
|
||||
pub fn cancel(self) -> Vec<Action> {
|
||||
if self.began { vec![Action::Cancel] } else { vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testutil::{cx, doc_with_rect, paint};
|
||||
use std::sync::Arc;
|
||||
use vectorcraft_doc::pattern::RepeatSpec;
|
||||
|
||||
#[test]
|
||||
fn mirror_axis_hit_targets_match_zoom_and_reject_invalid_geometry() {
|
||||
let (mut doc, id) = doc_with_rect();
|
||||
let mut source = doc.node(id).unwrap().clone();
|
||||
source.id = doc.alloc_id();
|
||||
doc.node_mut(id).unwrap().kind = NodeKind::Repeat(RepeatSpec::mirror(vec![Arc::new(source)], 90.0, 10.0));
|
||||
let mut selection = Selection::default();
|
||||
selection.set([id]);
|
||||
let paint = paint();
|
||||
for zoom in [0.1, 1.0, 10.0] {
|
||||
let mut cx = cx(&doc, &selection, &paint);
|
||||
cx.zoom = zoom;
|
||||
let axis = MirrorAxis::of(&doc, &selection, zoom).unwrap();
|
||||
assert_eq!(cursor(&cx, axis.center), Some(Cursor::Move));
|
||||
for end in axis.ends {
|
||||
assert!(MirrorAxis::hit(&cx, end).unwrap().1);
|
||||
}
|
||||
assert!(MirrorAxis::hit(&cx, axis.center + Vec2::new(6.0 / zoom, 0.0)).is_some());
|
||||
assert!(MirrorAxis::hit(&cx, axis.center + Vec2::new(10.0 / zoom, 0.0)).is_none());
|
||||
let mut drag = MirrorDrag::hit(&cx, axis.center, Mods::default()).unwrap();
|
||||
assert!(drag.drag(&cx, Point::new(f64::NAN, 0.0), Mods::default()).is_empty());
|
||||
assert!(!drag.adjusting());
|
||||
cx.show_bbox = false;
|
||||
assert!(MirrorAxis::hit(&cx, axis.center).is_none());
|
||||
}
|
||||
for zoom in [0.0, -1.0, f64::NAN, f64::INFINITY] {
|
||||
assert!(MirrorAxis::of(&doc, &selection, zoom).is_none());
|
||||
}
|
||||
let NodeKind::Repeat(spec) = &mut doc.node_mut(id).unwrap().kind else { panic!() };
|
||||
spec.kind = RepeatKind::Mirror { angle: f64::NAN, center: Point::ZERO };
|
||||
assert!(MirrorAxis::of(&doc, &selection, 1.0).is_none());
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,8 @@ enum State {
|
||||
Corner(CornerDrag),
|
||||
/// Dragging a live ellipse's pie widget.
|
||||
Pie(PieDrag),
|
||||
/// Move or rotate a live mirror axis.
|
||||
Mirror(crate::mirror::MirrorDrag),
|
||||
/// Dragging a bracket of type on a path.
|
||||
Bracket(BracketDrag),
|
||||
/// Loaded with the text of area type `from` by a click on its out port; `press`: where the
|
||||
@@ -390,6 +392,12 @@ impl Tool for SelectionTool {
|
||||
*enclosed = !*enclosed;
|
||||
return vec![];
|
||||
}
|
||||
if key == ToolKey::Escape
|
||||
&& matches!(self.state, State::Mirror(_))
|
||||
&& let State::Mirror(d) = std::mem::take(&mut self.state)
|
||||
{
|
||||
return d.cancel();
|
||||
}
|
||||
if key == ToolKey::Escape && matches!(self.state, State::Loaded { .. }) {
|
||||
self.state = State::Idle;
|
||||
return vec![];
|
||||
@@ -400,7 +408,7 @@ impl Tool for SelectionTool {
|
||||
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
|
||||
match self.state {
|
||||
State::Marquee { .. } => key == ToolKey::Letter('e'),
|
||||
State::Loaded { .. } => key == ToolKey::Escape,
|
||||
State::Loaded { .. } | State::Mirror(_) => key == ToolKey::Escape,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -410,6 +418,7 @@ impl Tool for SelectionTool {
|
||||
State::Moving { began: true, .. } | State::Scaling { .. } | State::Rotating { .. } => true,
|
||||
State::Corner(c) => c.adjusting(),
|
||||
State::Pie(d) => d.adjusting(),
|
||||
State::Mirror(d) => d.adjusting(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -420,6 +429,30 @@ impl Tool for SelectionTool {
|
||||
{
|
||||
return out;
|
||||
}
|
||||
// Mirror controls take precedence over art, bounding handles and ruler guides.
|
||||
if let State::Mirror(mut d) = self.state.clone() {
|
||||
match ev.kind {
|
||||
PointerKind::Drag | PointerKind::Up => {
|
||||
let mut out = d.drag(cx, ev.pos, ev.mods);
|
||||
if ev.kind == PointerKind::Up {
|
||||
out.extend(d.finish());
|
||||
self.state = State::Idle;
|
||||
} else {
|
||||
self.state = State::Mirror(d);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
_ => return vec![],
|
||||
}
|
||||
}
|
||||
if matches!(self.state, State::Idle)
|
||||
&& ev.kind == PointerKind::Down
|
||||
&& let Some(d) = crate::mirror::MirrorDrag::hit(cx, ev.pos, ev.mods)
|
||||
{
|
||||
self.state = State::Mirror(d);
|
||||
self.hover = None;
|
||||
return vec![];
|
||||
}
|
||||
if let Some(out) = self.guide.pointer(cx, ev) {
|
||||
return out;
|
||||
}
|
||||
@@ -693,6 +726,17 @@ impl Tool for SelectionTool {
|
||||
}
|
||||
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
|
||||
if let State::Mirror(d) = &self.state {
|
||||
return d.cursor(cx, p);
|
||||
}
|
||||
if matches!(self.state, State::Idle)
|
||||
&& !m.cmd
|
||||
&& !m.ctrl
|
||||
&& !m.alt
|
||||
&& let Some(cursor) = crate::mirror::cursor(cx, p)
|
||||
{
|
||||
return cursor;
|
||||
}
|
||||
match self.state {
|
||||
State::Rotating { center, .. } => return Cursor::rotate_about_in_view(center, p, cx.view_angle()),
|
||||
State::Scaling { handle, bx, .. } => return handle_cursor(handle, bx.angle - cx.view_angle()),
|
||||
|
||||
@@ -503,6 +503,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if app.ui.view.edges && !revolve_edit {
|
||||
hover_highlight(app, &painter, &xf);
|
||||
selection_overlay(app, &painter, &xf, overlays.iter().any(|o| matches!(o, Overlay::CurvePoint { .. })));
|
||||
mirror_axis_overlay(app, &painter, &xf);
|
||||
if app.ui.view.text_threads {
|
||||
thread_overlay(app, &painter, &xf);
|
||||
}
|
||||
@@ -2266,6 +2267,32 @@ fn shaper_widget_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
}
|
||||
}
|
||||
|
||||
/// High-contrast live controls, using the same geometry as the Selection tool's hit testing.
|
||||
fn mirror_axis_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
if app.session.tool_id() != "selection" || !app.ui.view.bounding_box {
|
||||
return;
|
||||
}
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let Some(axis) = vectorcraft_tools::mirror::MirrorAxis::of(&st.doc, &st.selection, xf.zoom) else { return };
|
||||
let t = Tokens::get(p.ctx());
|
||||
let ends = axis.ends.map(|q| xf.to_screen(q));
|
||||
let center = xf.to_screen(axis.center);
|
||||
if !center.is_finite() || !ends.iter().all(|p| p.is_finite()) {
|
||||
return;
|
||||
}
|
||||
if let Some((a, b)) = clip_segment(ends[0], ends[1], xf.rect) {
|
||||
p.line_segment([a, b], Stroke::new(5.0, t.measure_bg));
|
||||
p.line_segment([a, b], Stroke::new(2.0, t.selection));
|
||||
}
|
||||
for end in ends {
|
||||
p.circle_filled(end, vectorcraft_tools::mirror::HANDLE_RADIUS, t.measure_bg);
|
||||
p.circle_stroke(end, vectorcraft_tools::mirror::HANDLE_RADIUS, Stroke::new(2.0, t.selection));
|
||||
}
|
||||
let handle = egui::Rect::from_center_size(center, vec2(12.0, 12.0));
|
||||
p.rect_filled(handle, 2.0, t.selection);
|
||||
p.rect_stroke(handle, 2.0, Stroke::new(2.0, t.measure_bg), StrokeKind::Outside);
|
||||
}
|
||||
|
||||
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).
|
||||
@@ -4933,6 +4960,87 @@ mod tests {
|
||||
assert!(big.contains(&8.0) && big.contains(&10.0) && !big.contains(&4.0), "4 points bigger at 7: {big:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_axis_tracks_selection_options_and_view_without_editing_art() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 60, "height": 80})).unwrap();
|
||||
let id = app.run("object.repeat.mirror", json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let axis = |app: &mut VectorcraftApp| {
|
||||
let color = Tokens::get(&ctx).selection;
|
||||
shapes(app, &ctx)
|
||||
.into_iter()
|
||||
.filter_map(|s| match s {
|
||||
Shape::LineSegment { points, stroke } if stroke.color == color && stroke.width == 2.0 => Some(points),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let before = serde_json::to_value(&app.session.active().unwrap().doc).unwrap();
|
||||
let lines = axis(&mut app);
|
||||
assert_eq!(lines.len(), 1);
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
assert!(lines[0][0].distance(xf.to_screen(Point::new(170.0, 100.0)) - vec2(0.0, 24.0)) < 0.01);
|
||||
assert!(lines[0][1].distance(xf.to_screen(Point::new(170.0, 180.0)) + vec2(0.0, 24.0)) < 0.01);
|
||||
assert_eq!(serde_json::to_value(&app.session.active().unwrap().doc).unwrap(), before);
|
||||
app.ui.view.edges = false;
|
||||
assert!(axis(&mut app).is_empty());
|
||||
app.ui.view.edges = true;
|
||||
app.run("select.set", json!({"ids": []})).unwrap();
|
||||
assert!(axis(&mut app).is_empty());
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
assert_eq!(axis(&mut app), lines);
|
||||
app.run("object.repeat.options", json!({"angle": 0, "center": [180, 140]})).unwrap();
|
||||
let horizontal = axis(&mut app);
|
||||
assert_eq!(horizontal.len(), 1);
|
||||
assert!((horizontal[0][0].y - horizontal[0][1].y).abs() < 0.01);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(axis(&mut app), lines);
|
||||
app.view_mut().unwrap().rotation = 45.0;
|
||||
let rotated = axis(&mut app);
|
||||
let d = rotated[0][1] - rotated[0][0];
|
||||
assert!((d.x.abs() - d.y.abs()).abs() < 0.01);
|
||||
app.run("object.repeat.release", json!({})).unwrap();
|
||||
assert!(axis(&mut app).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_axis_drag_uses_canvas_coordinates_in_a_zoomed_rotated_view() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 60, "height": 80})).unwrap();
|
||||
app.run("object.repeat.mirror", json!({})).unwrap();
|
||||
app.ui.task_bar = false;
|
||||
app.ui.view.rulers = false;
|
||||
app.view_mut().unwrap().zoom = 1.5;
|
||||
app.view_mut().unwrap().rotation = 30.0;
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let axis = |app: &VectorcraftApp| {
|
||||
let st = app.session.active().unwrap();
|
||||
vectorcraft_tools::mirror::MirrorAxis::of(&st.doc, &st.selection, app.view().unwrap().zoom).unwrap()
|
||||
};
|
||||
let initial = axis(&app);
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
let from = xf.to_screen(initial.center);
|
||||
let to = from + vec2(40.0, 15.0);
|
||||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from), button(from, true)]);
|
||||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(to)]);
|
||||
assert!(axis(&app).center.distance(xf.to_doc(to)) < 0.001, "the mirrored artwork previews during the drag");
|
||||
frame(&mut app, &ctx, vec![button(to, false)]);
|
||||
assert!(axis(&app).center.distance(xf.to_doc(to)) < 0.001);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(axis(&app).center, initial.center);
|
||||
// Both rotation handles remain a usable size at this zoom and view angle.
|
||||
let drawn = shapes(&mut app, &ctx);
|
||||
for end in initial.ends {
|
||||
assert!(drawn.iter().any(|s| matches!(s, Shape::Circle(c)
|
||||
if c.center.distance(xf.to_screen(end)) < 0.01 && c.radius == vectorcraft_tools::mirror::HANDLE_RADIUS)));
|
||||
}
|
||||
}
|
||||
|
||||
/// #1236: Curvature edits points, so existing paths show one circular marker per point and
|
||||
/// no direction handles; switching back to Direct Selection keeps its normal handles.
|
||||
#[test]
|
||||
|
||||
+4
-1
File diff suppressed because one or more lines are too long
+3
-2
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user