mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-02 16:29:34 -06:00
The Pen showed only its anchors while a path was drawn; handles appeared only in vector edit mode afterwards. Now: - The point being dragged shows its in and out handles, and the point before it its out handle (the handles shaping the segments at the pen), drawn like vector edit mode's (one shared canvas helper). - Moving on to the next point, the rubber band is the segment a click would make: it leaves the last anchor along its out handle instead of going straight. - A press no longer leaves the previous straight rubber band on screen over the segment being dragged. Overlay gains pen_handles and rubber_handle (shown in ui.inspect). Tests: pen_overlay_tests (handles during and after a drag, none and a straight band for corner points, cleared on finish); canvas_fixes_tests (handle lines and dots painted mid-drag, curved band after release). Refs #256 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
128 lines
6.2 KiB
Rust
128 lines
6.2 KiB
Rust
//! Canvas fixes, driven through egui headless: View ▸ Frame outlines and View ▸ Slices draw (off
|
|
//! 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};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use egui::{Pos2, Shape};
|
|
use figcraft_engine::Session;
|
|
use serde_json::json;
|
|
|
|
use crate::layers_drag_tests::Ui;
|
|
use crate::live_tests::flat;
|
|
use crate::present_tests::id;
|
|
|
|
/// Whether `shapes` hold a closed outline through the corners `q`.
|
|
fn outlined(shapes: &[Shape], q: [Pos2; 4]) -> bool {
|
|
shapes.iter().any(|s| matches!(s, Shape::Path(p) if p.closed && p.points.len() == 4 && p.points.iter().zip(q).all(|(a, b)| a.distance(b) < 0.5)))
|
|
}
|
|
|
|
/// How many line segments lie along the top edge from `a` to `b` (a dashed outline's dashes).
|
|
fn dashes_along(shapes: &[Shape], a: Pos2, b: Pos2) -> usize {
|
|
let on = |p: Pos2| (p.y - a.y).abs() < 0.5 && p.x >= a.x - 0.5 && p.x <= b.x + 0.5;
|
|
shapes.iter().filter(|s| matches!(s, Shape::LineSegment { points: [p, q], .. } if on(*p) && on(*q))).count()
|
|
}
|
|
|
|
#[test]
|
|
fn frame_outlines_and_slices_draw_when_shown() {
|
|
let mut s = Session::new();
|
|
let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 120})).unwrap());
|
|
let inner = id(&s.execute("frame.create", &json!({"x": 20, "y": 20, "width": 80, "height": 60, "parent": f})).unwrap());
|
|
let slice = id(&s.execute("node.create", &json!({"type": "slice", "x": 300, "y": 0, "width": 100, "height": 50})).unwrap());
|
|
s.execute("select.set", &json!({"ids": []})).unwrap();
|
|
let mut u = Ui::new(s);
|
|
let quad = |u: &Ui, i| crate::canvas::node_quad(&u.app, u.app.session.doc().unwrap(), i).unwrap();
|
|
let shapes = flat(&u.frame_output(vec![]).shapes);
|
|
let sq = quad(&u, slice);
|
|
assert!(!outlined(&shapes, quad(&u, inner)), "off by default");
|
|
assert_eq!(dashes_along(&shapes, sq[0], sq[1]), 0, "slices hidden by default");
|
|
u.app.run("view.frameOutlines", json!({}));
|
|
u.app.run("view.slices", json!({}));
|
|
let shapes = flat(&u.frame_output(vec![]).shapes);
|
|
for frame in [f, inner] {
|
|
assert!(outlined(&shapes, quad(&u, frame)), "every frame is outlined");
|
|
}
|
|
assert!(dashes_along(&shapes, sq[0], sq[1]) > 3, "the slice shows as a dashed rectangle");
|
|
u.app.run("view.frameOutlines", json!({"on": false}));
|
|
u.app.run("view.slices", json!({"on": false}));
|
|
let shapes = flat(&u.frame_output(vec![]).shapes);
|
|
assert!(!outlined(&shapes, quad(&u, inner)) && dashes_along(&shapes, sq[0], sq[1]) == 0, "both turn off again");
|
|
}
|
|
|
|
/// A job a few hundred milliseconds of rendering big.
|
|
fn big_job(doc: &Arc<figcraft_doc::Document>, page: figcraft_doc::NodeId, scale: f64) -> crate::render_worker::Job {
|
|
crate::render_worker::Job {
|
|
doc: doc.clone(),
|
|
page,
|
|
view: figcraft_render::View { origin: (0.0, 0.0), scale, width: 2400, height: 2400 },
|
|
outline: false,
|
|
outline_extras: Default::default(),
|
|
outlined: vec![],
|
|
skip: vec![],
|
|
solo: None,
|
|
proof: false,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_job_queued_behind_a_finished_one_stays_in_flight() {
|
|
let ctx = egui::Context::default();
|
|
let wakes = Arc::new(AtomicUsize::new(0));
|
|
let w = wakes.clone();
|
|
ctx.set_request_repaint_callback(move |_| {
|
|
w.fetch_add(1, Ordering::SeqCst);
|
|
});
|
|
let mut s = Session::new();
|
|
for k in 0..40 {
|
|
s.execute("shape.ellipse", &json!({"x": k * 50, "y": k * 50, "width": 400, "height": 400})).unwrap();
|
|
}
|
|
let ds = s.ds().unwrap();
|
|
let mut worker = crate::render_worker::Worker::new(ctx.clone());
|
|
worker.submit(big_job(&ds.doc, ds.page, 1.0));
|
|
let start = Instant::now();
|
|
while wakes.load(Ordering::SeqCst) == 0 {
|
|
assert!(start.elapsed() < Duration::from_secs(60), "the first render never finished");
|
|
std::thread::sleep(Duration::from_millis(2));
|
|
}
|
|
// As in a canvas frame: a new job goes out, then the finished one is picked up.
|
|
worker.submit(big_job(&ds.doc, ds.page, 0.9));
|
|
assert!(worker.poll().is_some(), "the first image");
|
|
// 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");
|
|
}
|