Object: Lock and Hide › All Artwork Above go through each top-level layer once

Lock › All Artwork Above and Hide › All Artwork Above went through the
objects above each selected object in its top-level layer, one selected
object at a time. For each object they looked up in a list whether it
was selected or already found, and they computed its bounds again for
each selected object below it until one overlapped it. With 100 tiles
selected under 25,000 rectangles, a release build of vectorcraft-cli
took 6.4 s for Lock, and with all of 5,001 objects selected it took
8.8 s and locked nothing.

The commands now go through each top-level layer that holds a selected
object once, and check each object that is not selected against the
bounds of the selected objects below it. The selected objects and the
objects found are kept in sets, and each object's bounds are computed
once. The objects they lock or hide, the count, the selection and the
undo step are the same as before. On the same documents Lock now takes
under 0.1 s.

Docs: docs/target-app-parity.md.
This commit is contained in:
dotCooCoo authored and bflatastic committed 2026-10-13 12:58:05 +07:00
1 parent 35d6aa0a16
commit f0810ca58b
4 files changed
+125 -12

No files matched your search

+22 -5
View File
@@ -2,6 +2,7 @@
//! Bounding Box, Rasterize, Crop Image, Mask (an image), Create Trim Marks, Convert to Shape and
//! Artboards → Convert / Rearrange. (Live blends are in `live.rs`.)
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::sync::Arc;
use serde_json::{Value, json};
@@ -209,7 +210,9 @@ fn reset_bounding_box(s: &mut Session, _: &Value) -> Result<Value> {
fn above(s: &mut Session, lock: bool) -> Result<Value> {
let roots = selected_roots(s)?;
let d = &s.doc()?.doc;
let mut hits: Vec<NodeId> = vec![];
// `selected` holds each selected object's visual bounds, by layer, with the index there of its
// ancestor directly inside the layer.
let mut selected: BTreeMap<NodeId, Vec<(usize, Rect)>> = BTreeMap::new();
for id in &roots {
let Some(layer) = d.layer_of(*id) else { continue };
// The selected object's ancestor directly inside the layer.
@@ -218,12 +221,26 @@ fn above(s: &mut Session, lock: bool) -> Result<Value> {
let top = anc.get(pos + 1).copied().unwrap_or(*id);
let Some(b) = d.node(*id).and_then(|n| n.visual_bounds()) else { continue };
let Some((_, idx, _)) = d.position(top) else { continue };
for n in d.children(Some(layer)).into_iter().flatten().skip(idx + 1) {
if roots.contains(&n.id) || hits.contains(&n.id) {
selected.entry(layer).or_default().push((idx, b));
}
// The loop walks up each layer once. Each object that is not selected and overlaps a selected
// object below it is locked (Lock) or hidden (Hide).
let roots: HashSet<NodeId> = roots.into_iter().collect();
let mut hits: BTreeSet<NodeId> = BTreeSet::new();
for (layer, mut below) in selected {
below.sort_by_key(|(idx, _)| *idx);
let mut below = below.into_iter().peekable();
// `under` holds the bounds of the selected objects below the object at `i`.
let mut under: Vec<Rect> = vec![];
for (i, n) in d.children(Some(layer)).into_iter().flatten().enumerate() {
while let Some((_, b)) = below.next_if(|(idx, _)| *idx < i) {
under.push(b);
}
if under.is_empty() || roots.contains(&n.id) {
continue;
}
if n.visual_bounds().is_some_and(|nb| nb.intersect(b).area() > 0.0 || nb.contains(b.center())) {
hits.push(n.id);
if n.visual_bounds().is_some_and(|nb| under.iter().any(|b| nb.intersect(*b).area() > 0.0 || nb.contains(b.center()))) {
hits.insert(n.id);
}
}
}
+5 -5
View File
@@ -22,13 +22,13 @@ fn square_at(k: usize) -> Rect {
}
/// Square `k` ([`square_at`]) as a path with a new id.
fn square(d: &mut Document, k: usize) -> Node {
pub(crate) fn square(d: &mut Document, k: usize) -> Node {
Node::path(d.alloc_id(), shapes::rectangle(square_at(k)), Appearance::default_art())
}
/// A session whose document holds `make(d, k)` for each `k` below `n` on its one layer, all of it
/// selected.
fn session(n: usize, mut make: impl FnMut(&mut Document, usize) -> Node) -> Session {
pub(crate) fn session(n: usize, mut make: impl FnMut(&mut Document, usize) -> Node) -> Session {
let mut d = Document::new(ROW as f64 * 20.0, (n / ROW + 1) as f64 * 20.0);
let art: Vec<Arc<Node>> = (0..n).map(|k| Arc::new(make(&mut d, k))).collect();
if let Some(ch) = Arc::make_mut(&mut d.layers[0]).children_mut() {
@@ -46,7 +46,7 @@ fn ids_of(n: &Node) -> Vec<NodeId> {
}
/// The ids of the objects on layer `i`.
fn layer(s: &Session, i: usize) -> Vec<NodeId> {
pub(crate) fn layer(s: &Session, i: usize) -> Vec<NodeId> {
ids_of(&s.doc().unwrap().doc.layers[i])
}
@@ -76,7 +76,7 @@ fn soon<T: Send + 'static>(mut s: Session, what: &'static str, f: impl FnOnce(&m
}
/// Runs command `cmd` with `params` through [`soon`] and returns its result.
fn run_soon(s: Session, cmd: &'static str, params: Value) -> (Session, Value) {
pub(crate) fn run_soon(s: Session, cmd: &'static str, params: Value) -> (Session, Value) {
let (s, r) = soon(s, cmd, move |s| s.execute(cmd, &params));
(s, r.unwrap())
}
@@ -87,7 +87,7 @@ fn ids_in(r: &Value) -> Vec<NodeId> {
}
/// Runs Undo and checks that it gives back `before`.
fn undo_gives_back(s: &mut Session, before: &Document) {
pub(crate) fn undo_gives_back(s: &mut Session, before: &Document) {
s.execute("edit.undo", &json!({})).unwrap();
assert!(*s.doc().unwrap().doc == *before, "Undo gives back the document");
}
+97 -2
View File
@@ -2,9 +2,10 @@
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{ColorMode, LiveShape, Node, NodeKind, TextKind};
use vectorcraft_geom::Rect;
use vectorcraft_doc::{Appearance, ColorMode, LiveShape, Node, NodeKind, TextKind};
use vectorcraft_geom::{Rect, shapes};
use super::tests_large_edits::{layer, run_soon, square, undo_gives_back};
use super::*;
fn session() -> Session {
@@ -137,6 +138,100 @@ fn hide_all_artwork_above_deselects_the_objects_it_hides() {
assert_eq!((selected(&s), key(&s)), (vec![hidden, a, b], Some(a)));
}
/// Lock › All Artwork Above and Hide › All Artwork Above take each object that overlaps a selected
/// object below it in the same layer, once, whether it is locked or hidden already or not. An object
/// that overlaps only a selected object above it, the group around a selected object, the selected
/// objects and an object that lies over a selected object of another layer stay as they are.
#[test]
fn lock_and_hide_all_artwork_above_take_what_overlaps_a_selected_object_below_in_its_layer() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
// `b` overlaps only `c`, which lies above it.
let b = rect(&mut s, 250.0, 0.0, 100.0, 100.0);
let c = rect(&mut s, 90.0, 0.0, 200.0, 100.0);
// `d` overlaps `a`, `e` overlaps `c`, `f` overlaps both, `g` overlaps neither, and `k` overlaps `a` and is locked.
let d = rect(&mut s, 20.0, 50.0, 30.0, 30.0);
let e = rect(&mut s, 200.0, 50.0, 30.0, 30.0);
let f = rect(&mut s, 60.0, 10.0, 50.0, 20.0);
let g = rect(&mut s, 500.0, 500.0, 20.0, 20.0);
let k = rect(&mut s, 10.0, 10.0, 10.0, 10.0);
s.execute("layer.setProps", &json!({"id": k.0, "locked": true})).unwrap();
// `m` is in a group, and `n` overlaps it.
let m = rect(&mut s, 800.0, 0.0, 50.0, 50.0);
sel(&mut s, &[m]);
let group = id_of(&s.execute("object.group", &json!({})).unwrap());
let n = rect(&mut s, 820.0, 20.0, 50.0, 50.0);
// In another layer, `h` lies where `a` lies, and `j` overlaps `i`.
s.execute("layer.new", &json!({})).unwrap();
let h = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let i = rect(&mut s, 600.0, 0.0, 100.0, 100.0);
let j = rect(&mut s, 650.0, 50.0, 100.0, 100.0);
let (taken, kept) = ([d, e, f, k, n, j], [a, b, c, g, group, m, h, i]);
for (cmd, lock) in [("object.lock.above", true), ("object.hide.above", false)] {
let done = |s: &Session, id: NodeId| if lock { node(s, id).locked } else { !node(s, id).visible };
sel(&mut s, &[a, c, m, i]);
let steps = undo_len(&s);
assert_eq!(s.execute(cmd, &json!({})).unwrap(), json!({"count": 6}), "{cmd}");
assert!(taken.iter().all(|id| done(&s, *id)), "{cmd}");
assert!(!kept.iter().any(|id| done(&s, *id)), "{cmd}");
assert_eq!((undo_len(&s), selected(&s)), (steps + 1, vec![a, c, m, i]), "{cmd}");
s.execute("edit.undo", &json!({})).unwrap();
}
}
/// A session with 1,000 tiles of 100 x 200 pt in rows of 40 under the 50,000 squares of [`square`],
/// 50 on each tile, all of it selected; and the ids of the tiles and of the squares.
fn tiles_under_squares() -> (Session, Vec<NodeId>, Vec<NodeId>) {
let tile = |t: usize| {
let (x, y) = ((t % 40) as f64 * 100.0, (t / 40) as f64 * 200.0);
Rect::new(x, y, x + 100.0, y + 200.0)
};
let s = super::tests_large_edits::session(51_000, |d, k| {
if k < 1000 { Node::path(d.alloc_id(), shapes::rectangle(tile(k)), Appearance::default_art()) } else { square(d, k - 1000) }
});
let mut tiles = layer(&s, 0);
let squares = tiles.split_off(1000);
(s, tiles, squares)
}
/// With 1,000 tiles selected under 50,000 squares, Lock › All Artwork Above locks every square in one
/// undo step and Hide › All Artwork Above hides every square, each within 20 s, and the tiles stay
/// selected.
#[test]
fn lock_and_hide_all_artwork_above_1000_tiles_under_50000_objects_finish_within_20_s() {
let (mut s, tiles, squares) = tiles_under_squares();
sel(&mut s, &tiles);
let before = s.doc().unwrap().doc.clone();
let (mut s, r) = run_soon(s, "object.lock.above", json!({}));
assert_eq!(r, json!({"count": 50_000}));
let d = &s.doc().unwrap().doc;
assert!(squares.iter().all(|id| d.node(*id).is_some_and(|n| n.locked)), "every square is locked");
assert!(tiles.iter().all(|id| d.node(*id).is_some_and(|n| !n.locked)), "no tile is locked");
assert!(selected(&s) == tiles, "the tiles stay selected");
undo_gives_back(&mut s, &before);
let (s, r) = run_soon(s, "object.hide.above", json!({}));
assert_eq!(r, json!({"count": 50_000}));
let d = &s.doc().unwrap().doc;
assert!(squares.iter().all(|id| d.node(*id).is_some_and(|n| !n.visible)), "every square is hidden");
assert!(tiles.iter().all(|id| d.node(*id).is_some_and(|n| n.visible)), "no tile is hidden");
assert!(selected(&s) == tiles, "the tiles stay selected");
}
/// With all 51,000 objects of [`tiles_under_squares`] selected, Lock › All Artwork Above and Hide ›
/// All Artwork Above each find nothing to lock or hide within 20 s and record no undo step.
#[test]
fn lock_and_hide_all_artwork_above_with_51000_objects_selected_finish_within_20_s() {
let (s, tiles, squares) = tiles_under_squares();
let (steps, all) = (undo_len(&s), [tiles, squares].concat());
assert!(selected(&s) == all, "everything is selected");
let (s, r) = run_soon(s, "object.lock.above", json!({}));
assert_eq!(r, json!({"count": 0}));
let (s, r) = run_soon(s, "object.hide.above", json!({}));
assert_eq!(r, json!({"count": 0}));
assert_eq!(undo_len(&s), steps);
assert!(selected(&s) == all, "everything stays selected");
}
#[test]
fn lock_and_hide_other_layers() {
let mut s = session();
+1
View File
@@ -487,6 +487,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names.
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
Object › Lock › All Artwork Above and Object › Hide › All Artwork Above deselect the selected objects they lock or hide, the key object included.
Object › Lock › All Artwork Above and Object › Hide › All Artwork Above go through each top-level layer once, checking each object that is not selected against the selected objects below it: with 100 tiles selected under 25,000 rectangles, Lock took 6.4 s and now takes under 0.1 s, and with all of 5,001 objects selected it took 8.8 s and now takes under 0.1 s.
All Artwork Above and Other Layers (Object › Lock and Object › Hide), Transform Each, Rearrange All Artboards, the width point and width profile commands, Liquify, Puppet Warp, Define Perspective Grid, Attach to Active Plane, Release with Perspective, moves and transforms in perspective, Move Plane and Move Plane to Match Object record no undo step when they change nothing.
Change Case, Smart Punctuation, Find and Replace, Convert To Area Type, Convert To Point Type, Type on a Path Options, the Type on a Path effects and Fill with Placeholder Text record no undo step when they change nothing.
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579). When a document opens with fonts that aren't available, fonts used only in symbols included, a Missing Fonts dialog lists them after the linked-file questions, with Find Fonts (Type › Find Font on them) and Close. Its Find in Folder… (in Find Font too) searches a folder on several threads, reads only what the font scan reads of font files and skips app packages, the Program Files and ProgramData folders, the system's folders and the app data folders in a home folder; Add Fonts copies the font files the user chooses, suitcase fonts with their resource fork, into VectorCraft's own Fonts folder, which the desktop app and `vectorcraft-cli` read (`text.missingFonts`, `text.findFontFiles`, `text.addFontFiles`). The font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux).