Add interactive Mirror Repeat axis controls

This commit is contained in:
AJ Richardson
2026-10-18 19:02:44 +07:00
committed by bflatastic
parent 5906f53ca5
commit 63cebd392a
9 changed files with 436 additions and 8 deletions
+86
View File
@@ -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);
}
}
+1
View File
@@ -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;
+182
View File
@@ -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());
}
}
+45 -1
View File
@@ -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()),
+108
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3 -1
View File
File diff suppressed because one or more lines are too long