mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 20:06:53 -06:00
This commit is contained in:
@@ -1527,6 +1527,8 @@ mod panels_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod pdf_print_tests;
|
||||
#[cfg(test)]
|
||||
mod pen_overlay_tests;
|
||||
#[cfg(test)]
|
||||
mod pixel_preview_tests;
|
||||
#[cfg(test)]
|
||||
mod playground_tests;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! What the Pen tool shows while a path is drawn (`tool.overlay`): the handles shaping the segments
|
||||
//! at the pen, and the rubber band to the pointer, which follows the last anchor's out handle.
|
||||
|
||||
use crate::Session;
|
||||
use crate::tools::{PointerEvent, PointerKind, ToolId};
|
||||
|
||||
fn at(s: &mut Session, kind: PointerKind, x: f64, y: f64) {
|
||||
s.pointer(&PointerEvent::at(kind, x, y, 1.0)).unwrap();
|
||||
}
|
||||
|
||||
/// A press at (x, y) dragged to (to_x, to_y) and released: a smooth point with out handle
|
||||
/// (to - p).
|
||||
fn drag_point(s: &mut Session, x: f64, y: f64, to_x: f64, to_y: f64) {
|
||||
at(s, PointerKind::Down, x, y);
|
||||
at(s, PointerKind::Move, to_x, to_y);
|
||||
at(s, PointerKind::Up, to_x, to_y);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dragged_point_shows_its_handles_and_the_one_before_its_out_handle() {
|
||||
let mut s = Session::new();
|
||||
s.set_tool(ToolId::Pen);
|
||||
drag_point(&mut s, 100.0, 100.0, 140.0, 100.0);
|
||||
assert_eq!(s.tool.overlay.pen_handles, vec![[100.0, 100.0, 60.0, 100.0], [100.0, 100.0, 140.0, 100.0]], "in and out of the first point");
|
||||
// The second point, mid-drag.
|
||||
at(&mut s, PointerKind::Down, 200.0, 200.0);
|
||||
assert_eq!(s.tool.overlay.rubber, None, "the press shows the segment itself, not a straight band");
|
||||
at(&mut s, PointerKind::Move, 200.0, 240.0);
|
||||
assert_eq!(
|
||||
s.tool.overlay.pen_handles,
|
||||
vec![[100.0, 100.0, 140.0, 100.0], [200.0, 200.0, 200.0, 160.0], [200.0, 200.0, 200.0, 240.0]],
|
||||
"the one before's out handle, then the dragged point's in and out"
|
||||
);
|
||||
at(&mut s, PointerKind::Up, 200.0, 240.0);
|
||||
// Moving on: the band leaves along the last out handle.
|
||||
at(&mut s, PointerKind::Hover, 300.0, 200.0);
|
||||
assert_eq!(s.tool.overlay.rubber, Some([200.0, 200.0, 300.0, 200.0]));
|
||||
assert_eq!(s.tool.overlay.rubber_handle, Some([200.0, 240.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_points_have_no_handles_and_a_straight_band() {
|
||||
let mut s = Session::new();
|
||||
s.set_tool(ToolId::Pen);
|
||||
for (x, y) in [(100.0, 100.0), (200.0, 100.0)] {
|
||||
at(&mut s, PointerKind::Down, x, y);
|
||||
at(&mut s, PointerKind::Up, x, y);
|
||||
}
|
||||
at(&mut s, PointerKind::Hover, 250.0, 200.0);
|
||||
assert!(s.tool.overlay.pen_handles.is_empty());
|
||||
assert_eq!(s.tool.overlay.rubber, Some([200.0, 100.0, 250.0, 200.0]));
|
||||
assert_eq!(s.tool.overlay.rubber_handle, None, "straight");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finishing_the_path_clears_its_handles_and_band() {
|
||||
let mut s = Session::new();
|
||||
s.set_tool(ToolId::Pen);
|
||||
drag_point(&mut s, 100.0, 100.0, 140.0, 100.0);
|
||||
drag_point(&mut s, 200.0, 200.0, 200.0, 240.0);
|
||||
at(&mut s, PointerKind::Hover, 300.0, 200.0);
|
||||
s.pen_finish().unwrap();
|
||||
let ov = &s.tool.overlay;
|
||||
assert!(ov.pen.is_empty() && ov.pen_handles.is_empty() && ov.rubber.is_none() && ov.rubber_handle.is_none());
|
||||
}
|
||||
@@ -266,6 +266,12 @@ pub struct Overlay {
|
||||
/// Pen tool: the in-progress path's anchor points and the rubber band to the pointer.
|
||||
pub pen: Vec<[f64; 2]>,
|
||||
pub rubber: Option<[f64; 4]>,
|
||||
/// Pen tool: the handles shaping the segments at the pen (anchor x, y, handle x, y): the last
|
||||
/// anchor's in and out handles and the one before's out handle.
|
||||
pub pen_handles: Vec<[f64; 4]>,
|
||||
/// Pen tool: where the last anchor's out handle ends, when it has one: the rubber band is the
|
||||
/// curve a click would make, leaving along it.
|
||||
pub rubber_handle: Option<[f64; 2]>,
|
||||
/// Frame/section the pointer is drawing or dropping into (highlighted).
|
||||
pub drop_target: Option<NodeId>,
|
||||
/// Auto layout insertion indicator (x0, y0, x1, y1).
|
||||
@@ -397,6 +403,14 @@ pub(crate) enum Gesture {
|
||||
},
|
||||
}
|
||||
|
||||
/// The handles a pen path shows while it is drawn ([`Overlay::pen_handles`]): the last anchor's in
|
||||
/// and out handles and the one before's out handle, those that aren't zero.
|
||||
fn pen_handles(points: &[(Point, Vec2, Vec2)]) -> Vec<[f64; 4]> {
|
||||
let last = points.last().into_iter().flat_map(|(p, i, o)| [(p, i), (p, o)]);
|
||||
let before = points.len().checked_sub(2).and_then(|k| points.get(k)).into_iter().map(|(p, _, o)| (p, o));
|
||||
before.chain(last).filter(|(_, h)| h.hypot() > 0.0).map(|(p, h)| [p.x, p.y, p.x + h.x, p.y + h.y]).collect()
|
||||
}
|
||||
|
||||
/// The pen tool's in-progress path (page coordinates).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PenPath {
|
||||
@@ -1057,8 +1071,9 @@ impl Session {
|
||||
let (to, g) = if ev.shift { (ev.p(), Vec::new()) } else { self.snap_pen_tool(ev)? };
|
||||
guides = g;
|
||||
self.tool.overlay.cursor = Cursor::Pen(self.pen_cursor(&doc, &sel, to, ev.zoom));
|
||||
if let Some((last, _, _)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) {
|
||||
if let Some((last, _, out)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) {
|
||||
self.tool.overlay.rubber = Some([last.x, last.y, to.x, to.y]);
|
||||
self.tool.overlay.rubber_handle = (out.hypot() > 0.0).then_some([last.x + out.x, last.y + out.y]);
|
||||
}
|
||||
}
|
||||
self.tool.overlay.guides = guides;
|
||||
@@ -1974,6 +1989,9 @@ impl Session {
|
||||
|
||||
fn pen_down(&mut self, ev: &PointerEvent) -> Result<()> {
|
||||
self.tool.overlay.guides.clear();
|
||||
// The segment being placed shows as itself while the press lasts.
|
||||
self.tool.overlay.rubber = None;
|
||||
self.tool.overlay.rubber_handle = None;
|
||||
let p = if ev.shift { ev.p() } else { self.snap_pen_tool(ev)?.0 };
|
||||
let (doc, page) = {
|
||||
let ds = self.ds()?;
|
||||
@@ -2028,6 +2046,7 @@ impl Session {
|
||||
fn pen_update(&mut self) -> Result<()> {
|
||||
let Some(pen) = self.tool.pen.clone() else { return Ok(()) };
|
||||
self.tool.overlay.pen = pen.points.iter().map(|(p, _, _)| [p.x, p.y]).collect();
|
||||
self.tool.overlay.pen_handles = pen_handles(&pen.points);
|
||||
if pen.points.len() < 2 {
|
||||
if let Ok(ds) = self.ds_mut() {
|
||||
ds.revision += 1;
|
||||
@@ -2110,7 +2129,9 @@ impl Session {
|
||||
pub fn pen_finish(&mut self) -> Result<()> {
|
||||
let pen = self.tool.pen.take();
|
||||
self.tool.overlay.pen.clear();
|
||||
self.tool.overlay.pen_handles.clear();
|
||||
self.tool.overlay.rubber = None;
|
||||
self.tool.overlay.rubber_handle = None;
|
||||
self.tool.gesture = Gesture::None;
|
||||
if pen.as_ref().is_some_and(|p| p.id.is_some()) {
|
||||
self.commit_interaction()?;
|
||||
|
||||
@@ -973,12 +973,30 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
|
||||
if app.session.tool.tool == ToolId::Pencil {
|
||||
painter.add(Shape::line(pts, stroke(t.text, 2.0)));
|
||||
} else {
|
||||
for [x0, y0, x1, y1] in &ov.pen_handles {
|
||||
bezier_handle(painter, app.to_screen(*x0, *y0), app.to_screen(*x1, *y1), 3.5, Color32::WHITE);
|
||||
}
|
||||
for p in &pts {
|
||||
painter.circle_filled(*p, 3.5, Color32::WHITE);
|
||||
painter.circle_stroke(*p, 3.5, stroke(ACCENT, 1.0));
|
||||
}
|
||||
if let Some([x0, y0, x1, y1]) = ov.rubber {
|
||||
painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(ACCENT, 1.0));
|
||||
let (a, b) = (app.to_screen(x0, y0), app.to_screen(x1, y1));
|
||||
// The segment a click would make: along the last anchor's out handle, if it has one.
|
||||
match ov.rubber_handle {
|
||||
Some([hx, hy]) => {
|
||||
let h = app.to_screen(hx, hy);
|
||||
painter.add(egui::epaint::CubicBezierShape::from_points_stroke(
|
||||
[a, h, b, b],
|
||||
false,
|
||||
Color32::TRANSPARENT,
|
||||
stroke(ACCENT, 1.0),
|
||||
));
|
||||
}
|
||||
None => {
|
||||
painter.line_segment([a, b], stroke(ACCENT, 1.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1111,6 +1129,14 @@ fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &fig
|
||||
}
|
||||
}
|
||||
|
||||
/// A bezier handle from its anchor `v` to its end `e` (screen points): the line, and a dot of
|
||||
/// radius `r` filled with `fill` (vector edit mode and the Pen draw them alike).
|
||||
fn bezier_handle(painter: &egui::Painter, v: Pos2, e: Pos2, r: f32, fill: Color32) {
|
||||
painter.line_segment([v, e], stroke(ACCENT, 1.0));
|
||||
painter.circle_filled(e, r, fill);
|
||||
painter.circle_stroke(e, r, stroke(ACCENT, 1.0));
|
||||
}
|
||||
|
||||
/// Vector edit mode: a layer's segments, handles and points (`points`) with its `hover`ed part, and
|
||||
/// for the `active` layer the closed area under the paint bucket and the pen's rubber band.
|
||||
fn vector_overlay(
|
||||
@@ -1151,13 +1177,9 @@ fn vector_overlay(
|
||||
return;
|
||||
}
|
||||
for h in &o.handles {
|
||||
let (v, e) = (sp(&h.vertex), sp(&h.handle));
|
||||
painter.line_segment([v, e], stroke(ACCENT, 1.0));
|
||||
let hovered = hover == Some(VHit::Handle { seg: h.seg, start: h.start });
|
||||
let r = if hovered { 4.5 } else { 3.5 };
|
||||
let on = o.selected_handles.contains(&(h.seg, h.start));
|
||||
painter.circle_filled(e, r, if on { ACCENT } else { Color32::WHITE });
|
||||
painter.circle_stroke(e, r, stroke(ACCENT, 1.0));
|
||||
bezier_handle(painter, sp(&h.vertex), sp(&h.handle), if hovered { 4.5 } else { 3.5 }, if on { ACCENT } else { Color32::WHITE });
|
||||
}
|
||||
for (i, v) in o.vertices.iter().enumerate() {
|
||||
let p = sp(v);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Canvas fixes, driven through egui headless: View ▸ Frame outlines and View ▸ Slices draw (off
|
||||
//! by default), and the render worker counts a job as in flight until it is done.
|
||||
//! by default), the render worker counts a job as in flight until it is done, and the Pen shows
|
||||
//! the handles of the path it draws.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
@@ -91,3 +92,36 @@ fn a_job_queued_behind_a_finished_one_stays_in_flight() {
|
||||
// Not in flight only once the second is done: its image is then waiting.
|
||||
assert!(worker.in_flight() || worker.poll().is_some(), "the second job counts until its image is there");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pen_draws_the_handles_of_the_point_being_dragged_and_a_curved_band() {
|
||||
use egui::{Event, PointerButton};
|
||||
let mut u = Ui::new(Session::new());
|
||||
u.app.run("tool.set", json!({"tool": "pen"}));
|
||||
let at = |u: &Ui, x: f64, y: f64| u.app.to_screen(x, y);
|
||||
let press = |p: Pos2, pressed: bool| {
|
||||
vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() }]
|
||||
};
|
||||
let accent = egui::epaint::ColorMode::Solid(crate::theme::ACCENT);
|
||||
// A handle: the line from its anchor and the dot at its end.
|
||||
let handle = |shapes: &[Shape], a: Pos2, e: Pos2| {
|
||||
let near = |p: Pos2, q: Pos2| p.distance(q) < 0.5;
|
||||
let line = shapes.iter().any(|s| matches!(s, Shape::LineSegment { points: [p, q], .. } if near(*p, a) && near(*q, e)));
|
||||
let dot = shapes.iter().any(|s| matches!(s, Shape::Circle(c) if near(c.center, e) && c.fill == egui::Color32::WHITE));
|
||||
line && dot
|
||||
};
|
||||
let a = at(&u, 100.0, 100.0);
|
||||
u.frame(press(a, true));
|
||||
for k in 1..=4 {
|
||||
u.frame(vec![Event::PointerMoved(at(&u, 100.0 + 10.0 * f64::from(k), 100.0))]);
|
||||
}
|
||||
let shapes = flat(&u.frame_output(vec![]).shapes);
|
||||
assert!(handle(&shapes, a, at(&u, 140.0, 100.0)), "the out handle while dragging");
|
||||
assert!(handle(&shapes, a, at(&u, 60.0, 100.0)), "and the in handle opposite");
|
||||
u.frame(press(at(&u, 140.0, 100.0), false));
|
||||
// After release, towards the next point: the band curves along the out handle.
|
||||
let shapes = flat(&u.frame_output(vec![Event::PointerMoved(at(&u, 200.0, 200.0))]).shapes);
|
||||
assert!(handle(&shapes, a, at(&u, 140.0, 100.0)), "the handles stay");
|
||||
let band = shapes.iter().any(|s| matches!(s, Shape::CubicBezier(c) if c.points[0].distance(a) < 0.5 && c.points[1].distance(at(&u, 140.0, 100.0)) < 0.5 && c.stroke.color == accent));
|
||||
assert!(band, "a curve leaving along the out handle");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user