Render: Outline view draws paths and clipped art from geometry kept between frames

In Outline view the renderer made a new path from the path data of
each path and compound path it drew, in every frame. In Preview the
renderer draws plain paths from the path it keeps for each object
between frames. In Outline view the members of a clip group were
culled by bounds measured again in every frame, and the bounds of a
group inside a clip group were measured from every path in it. The
outlined shapes of an Image Trace object were culled and drawn the
same way.

In Outline view the renderer now draws paths and compound paths from
the paths it keeps for them, and culls the members of clip groups and
the outlined shapes of Image Trace objects by the bounds it keeps for
them. The live-effect geometry of those members is kept between frames
too. The frames have the same pixels as before.

With 50,000 paths at 2880x1800 on 8 threads, an Outline frame at 400%
took 47.1 ms on main and takes 16.7 ms when the paths are the members
of one clip group. It took 36.6 ms and takes 26.3 ms when the paths
are paired into compound paths.

Docs: docs/target-app-parity.md.
This commit is contained in:
dotCooCoo authored and bflatastic committed 2026-10-14 04:56:30 +07:00
1 parent caa80fb213
commit 2ce6f90522
3 files changed
+222 -6

No files matched your search

+42 -6
View File
@@ -409,6 +409,9 @@ pub struct Renderer {
/// caches did not keep.
made_again: bool,
/// The node `draw_shape_fx` may cache the live-effect geometry of (the one `draw_arc` is drawing).
/// In Outline view its path comes from `geom` too ([`Self::outline_path`]), and
/// [`Self::draw_outline_member`] sets it to each member of a clip group or shape of an Image
/// Trace object that it draws.
fx_owner: Option<Arc<Node>>,
/// Live-effect geometry per node ([`fx::FxGeom`]), keyed by `Arc` identity like `geom`.
fx_geom: PtrMap<usize, fx::FxGeom>,
@@ -886,7 +889,7 @@ impl Renderer {
(fill, stroke)
}
/// Cached BezPath of a path node.
/// Cached BezPath of a path or compound path node.
fn path_of(&mut self, a: &Arc<Node>) -> Option<Arc<BezPath>> {
let key = Arc::as_ptr(a) as usize;
if let Some(e) = self.geom.get(&key)
@@ -895,12 +898,19 @@ impl Renderer {
{
return Some(p.clone());
}
let p = Arc::new(a.path_data()?.to_bezpath());
let p = Arc::new(a.stroke_path()?);
let bounds = self.bounds_of(a);
self.geom.insert(key, GeomEntry { node: a.clone(), bounds, path: Some(p.clone()), stamp: self.stamp });
Some(p)
}
/// The cached path of path or compound path `n` ([`Self::path_of`]) when the frame is in
/// Outline view and `n` is `fx_owner`, else `None`.
fn outline_path(&mut self, f: &Frame, n: &Node) -> Option<Arc<BezPath>> {
let owner = self.fx_owner.as_ref().filter(|o| f.opts.outline && std::ptr::eq(&***o, n)).cloned()?;
self.path_of(&owner)
}
/// Whether `a` is left out of this frame: hidden, a skipped template or guide, or culled.
fn skipped(&mut self, f: &Frame, a: &Arc<Node>) -> bool {
let skipped_template = f.opts.skip_templates && matches!(a.kind, NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. });
@@ -1135,7 +1145,7 @@ impl Renderer {
}
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } if f.opts.outline => {
for c in children {
self.draw_node(ctx, f, c, false);
self.draw_outline_member(ctx, f, c);
}
}
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
@@ -1171,11 +1181,11 @@ impl Renderer {
}
}
NodeKind::Path { path, rule, guide, .. } => {
let bp = path.to_bezpath();
let bp = self.outline_path(f, n).unwrap_or_else(|| Arc::new(path.to_bezpath()));
if *guide {
let ([r, g, b], dots) = f.opts.guide;
if dots {
let mut screen = bp.clone();
let mut screen = BezPath::clone(&bp);
screen.apply_affine(f.view);
ctx.set_transform(Affine::IDENTITY);
ctx.set_stroke(kurbo::Stroke::new(1.5).with_dashes(0.0, [1.5, 2.5]));
@@ -1189,6 +1199,9 @@ impl Renderer {
}
}
NodeKind::Compound { children, rule } => {
if let Some(bp) = self.outline_path(f, n) {
return self.draw_shape(ctx, f, n, &bp, *rule);
}
let mut bp = BezPath::new();
for c in children {
if let Some(p) = c.path_data() {
@@ -1227,11 +1240,34 @@ impl Renderer {
let opts = RenderOptions { outline: true, ..f.opts.clone() };
let frame = Frame { opts: &opts, ..*f };
for c in shapes {
self.draw_node(ctx, &frame, c, false);
self.draw_outline_member(ctx, &frame, c);
}
}
}
/// [`Self::draw_node`] for `c`, a member of a clip group or a shape of an Image Trace object in
/// Outline view, culled by its cached bounds ([`Self::bounds_of`]) and drawn with `c` as
/// `fx_owner`, so its path and live-effect geometry come from the caches.
fn draw_outline_member(&mut self, ctx: &mut RenderContext, f: &Frame, c: &Arc<Node>) {
if !c.visible || f.opts.hidden.contains(&c.id) {
return;
}
match self.bounds_of(c) {
Some(b) => {
let pad = f.px * 2.0;
if !rects_overlap(b.inflate(pad, pad), f.visible) {
self.stats.culled += c.count();
return;
}
}
None if !c.is_container() => return,
None => {}
}
let outer = self.fx_owner.replace(c.clone());
self.draw_node(ctx, f, c, true);
self.fx_owner = outer;
}
/// The children of a group: as the elements of a knockout group when `knockout`.
fn draw_children(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc<Node>], knockout: bool) {
if knockout {
+179
View File
@@ -210,6 +210,185 @@ fn outline_mode_no_fill() {
assert!(r.pixel(10, 50)[0] < 200);
}
/// A 100 × 100 document with each kind of path Outline view draws, and the ids of its objects by
/// name: a stroked square, a compound path with a hole, a path and a compound path with a live
/// effect, a guide, a clip group, an Image Trace object showing its outlines, and a path in a layer
/// with Preview off. The clip group holds a path reaching past its clipping path, a compound path,
/// a group of two paths, a nested clip group, a path with a live effect, a hidden path and an
/// unstroked path a tenth of a point wide.
fn outline_doc() -> (Document, Vec<(&'static str, NodeId)>) {
let mut d = Document::new(100.0, 100.0);
let mut ids = vec![];
let black = || Paint::solid(Color::BLACK);
let offset = |by: f64| vectorcraft_doc::Effect { id: "path.offsetPath".into(), params: serde_json::json!({ "offset": by }), visible: true };
let compound = |d: &mut Document, outer: Rect, hole: Rect| {
let children = [outer, hole].map(|r| Arc::new(rect_node(d, r, Paint::None, Paint::None, 0.0))).to_vec();
let mut n = Node::new(d.alloc_id(), NodeKind::Compound { children, rule: FillRule::EvenOdd });
n.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0).into();
n
};
let clipping = |mut n: Node| {
if let NodeKind::Path { clipping, .. } = &mut n.kind {
*clipping = true;
}
n
};
let mut named = |name: &'static str, n: Node| {
ids.push((name, n.id));
Arc::new(n)
};
let square = named("square", rect_node(&mut d, Rect::new(5.0, 5.0, 25.0, 25.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), black(), 2.0));
let holed = named("holed", compound(&mut d, Rect::new(30.0, 5.0, 50.0, 25.0), Rect::new(35.0, 10.0, 45.0, 20.0)));
let mut fx_path = rect_node(&mut d, Rect::new(55.0, 8.0, 70.0, 22.0), black(), Paint::None, 0.0);
fx_path.appearance.effects.push(offset(2.0));
let fx_path = named("fx path", fx_path);
let mut fx_compound = compound(&mut d, Rect::new(75.0, 5.0, 95.0, 25.0), Rect::new(80.0, 10.0, 90.0, 20.0));
fx_compound.appearance.effects.push(offset(-1.0));
let fx_compound = named("fx compound", fx_compound);
let mut guide = Node::path(d.alloc_id(), shapes::line(Point::new(5.0, 30.0), Point::new(95.0, 30.0)), Appearance::default());
if let NodeKind::Path { guide, .. } = &mut guide.kind {
*guide = true;
}
let guide = named("guide", guide);
// The clip group.
let ellipse = Node::path(d.alloc_id(), shapes::ellipse(Rect::new(5.0, 35.0, 55.0, 85.0)), Appearance::basic(Paint::None, Paint::None, 0.0));
let clip_path = named("clipping path", clipping(ellipse));
let past = named("past the clip", rect_node(&mut d, Rect::new(0.0, 50.0, 60.0, 60.0), Paint::None, black(), 1.0));
let member_compound = named("member compound", compound(&mut d, Rect::new(10.0, 65.0, 20.0, 75.0), Rect::new(12.0, 67.0, 18.0, 73.0)));
let pair_rect = named("pair rect", rect_node(&mut d, Rect::new(25.0, 65.0, 30.0, 75.0), black(), Paint::None, 0.0));
let pair_ellipse = Node::path(d.alloc_id(), shapes::ellipse(Rect::new(32.0, 65.0, 40.0, 75.0)), Appearance::basic(black(), Paint::None, 0.0));
let pair_ellipse = named("pair ellipse", pair_ellipse);
let pair = named("pair", Node::group(d.alloc_id(), vec![pair_rect, pair_ellipse]));
let nested_clip = named("nested clipping path", clipping(rect_node(&mut d, Rect::new(42.0, 62.0, 54.0, 80.0), Paint::None, Paint::None, 0.0)));
let nested_path = named("nested path", rect_node(&mut d, Rect::new(44.0, 64.0, 70.0, 78.0), black(), Paint::None, 0.0));
let nested = named("nested", Node::new(d.alloc_id(), NodeKind::Group { children: vec![nested_clip, nested_path], clip: true }));
let mut member_fx = rect_node(&mut d, Rect::new(15.0, 40.0, 30.0, 47.0), black(), Paint::None, 0.0);
member_fx.appearance.effects.push(offset(1.5));
let member_fx = named("member fx", member_fx);
let mut hidden = rect_node(&mut d, Rect::new(20.0, 80.0, 40.0, 84.0), Paint::None, black(), 1.0);
hidden.visible = false;
let hidden = named("hidden", hidden);
let tiny = named("tiny", rect_node(&mut d, Rect::new(48.0, 56.0, 48.1, 56.1), black(), Paint::None, 0.0));
let members = vec![clip_path, past, member_compound, pair, nested, member_fx, hidden, tiny];
let clip = named("clip group", Node::new(d.alloc_id(), NodeKind::Group { children: members, clip: true }));
// The Image Trace object: its hidden source, then the traced shapes.
let mut source = rect_node(&mut d, Rect::new(60.0, 35.0, 95.0, 95.0), Paint::solid(Color::rgb(0.5, 0.5, 0.5)), Paint::None, 0.0);
source.visible = false;
let shape_a =
named("trace shape a", rect_node(&mut d, Rect::new(62.0, 40.0, 80.0, 60.0), Paint::solid(Color::rgb(0.0, 0.5, 0.0)), Paint::None, 0.0));
let shape_b = Node::path(
d.alloc_id(),
shapes::ellipse(Rect::new(70.0, 62.0, 92.0, 84.0)),
Appearance::basic(Paint::solid(Color::rgb(1.0, 0.5, 0.0)), Paint::None, 0.0),
);
let shape_b = named("trace shape b", shape_b);
let mut traced = Node::group(d.alloc_id(), vec![Arc::new(source), shape_a, shape_b]);
traced.extras_mut().trace = Some(Box::new(serde_json::json!({ "view": "outlines" })));
let traced = named("traced", traced);
let unpreviewed =
named("unpreviewed", rect_node(&mut d, Rect::new(5.0, 88.0, 40.0, 97.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0));
let l = d.layers[0].id;
for n in [square, holed, fx_path, fx_compound, guide, clip, traced] {
d.insert(Some(l), usize::MAX, Arc::unwrap_or_clone(n)).unwrap();
}
let layer = Node::layer(d.alloc_id(), "Preview off", vectorcraft_doc::LayerColor::Preset(1));
let off = d.insert(None, usize::MAX, layer).unwrap();
if let Some(NodeKind::Layer { preview, .. }) = d.node_mut(off).map(|n| &mut n.kind) {
*preview = false;
}
d.insert(Some(off), 0, Arc::unwrap_or_clone(unpreviewed)).unwrap();
(d, ids)
}
/// The id `outline_doc` gave the object `name`.
fn id_of(ids: &[(&'static str, NodeId)], name: &str) -> NodeId {
ids.iter().find(|(n, _)| *n == name).map(|(_, id)| *id).unwrap_or_else(|| panic!("no object {name}"))
}
/// Outline frames drawn from the geometry a renderer keeps between frames are the frames a new
/// renderer draws, at three views each drawn twice, and after a member of the clip group moves
/// (here with dotted guides). In Preview the Image Trace object's outlines and the layer with
/// Preview off are drawn in outline.
#[test]
fn outline_frames_from_kept_geometry_match_a_new_renderer() {
let (d, ids) = outline_doc();
let outline = RenderOptions { outline: true, guide: ([0, 128, 255], true), background: Some([255; 4]), ..Default::default() };
let screen = RenderOptions { trace_views: true, background: Some([255; 4]), ..Default::default() };
let views = [Affine::IDENTITY, Affine::scale(4.0) * Affine::translate((-40.0, -35.0)), Affine::translate((6.0, -4.0))];
let mut moved = d.clone();
if let Some(n) = moved.node_mut(id_of(&ids, "past the clip")) {
n.transform(Affine::translate((3.0, 2.0)), false);
}
for opts in [&outline, &screen] {
let mut kept = Renderer::new();
for (d, view) in views.iter().chain(&views).map(|v| (&d, v)).chain([(&moved, &Affine::IDENTITY)]) {
let new = Renderer::new().render(d, 100, 100, *view, opts);
assert_eq!(kept.render(d, 100, 100, *view, opts).pixels, new.pixels, "outline: {}, view {view:?}", opts.outline);
}
}
// Outline view draws the members of the clip group unclipped, the tenth-of-a-point path as a
// dot, and no fills.
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &outline);
assert!(img.pixel(58, 50)[0] < 200, "the member past the clipping path");
assert!(img.pixel(48, 56)[0] < 255, "the tenth-of-a-point path");
assert_eq!(img.pixel(15, 15), [255; 4], "the square's fill");
}
/// Outline view draws paths and compound paths from the geometry the renderer keeps for them,
/// the members of clip groups and the shapes of an Image Trace object showing its outlines
/// included: the next frame draws the same paths. A member of a clip group keeps its live-effect
/// geometry, and the members out of view are culled by bounds the renderer keeps.
#[test]
fn outline_view_draws_paths_from_kept_geometry() {
let (d, ids) = outline_doc();
let kept_paths = |r: &Renderer| -> HashMap<NodeId, Arc<BezPath>> { r.geom.values().filter_map(|e| Some((e.node.id, e.path.clone()?))).collect() };
let outline = RenderOptions { outline: true, ..Default::default() };
let screen = RenderOptions { trace_views: true, ..Default::default() };
let in_outline = [
"square",
"holed",
"fx path",
"fx compound",
"guide",
"clipping path",
"past the clip",
"member compound",
"pair rect",
"pair ellipse",
"nested clipping path",
"nested path",
"member fx",
"tiny",
"trace shape a",
"trace shape b",
"unpreviewed",
];
for (opts, drawn) in [(&outline, &in_outline[..]), (&screen, &["trace shape a", "trace shape b", "unpreviewed"][..])] {
let mut r = Renderer::new();
r.render(&d, 100, 100, Affine::IDENTITY, opts);
let first = kept_paths(&r);
r.render(&d, 100, 100, Affine::IDENTITY, opts);
let second = kept_paths(&r);
for name in drawn {
let id = id_of(&ids, name);
let path = first.get(&id).unwrap_or_else(|| panic!("outline: {}: no path kept for {name}", opts.outline));
assert!(second.get(&id).is_some_and(|p| Arc::ptr_eq(p, path)), "outline: {}: {name} drawn again from its kept path", opts.outline);
}
assert!(!first.contains_key(&id_of(&ids, "hidden")));
}
let mut r = Renderer::new();
r.render(&d, 100, 100, Affine::IDENTITY, &outline);
let member_fx = d.node(id_of(&ids, "member fx")).map(|n| n as *const Node as usize);
assert!(member_fx.is_some_and(|key| r.fx_geom.contains_key(&key)), "the member's live-effect geometry is kept");
// Zoomed in 4× on part of the clip group.
let mut r = Renderer::new();
r.render(&d, 100, 100, Affine::scale(4.0) * Affine::translate((-40.0, -35.0)), &outline);
for name in ["member compound", "pair", "member fx"] {
let id = id_of(&ids, name);
assert!(r.geom.values().any(|e| e.node.id == id && e.bounds.is_some() && e.path.is_none()), "{name}: culled by kept bounds");
}
}
#[test]
fn artboards_white_on_pasteboard() {
let d = Document::new(50.0, 50.0);
+1
View File
@@ -517,6 +517,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
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).
An Alt-drag with the Selection, Direct Selection, Group Selection, Rotate, Scale, Reflect or Shear tool copies the selection on its first move, and the moves after it transform those copies; the previews of the Move, Rotate, Scale, Reflect and Shear dialogs with Copy and Preview on copy the selection once in the same way (#1429).
The Layers panel's Delete Selection, Duplicate Selection, Collect in New Layer, Merge Selected, Flatten Artwork, Release to Layers (Sequence) and row drags also take time in proportion to the size of the document, and so do Cut Artboard and Duplicate Artboard with their art and each move of an Alt-drag of one artboard with the Artboard tool while Move/Copy Artwork with Artboard is on (#1429).
In Outline view the renderer draws paths and compound paths from the geometry it keeps for them between frames, and it culls the members of clip groups by the bounds it keeps for them. Layers with Preview off and the outlines of Image Trace objects are drawn the same way.
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland.
- **Control transport:** idle sockets expire after 5 minutes, partial lines after an absolute 30 seconds from their first byte, and reply writes after 60 seconds (#1726). Real TCP regressions cover stalled peers and slot recovery; command execution has its separate existing timeout.
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).