Canvas: selection subpaths and handles out of view add no triangles to a frame

Each subpath and each direction handle is now tested against the view on its own, and gets its triangles the first time it is in view. A group or compound path with one subpath in view, or a long path zoomed in with a few of its handles in view, draws only those.
This commit is contained in:
dotCooCoo
2026-11-07 05:15:13 +07:00
committed by bflatastic
parent 4a5ff880ff
commit 94741cdae1
2 changed files with 229 additions and 79 deletions
+143 -79
View File
@@ -4,10 +4,13 @@
//! color and set of selected anchors. The marks of all objects are joined into one mesh, in the
//! order they are drawn. A frame with nothing changed draws that mesh again. A pan moves the
//! triangles of outlines and handles, and places the anchor squares again, because a square's edges
//! are rounded to whole pixels where it lands. A frame with a new zoom, rotation or display scale
//! draws plain shapes and keeps nothing, and the next frame with the same view builds the marks.
//! are rounded to whole pixels where it lands. Each subpath, handle and square is tested against the
//! view on its own: one out of view adds nothing to the mesh, and a subpath or handle gets its
//! triangles the first time it is in view. A frame with a new zoom, rotation or display scale draws
//! plain shapes and keeps nothing, and the next frame with the same view builds the marks.
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::ops::Range;
use std::sync::{Arc, Weak};
use egui::emath::{GuiRounding as _, TSTransform};
@@ -101,17 +104,42 @@ impl ViewMap {
}
}
/// The triangles of one item in its run's mesh.
#[derive(Clone)]
struct Span {
vertices: Range<u32>,
indices: Range<u32>,
}
/// A polyline, disc or slanted line in a [`Mark::Rigid`].
struct RigidPiece {
/// The shape, in screen coordinates at the object's `base`, until it is first on screen.
shape: Option<Shape>,
/// Where the shape draws at the object's `base`.
bounds: egui::Rect,
span: Span,
}
/// A direction handle in a [`Mark::Handles`]: the line from its anchor and the dot at its end.
struct HandleLine {
from: [f64; 2],
to: [f64; 2],
/// Whether the line is, or is nearly, level or upright. Its triangles then move only by whole
/// pixels, and any other move draws it again.
rounded: bool,
/// The offset its triangles were drawn for.
at: [f64; 2],
/// Its triangles, from the first time it is on screen.
span: Option<Span>,
}
/// One run of an object's marks.
enum Mark {
/// Marks whose triangles keep their form when moved: polylines, discs and slanted lines.
/// `shapes` are in screen coordinates at the object's `base` and become `mesh` the first time
/// the run is on screen. Discs need the frame's own tessellator (`discs`).
Rigid { shapes: Vec<Shape>, mesh: Mesh, bounds: egui::Rect, points: usize, discs: bool },
/// Direction handles of one color, from [`ViewMap::view`]: the line from each anchor and the dot
/// at its end. They become `mesh` for the offset `at` the first time the run is on screen. With a
/// line that is, or is nearly, level or upright (`rounded`), `mesh` moves only by whole pixels,
/// and any other move draws the run again.
Handles { color: Color32, lines: Vec<[[f64; 2]; 2]>, rounded: bool, mesh: Mesh, at: [f64; 2], bounds: ([f64; 2], [f64; 2]) },
/// `mesh` holds the triangles of the pieces that have been on screen.
Rigid { pieces: Vec<RigidPiece>, mesh: Mesh, points: usize },
/// Direction handles of one color. `mesh` holds the triangles of those that have been on screen.
Handles { color: Color32, lines: Vec<HandleLine>, mesh: Mesh },
/// Anchor squares in one style, at positions from [`ViewMap::view`].
Squares { color: Color32, filled: bool, size: f32, at: Vec<[f64; 2]> },
/// A line that is, or is nearly, level or upright: the tessellator rounds those to whole pixels.
@@ -141,43 +169,30 @@ impl<'a> MarkList<'a> {
}
fn rigid(&mut self, shape: Shape) {
let rect = shape.visual_bounding_rect();
let count = match &shape {
Shape::Path(path) => path.points.len(),
_ => 1,
};
let disc = matches!(shape, Shape::Circle(_));
if let Some(Mark::Rigid { shapes, bounds, points, discs, .. }) = self.marks.last_mut() {
*bounds = bounds.union(rect);
let piece = RigidPiece { bounds: shape.visual_bounding_rect(), shape: Some(shape), span: Span { vertices: 0..0, indices: 0..0 } };
if let Some(Mark::Rigid { pieces, points, .. }) = self.marks.last_mut() {
*points += count;
*discs |= disc;
shapes.push(shape);
pieces.push(piece);
} else {
self.marks.push(Mark::Rigid { shapes: vec![shape], mesh: Mesh::default(), bounds: rect, points: count, discs: disc });
self.marks.push(Mark::Rigid { pieces: vec![piece], mesh: Mesh::default(), points: count });
}
}
/// A direction handle: its line from `from` and the dot at `to`.
fn handle(&mut self, from: Point, to: Point, color: Color32) {
let (a, b) = (self.map.view(from), self.map.view(to));
let is_rounded = is_rounded_line(self.map.screen(a), self.map.screen(b));
let least = [a[0].min(b[0]), a[1].min(b[1])];
let most = [a[0].max(b[0]), a[1].max(b[1])];
if let Some(Mark::Handles { color: c, lines, rounded, bounds, .. }) = self.marks.last_mut()
let rounded = is_rounded_line(self.map.screen(a), self.map.screen(b));
let line = HandleLine { from: a, to: b, rounded, at: [0.0; 2], span: None };
if let Some(Mark::Handles { color: c, lines, .. }) = self.marks.last_mut()
&& *c == color
{
*bounds = ([bounds.0[0].min(least[0]), bounds.0[1].min(least[1])], [bounds.1[0].max(most[0]), bounds.1[1].max(most[1])]);
*rounded |= is_rounded;
lines.push([a, b]);
lines.push(line);
} else {
self.marks.push(Mark::Handles {
color,
lines: vec![[a, b]],
rounded: is_rounded,
mesh: Mesh::default(),
at: [0.0; 2],
bounds: (least, most),
});
self.marks.push(Mark::Handles { color, lines: vec![line], mesh: Mesh::default() });
}
}
@@ -269,6 +284,14 @@ struct Dot {
mesh: Mesh,
}
/// A disc in one style, around the origin.
struct Disc {
radius: f32,
fill: Color32,
stroke: Stroke,
mesh: Mesh,
}
/// What one anchor square draws: the fill, then the stroke of a hollow one.
fn square_shapes(rect: egui::Rect, color: Color32, filled: bool) -> [Option<RectShape>; 2] {
if filled {
@@ -307,7 +330,7 @@ struct Composer<'a> {
/// Draws lines and squares that rounding keeps from being reused, culled by `clip`.
tessellator: Tessellator,
/// Draws what is kept for later frames, with nothing culled. Like `tessellator`, it has no discs,
/// so neither draws circles.
/// so neither draws circles: those come from `discs`, stamped where they land.
keeper: Tessellator,
/// Draws filled squares without the soft edge, when that edge falls between pixel centers: on a
/// square rounded to whole pixels, each pixel center is then fully in or fully out.
@@ -317,6 +340,7 @@ struct Composer<'a> {
look: HandleLook<'a>,
templates: &'a mut Vec<Template>,
dots: &'a mut Vec<Dot>,
discs: &'a mut Vec<Disc>,
out: Mesh,
}
@@ -327,50 +351,41 @@ fn stamp(out: &mut Mesh, mesh: &Mesh, delta: Vec2) {
out.indices.extend(mesh.indices.iter().map(|i| i + first));
}
/// Append the triangles of `span` in `mesh` to `out`, moved by `delta`.
fn stamp_span(out: &mut Mesh, mesh: &Mesh, span: &Span, delta: Vec2) {
let vertices = mesh.vertices.get(span.vertices.start as usize..span.vertices.end as usize);
let indices = mesh.indices.get(span.indices.start as usize..span.indices.end as usize);
let (Some(vertices), Some(indices)) = (vertices, indices) else { return };
let first = out.vertices.len() as u32;
out.vertices.extend(vertices.iter().map(|v| Vertex { pos: v.pos + delta, ..*v }));
out.indices.extend(indices.iter().map(|i| i - span.vertices.start + first));
}
/// Run `draw` on `mesh`, and return the triangles it added.
fn drawn_span(mesh: &mut Mesh, draw: impl FnOnce(&mut Mesh)) -> Span {
let (vertices, indices) = (mesh.vertices.len() as u32, mesh.indices.len() as u32);
draw(mesh);
Span { vertices: vertices..mesh.vertices.len() as u32, indices: indices..mesh.indices.len() as u32 }
}
impl Composer<'_> {
fn marks(&mut self, marks: &mut [Mark], base: [f64; 2]) {
let delta = vec2((self.map.offset[0] - base[0]) as f32, (self.map.offset[1] - base[1]) as f32);
let margin = 2.0 / self.pixels_per_point;
for mark in marks {
match mark {
Mark::Rigid { shapes, mesh, bounds, discs, .. } => {
if !bounds.translate(delta).expand(margin).intersects(self.clip) {
continue;
}
if !shapes.is_empty() {
let shapes = std::mem::take(shapes);
if *discs {
*mesh = tessellated(self.ctx, shapes, self.pixels_per_point);
} else {
for shape in shapes {
self.keeper.tessellate_shape(shape, mesh);
}
Mark::Rigid { pieces, mesh, .. } => {
for piece in pieces {
if !piece.bounds.translate(delta).expand(margin).intersects(self.clip) {
continue;
}
}
stamp(&mut self.out, mesh, delta);
}
Mark::Handles { color, lines, rounded, mesh, at, bounds } => {
let onscreen = egui::Rect::from_two_pos(self.map.screen(bounds.0), self.map.screen(bounds.1))
.expand(HANDLE_REACH + self.look.grow.max(0.0) / 2.0);
if !onscreen.intersects(self.clip) {
continue;
}
let moved = vec2((self.map.offset[0] - at[0]) as f32, (self.map.offset[1] - at[1]) as f32);
let whole_pixels = (moved * self.pixels_per_point).round() == moved * self.pixels_per_point;
if *rounded && !mesh.is_empty() && !whole_pixels {
let mut out = std::mem::take(&mut self.out);
self.handles(*color, lines, &mut out);
self.out = out;
continue;
}
if mesh.is_empty() {
self.handles(*color, lines, mesh);
*at = self.map.offset;
stamp(&mut self.out, mesh, Vec2::ZERO);
} else {
stamp(&mut self.out, mesh, moved);
if let Some(shape) = piece.shape.take() {
piece.span = self.rigid_span(shape, mesh);
}
stamp_span(&mut self.out, mesh, &piece.span, delta);
}
}
Mark::Handles { color, lines, mesh } => self.handles(*color, lines, mesh),
Mark::Squares { color, filled, size, at } => self.squares(*color, *filled, *size, at),
Mark::Segment { a, b, stroke } => {
let points = [self.map.screen(*a), self.map.screen(*b)];
@@ -380,8 +395,36 @@ impl Composer<'_> {
}
}
/// Draw direction handles into `mesh`.
fn handles(&mut self, color: Color32, lines: &[[[f64; 2]; 2]], mesh: &mut Mesh) {
/// Draw `shape` into `mesh`.
fn rigid_span(&mut self, shape: Shape, mesh: &mut Mesh) -> Span {
drawn_span(mesh, |mesh| match shape {
Shape::Circle(circle) => {
let index = self.disc(&circle);
if let Some(disc) = self.discs.get(index) {
stamp(mesh, &disc.mesh, circle.center.to_vec2());
}
}
shape => self.keeper.tessellate_shape(shape, mesh),
})
}
/// The index of the disc in the style of `circle`, made when there is none.
fn disc(&mut self, circle: &egui::epaint::CircleShape) -> usize {
let same = |d: &Disc| d.radius == circle.radius && d.fill == circle.fill && d.stroke == circle.stroke;
if let Some(index) = self.discs.iter().position(same) {
return index;
}
let shape = Shape::Circle(egui::epaint::CircleShape { center: Pos2::ZERO, ..*circle });
let mesh = tessellated(self.ctx, vec![shape], self.pixels_per_point);
if self.discs.len() >= MAX_TEMPLATES {
self.discs.clear();
}
self.discs.push(Disc { radius: circle.radius, fill: circle.fill, stroke: circle.stroke, mesh });
self.discs.len() - 1
}
/// Add the direction handles in view to the frame, drawing those not yet in `mesh` into it.
fn handles(&mut self, color: Color32, lines: &mut [HandleLine], mesh: &mut Mesh) {
let index = match self.dots.iter().position(|d| d.color == color) {
Some(index) => index,
None => {
@@ -396,10 +439,29 @@ impl Composer<'_> {
};
let Some(dot) = self.dots.get(index) else { return };
let stroke = Stroke::new(1.0, color);
for [from, to] in lines {
let (from, to) = (self.map.screen(*from), self.map.screen(*to));
self.keeper.tessellate_line_segment([from, to], stroke, mesh);
stamp(mesh, &dot.mesh, to.to_vec2());
let reach = HANDLE_REACH + self.look.grow.max(0.0) / 2.0;
for line in lines {
let (from, to) = (self.map.screen(line.from), self.map.screen(line.to));
if !egui::Rect::from_two_pos(from, to).expand(reach).intersects(self.clip) {
continue;
}
let moved = vec2((self.map.offset[0] - line.at[0]) as f32, (self.map.offset[1] - line.at[1]) as f32);
let whole_pixels = (moved * self.pixels_per_point).round() == moved * self.pixels_per_point;
let keeper = &mut self.keeper;
let mut draw = |mesh: &mut Mesh| {
keeper.tessellate_line_segment([from, to], stroke, mesh);
stamp(mesh, &dot.mesh, to.to_vec2());
};
match &line.span {
Some(span) if !line.rounded || whole_pixels => stamp_span(&mut self.out, mesh, span, moved),
Some(_) => draw(&mut self.out),
None => {
let span = drawn_span(mesh, draw);
stamp_span(&mut self.out, mesh, &span, Vec2::ZERO);
line.at = self.map.offset;
line.span = Some(span);
}
}
}
}
@@ -477,6 +539,7 @@ pub struct SelectionMarks {
key: Option<(NodeId, ObjectMarks)>,
templates: Vec<Template>,
dots: Vec<Dot>,
discs: Vec<Disc>,
frame: Arc<Mesh>,
shown: Option<Shown>,
order: Vec<NodeId>,
@@ -599,7 +662,7 @@ impl SelectionMarks {
{
self.composes += 1;
}
let Self { objects, key, templates, dots, frame, order, kept, .. } = self;
let Self { objects, key, templates, dots, discs, frame, order, kept, .. } = self;
let all_handles = options.all_handles;
let mut out = Arc::try_unwrap(std::mem::take(frame)).unwrap_or_default();
out.clear();
@@ -623,6 +686,7 @@ impl SelectionMarks {
look: options.look,
templates,
dots,
discs,
out,
};
if let Some((_, entry)) = key {
@@ -715,14 +779,14 @@ fn draw_once(st: &vectorcraft_engine::DocState, p: &egui::Painter, xf: &Xf, opti
fn add_marks(p: &egui::Painter, map: &ViewMap, look: HandleLook, marks: Vec<Mark>) {
for mark in marks {
match mark {
Mark::Rigid { shapes, .. } => {
for shape in shapes {
Mark::Rigid { pieces, .. } => {
for shape in pieces.into_iter().filter_map(|piece| piece.shape) {
p.add(shape);
}
}
Mark::Handles { color, lines, .. } => {
for [from, to] in lines {
let (from, to) = (map.screen(from), map.screen(to));
for line in lines {
let (from, to) = (map.screen(line.from), map.screen(line.to));
p.line_segment([from, to], Stroke::new(1.0, color));
look.draw(p, to, color);
}
@@ -376,6 +376,92 @@ fn a_zoom_keeps_nothing_until_the_view_stays() {
assert!(marks.builds > builds && !marks.objects.is_empty(), "the same view again builds the marks");
}
/// Whether every vertex of the kept frame lies within a few pixels of the picture.
fn all_near_the_picture(marks: &SelectionMarks) -> bool {
let near = egui::Rect::from_min_size(Pos2::ZERO, vec2(SIZE.0, SIZE.1)).expand(8.0);
marks.frame.vertices.iter().all(|v| near.contains(v.pos))
}
/// A group of rectangles 1,000 points apart keeps triangles only for the ones in view. One that
/// comes into view gets its triangles then.
#[test]
fn a_frame_holds_no_triangles_of_a_groups_subpaths_out_of_view() {
for tool in ["selection", "directSelection"] {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
for i in 0..12 {
app.run("shape.rectangle", json!({"x": 40 + i * 1000, "y": 40, "width": 60, "height": 40})).unwrap();
}
app.run("select.all", json!({})).unwrap();
app.run("object.group", json!({})).unwrap();
app.run("select.all", json!({})).unwrap();
app.select_tool(tool);
let ctx = egui::Context::default();
let mut marks = SelectionMarks::default();
for (dx, seen) in [(0.0, 1), (1000.0, 2), (1000.37, 2), (11_000.0, 3), (0.0, 3)] {
let view = xf(1.0, (VIEW.0 + dx, VIEW.1), 0.0);
assert_matches_plain(&ctx, &mut marks, &app, &view, 1.0, &format!("{tool}, pan {dx}"));
assert!(!marks.frame.is_empty(), "{tool}, pan {dx}: a rectangle is in view");
assert!(all_near_the_picture(&marks), "{tool}, pan {dx}: triangles of a rectangle out of view");
let waiting: usize = marks
.objects
.values()
.flat_map(|entry| &entry.marks)
.map(|mark| match mark {
Mark::Rigid { pieces, .. } => pieces.iter().filter(|piece| piece.shape.is_some()).count(),
_ => 0,
})
.sum();
assert_eq!(waiting, 12 - seen, "{tool}, pan {dx}: rectangles not yet in view get no triangles");
}
}
}
/// How many triangles the direction handles add to a frame of a direct-selected path with `anchors`
/// anchors 1,000 points apart.
fn handle_triangles(anchors: usize, view: &Xf) -> (usize, VectorcraftApp, SelectionMarks, egui::Context) {
let mut d = String::from("M40 150");
for i in 0..anchors - 1 {
let (from, to) = (40 + i * 1000, 40 + (i + 1) * 1000);
// Handles alternate between level, which the tessellator rounds to whole pixels, and slanted.
let y = if i % 2 == 0 { 150 } else { 110 };
d += &format!(" C{} {y} {} 190 {to} 150", from + 30, to - 30);
}
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.run("path.create", json!({"d": d})).unwrap();
app.run("select.all", json!({})).unwrap();
app.select_tool("directSelection");
let ctx = egui::Context::default();
let mut marks = SelectionMarks::default();
let mut triangles = |app: &mut VectorcraftApp, all_handles: bool| {
app.session.prefs.show_handles_multiple_anchors = all_handles;
for _ in 0..2 {
picture(&ctx, 1.0, |p| paint(&mut marks, app, p, view, false));
}
marks.frame.indices.len() / 3
};
let without = triangles(&mut app, false);
let with = triangles(&mut app, true);
(with - without, app, marks, ctx)
}
/// A frame of a long path shows the handles in view and holds the triangles of those alone, exactly
/// as many as a path that ends just past the view. Handles that come into view draw correctly, level
/// ones after a move by a fraction of a pixel too.
#[test]
fn a_frame_holds_no_triangles_of_handles_out_of_view() {
let view = xf(1.0, VIEW, 0.0);
let (short, ..) = handle_triangles(2, &view);
let (long, app, mut marks, ctx) = handle_triangles(40, &view);
assert!(short > 0, "the handle in view draws");
assert_eq!(long, short, "the long path's handles out of view add nothing");
for (dx, dy) in [(0.0, 0.0), (0.0, 0.37), (1000.0, 0.0), (1000.61, 0.61), (2000.0, 0.0), (2000.0, 0.5), (5000.5, 0.25), (0.0, 0.0)] {
let view = xf(1.0, (VIEW.0 + dx, VIEW.1 + dy), 0.0);
assert_matches_plain(&ctx, &mut marks, &app, &view, 1.0, &format!("pan {dx} {dy}"));
}
}
#[test]
fn no_stale_marks_after_a_change_with_the_selection_tool() {
no_stale_marks_after_a_change("selection");