Type: a huge selection of type on a path draws no per-object brackets

The Selection and Direct Selection tools drew the start, center and end
brackets of every selected type on a path, and hit-tested each of
them, however large the selection was: 32,768 selected type on a path
objects added 1,310,720 vertices to the frame's mesh, 40 for each
object. The PDF importer makes type on a path for curved text lines,
so selecting all of a large PDF can reach that size.

With more than 10,000 type on a path objects selected, the tools now
neither draw nor hit-test any bracket: a press where a bracket would
be does not start a bracket drag, and the pointer does not change to
the bracket cursor there. The tools draw 10 points for each object's
brackets, so 10,000 objects take 100,000, the same number as the
canvas's limit on the anchor points it outlines. The tools crate has
its own constant with the same value.

Docs: docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-17 16:38:42 +07:00
committed by bflatastic
parent df16d21e84
commit c5405479b9
3 changed files with 114 additions and 5 deletions
+69 -4
View File
@@ -3,7 +3,8 @@
//! type begins or ends along its path; dragging the centre bracket slides the type along the path
//! (its brackets with it) and, dragged across the path, flips the type to the other side
//! (Cmd/Ctrl held: it only slides). Each drag is one undo step of `type.pathOptions`. The tools
//! draw the brackets from the same geometry they hit-test.
//! draw the brackets from the same geometry they hit-test. With more than 10,000 type on a path
//! objects selected, they neither draw nor hit-test any bracket.
use serde_json::{Map, Value, json};
use vectorcraft_doc::{NodeId, NodeKind, TextKind};
@@ -19,6 +20,13 @@ const DOWN_PX: f64 = 4.0;
const FOOT_PX: f64 = 4.0;
/// How far past the path (screen px) the centre bracket goes before the type flips.
const FLIP_PX: f64 = 6.0;
/// The most points the tools draw for the brackets of a selection, the same number as the canvas's
/// limit on the anchor points it outlines (`OVERLAY_MAX_ANCHORS` in `crates/ui-egui/src/canvas.rs`).
/// Past it the tools neither draw nor hit-test any bracket.
const MAX_POINTS: usize = 100_000;
/// The points the tools draw for one type on a path's brackets: the two ends of each of the five
/// lines.
const POINTS_EACH: usize = 10;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Bracket {
@@ -110,6 +118,13 @@ impl Brackets {
}
}
/// Whether the selection holds more than [`MAX_POINTS`] / [`POINTS_EACH`] type on a path objects,
/// in perspective or not (counting stops there).
fn too_many(cx: &ToolContext) -> bool {
let on_path = |id: &&NodeId| cx.doc.node(**id).is_some_and(|n| matches!(&n.kind, NodeKind::Text(t) if matches!(t.kind, TextKind::OnPath { .. })));
cx.selection.objects.iter().filter(on_path).nth(MAX_POINTS / POINTS_EACH).is_some()
}
/// The selected type on a path's bracket under `p`, if any.
fn bracket_at(cx: &ToolContext, p: Point) -> Option<(Brackets, Bracket)> {
Brackets::of(cx).into_iter().find_map(|b| {
@@ -120,11 +135,14 @@ fn bracket_at(cx: &ToolContext, p: Point) -> Option<(Brackets, Bracket)> {
/// Is `p` over a bracket of the selected type on a path?
pub fn over_bracket(cx: &ToolContext, p: Point) -> bool {
bracket_at(cx, p).is_some()
!too_many(cx) && bracket_at(cx, p).is_some()
}
/// The brackets of the selected type on a path, in their layer's colour.
pub fn overlays(cx: &ToolContext) -> Vec<Overlay> {
if too_many(cx) {
return vec![];
}
let mut out = vec![];
for b in Brackets::of(cx) {
let color = cx.doc.layer_color(b.id);
@@ -149,6 +167,9 @@ pub struct BracketDrag {
impl BracketDrag {
/// Start a drag when the press `ev` is on a bracket of the selected type on a path.
pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option<Self> {
if too_many(cx) {
return None;
}
let (brackets, which) = bracket_at(cx, ev.pos)?;
let grab = brackets.fraction_at(ev.pos)?;
Some(Self { brackets, which, start: ev.pos, grab, began: false })
@@ -214,8 +235,8 @@ impl BracketDrag {
mod tests {
use super::*;
use crate::testutil::*;
use crate::{Mods, PointerKind};
use vectorcraft_doc::Selection;
use crate::{Cursor, Mods, PointerKind, Tool};
use vectorcraft_doc::{Document, Selection};
fn selected(id: NodeId) -> Selection {
let mut s = Selection::default();
@@ -260,6 +281,50 @@ mod tests {
assert!(Brackets::of(&crate::testutil::cx(&d2, &s2, &p)).is_empty());
}
/// 10,000 selected type on a path objects keep their brackets (100,000 points); with one more,
/// the Selection and Direct Selection tools neither draw them nor start a drag or show the
/// bracket cursor over them.
#[test]
fn more_than_ten_thousand_type_on_a_path_objects_show_no_brackets() {
let (mut d, id) = doc_with_path_type();
let l = d.layers[0].id;
let kind = d.node(id).unwrap().kind.clone();
let mut s = selected(id);
let add = |d: &mut Document, s: &mut Selection, n: usize| {
for _ in 0..n {
let copy = d.alloc_id();
d.insert(Some(l), usize::MAX, vectorcraft_doc::Node::new(copy, kind.clone())).unwrap();
s.add(copy);
}
};
add(&mut d, &mut s, 9_999);
let p = paint();
// On the start bracket of every copy (they all lie on the same path).
let at = Point::new(160.0, 290.0);
let press = PointerEvent::new(PointerKind::Down, at.x, at.y);
let lines = |o: &[Overlay]| o.iter().filter(|o| matches!(o, Overlay::Line { .. })).count();
let tools = |cx: &ToolContext| -> Vec<(usize, Cursor)> {
let select = crate::select::SelectionTool::default();
let direct = crate::direct::DirectSelectionTool::new(false);
let tools: [&dyn Tool; 2] = [&select, &direct];
tools.iter().map(|t| (lines(&t.overlays(cx)), t.cursor(cx, at, Mods::default()))).collect()
};
{
let cx = cx(&d, &s, &p);
assert_eq!(overlays(&cx).len(), 50_000);
assert!(over_bracket(&cx, at) && BracketDrag::hit(&cx, &press).is_some());
assert_eq!(tools(&cx), vec![(50_000, Cursor::PathBracket); 2]);
}
add(&mut d, &mut s, 1);
let cx = cx(&d, &s, &p);
assert!(overlays(&cx).is_empty());
assert!(!over_bracket(&cx, at) && BracketDrag::hit(&cx, &press).is_none());
for (n, cursor) in tools(&cx) {
assert_eq!(n, 0);
assert_ne!(cursor, Cursor::PathBracket);
}
}
#[test]
fn dragging_the_centre_bracket_slides_the_type_and_across_the_path_flips_it() {
let (d, id) = doc_with_path_type();
+44 -1
View File
@@ -1,5 +1,6 @@
//! Type → Type on a Path → Type on a Path Options… (#429): the dialog shows the selected type's
//! options (Effect, Flip, Align to Path, Spacing) and OK leaves its brackets where they are.
//! options (Effect, Flip, Align to Path, Spacing) and OK leaves its brackets where they are. The
//! Selection tool draws no brackets on a huge selection.
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, NodeKind, TextKind};
@@ -27,6 +28,17 @@ fn open_options(app: &mut VectorcraftApp) {
menus::invoke(app, &cmd, params);
}
/// Two headless canvas frames → the vertices of the second one's tessellated mesh, which the GPU
/// takes in one buffer.
fn canvas_vertices(app: &mut VectorcraftApp, ctx: &egui::Context) -> usize {
let raw = || egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), ..Default::default() };
ctx.run_ui(raw(), |ui| crate::canvas::show(app, ui)).textures_delta.clear();
let mut out = ctx.run_ui(raw(), |ui| crate::canvas::show(app, ui));
out.textures_delta.clear();
let mesh = |p: &egui::ClippedPrimitive| if let egui::epaint::Primitive::Mesh(m) = &p.primitive { m.vertices.len() } else { 0 };
ctx.tessellate(out.shapes, out.pixels_per_point).iter().map(mesh).sum()
}
#[test]
fn type_on_a_path_options_keep_the_type_where_it_is() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
@@ -67,3 +79,34 @@ fn type_on_a_path_options_keep_the_type_where_it_is() {
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.fields.get("alignToPath"), d.fields.get("flip")), (Some(&json!("center")), Some(&json!(false))));
}
/// The Selection tool's brackets on one selected type on a path add their lines to the frame's
/// mesh. On 32,768 (an opened PDF can hold as many curved text lines) they add none.
#[test]
fn a_huge_selection_of_type_on_a_path_draws_no_brackets() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let path = app.run("path.create", json!({"d": "M20 100 L380 100"})).unwrap()["id"].clone();
app.run("text.createInPath", json!({"path": path, "mode": "onPath", "text": "Along"})).unwrap();
app.ui.task_bar = false;
app.ui.view.bounding_box = false;
let ctx = egui::Context::default();
// The vertices that the Selection tool adds over the Rectangle tool, which draws nothing of its
// own, with everything selected.
let brackets = |app: &mut VectorcraftApp| {
app.run("select.all", json!({})).unwrap();
app.select_tool("rectangle");
let rectangle = canvas_vertices(app, &ctx);
app.select_tool("selection");
canvas_vertices(app, &ctx).saturating_sub(rectangle)
};
let one = brackets(&mut app);
assert!(one > 0, "one shows its brackets");
for _ in 0..15 {
app.run("select.all", json!({})).unwrap();
app.run("edit.duplicate", json!({})).unwrap();
}
let many = brackets(&mut app);
assert_eq!(app.session.active().unwrap().selection.objects.len(), 32_768);
assert_eq!(many, 0, "32,768 show no brackets, not {many} vertices");
}
+1
View File
@@ -699,6 +699,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
The renderer composes the two ink planes of a CMYK document that shows transparency into screen colors on up to as many threads as it rasterizes with, instead of on one. At 2880 × 1800 on 8 threads, a page of 300 shapes at 20% to 90% opacity takes 30 ms a frame instead of 55 ms, and 50,000 paths with one at 99% opacity take 258 ms instead of 361 ms (`vectorcraft-cli bench`), with the same pixels.
With Overprint Preview or Separations Preview on, the renderer keeps the art it prepared for the preview from one frame to the next. It prepares again the objects that changed, and all of the art when Discard White Overprint changes or, with Separations Preview, when the plates shown, the proof setup, the swatches, the Spot Colors option or the color settings change or a profile is loaded. It draws the other objects from the art, geometry and stroke outlines it kept for them.
The Pathfinder panel, Offset Path, Clean Up, Divide Objects Below, Make and Release Blend, Envelope Distort's Make and Release, Repeat (Radial, Grid, Mirror and Release), Vector Halftone, New and Redefine Symbol, Delete Symbol, erasing with the Symbol Sprayer, Rasterize, Convert to Artboards, Flatten Transparency, Clear Guides, Shape Builder, Live Paint, Release Image Trace, Make and Release Opacity Mask, Delete with direct-selected anchor points, Remove Anchor Points, Cut Path, and the Shaper, Knife, Eraser, Path Eraser, Line Cut, Rectangle Cut, Mirror & Cut and Blob Brush tools add and remove objects in one pass over each layer or group they change, and Outline Stroke and Expand write each new object in the place of the object it replaces (#1429).
With more than 10,000 type on a path objects selected, the Selection and Direct Selection tools neither draw nor hit-test the objects' brackets.
Type › Show Hidden Characters keeps the marks it places on each text object from one frame to the next. The canvas lays a text object out again when an edit changes it or its threaded story, or when the last frame that showed the marks did not draw it, and it lays out all type in view again when the fonts or the hyphenation exceptions change. With 1,000 two-line labels in view, drawing the marks takes 0.8 ms per frame in a release build instead of 19.7 ms.
Delete, Cut, Group, Ungroup, Make and Release Compound Path, Make Clipping Mask, Send to Current Layer, Duplicate, Copy in the Move, Rotate, Scale, Reflect and Shear dialogs, Transform Each with Copy and each move of an Alt-drag take time in proportion to the size of the document (#1429).
In the web build the status bar's render and UI times are measured with the browser's `performance.now()`, and the Layers panel stops rendering row thumbnails in a frame once they have taken 8 ms.