Merge remote-tracking branch 'origin/main' into feat/width-smooth-profile

# Conflicts:
#	docs/target-app-parity.md
This commit is contained in:
adambelis
2026-11-11 22:13:44 +01:00
592 changed files with 54042 additions and 2799 deletions
+2 -2
View File
@@ -248,7 +248,7 @@ fn drawn_bounds_with(symbols: &SymbolBoxes, n: &Node, held: Option<&Arc<Node>>,
return None;
}
match &n.kind {
NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. } => None,
NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } => None,
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !fx::has_object_fx(n) => children
.iter()
.skip(usize::from(n.extras().shaper.is_some()))
@@ -289,7 +289,7 @@ fn without_undrawn(n: &Node) -> Option<Node> {
if keeps_all || !matches!(n.kind, NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. }) {
return None;
}
let undrawn = |c: &Node| !c.visible || matches!(c.kind, NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. });
let undrawn = |c: &Node| !c.visible || c.is_guide() || c.is_template();
let mut changed = false;
let kept: Vec<Arc<Node>> = n
.children()?
+1 -1
View File
@@ -447,7 +447,7 @@ impl Parts<'_> {
/// Node `n` (a top-level layer, or with Maximum Editability anything in one) and its parts.
fn add(&mut self, n: &Node) {
// Templates and guides aren't artwork.
if matches!(n.kind, NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. }) || !(n.visible || self.o.hidden_layers) {
if n.is_template() || n.is_guide() || !(n.visible || self.o.hidden_layers) {
return;
}
let (hidden, opacity) = (!n.visible, opacity_byte(n.opacity));
+22
View File
@@ -271,6 +271,28 @@ fn guides_are_not_layers() {
assert_eq!(parse(&export(&b.build(), &o)).count, -3, "the group and the square");
}
/// Ruler guides on a layer and a sublayer change nothing in the export: the same file with and
/// without Maximum Editability, the same drawn bounds and the same cached size of each layer.
#[test]
fn ruler_guides_are_not_exported() {
use vectorcraft_doc::{NodeId, NodeKind};
let plain = doc();
let mut guided = plain.clone();
let guide = |id, vertical, pos| Node::new(NodeId(id), NodeKind::RulerGuide { vertical, pos, artboard: None });
let (bottom, inner) = (guided.layers[0].id, guided.layers[1].children().unwrap().last().unwrap().id);
guided.insert(Some(bottom), usize::MAX, guide(900, true, 20.0)).unwrap();
guided.insert(Some(bottom), usize::MAX, guide(901, false, 15.0)).unwrap();
guided.insert(Some(inner), usize::MAX, guide(902, true, 3.0)).unwrap();
for psd in [PsdOptions::default(), PsdOptions { max_editability: true, ..PsdOptions::default() }] {
assert_eq!(export(&guided, &opts(psd.clone())), export(&plain, &opts(psd)));
}
assert_eq!(art_bounds(&guided), art_bounds(&plain));
for (g, p) in guided.layers.iter().zip(&plain.layers) {
assert_eq!(crate::drawn_bounds(&guided, g), crate::drawn_bounds(&plain, p));
assert_eq!(crate::lru::node_bytes(g), crate::lru::node_bytes(p));
}
}
#[test]
fn a_background_colour_is_the_bottom_layer() {
let doc = doc();
+2 -1
View File
@@ -445,7 +445,8 @@ fn three_d_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Option<Rect>> {
if !effects::has_revolve(n) && !effects::has_solid(n) {
return None;
}
let art = effects::revolve_art(n).or_else(|| effects::solid_art(n))?;
let doc = Some(symbols.document());
let art = effects::revolve_art_in(doc, n).or_else(|| effects::solid_art_in(doc, n))?;
Some(three_d_art_bounds(symbols, &art))
}
+1 -1
View File
@@ -588,7 +588,7 @@ impl Renderer {
let art = match effects::crop_marks_art(a)
.or_else(|| crate::addons::halftone::art(doc, a))
.or_else(|| {
let art = effects::revolve_art(a).or_else(|| effects::solid_art(a));
let art = effects::revolve_art_in(Some(doc), a).or_else(|| effects::solid_art_in(Some(doc), a));
self.stats.three_d_built += usize::from(art.is_some());
three_d |= art.is_some();
art
+90
View File
@@ -612,6 +612,96 @@ fn an_export_and_rasterize_hold_the_solid_of_3d_type() {
assert!(rasterized.width() > letters.width() + 15.0, "{letters:?} {rasterized:?}");
}
/// A symbol instance, a blend and a clipping group with Extrude & Bevel, alone or in a group, draw
/// their solids on the canvas, in an export and in Object › Rasterize, not their flat art.
#[test]
fn a_symbol_instance_a_blend_and_a_clipping_group_draw_their_solids() {
use vectorcraft_doc::{BlendSpacing, BlendSpec, NodeId, NodeKind, Symbol};
let square = |x: f64, y: f64| {
Arc::new(Node::path(
NodeId(0),
shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)),
Appearance::basic(Paint::solid(Color::rgb(0.2, 0.5, 0.8)), Paint::None, 0.0),
))
};
let mut d = Document::new(200.0, 200.0);
d.symbols.push(Symbol { dynamic: false, name: "Box".into(), art: square(0.0, 0.0) });
let mut clip = (*square(30.0, 30.0)).clone();
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
let spec = Box::new(BlendSpec { spacing: BlendSpacing::Steps(2), ..Default::default() });
let instance = NodeKind::SymbolInstance { symbol: "Box".into(), xf: Affine::translate((40.0, 40.0)) };
let clipped = NodeKind::Group { children: vec![Arc::new(clip), square(20.0, 20.0)], clip: true };
let in_group = |kind: NodeKind| NodeKind::Group { children: vec![Arc::new(Node::new(NodeId(0), kind)), square(100.0, 100.0)], clip: false };
let kinds = [
("a symbol instance", instance.clone()),
("a blend", NodeKind::Blend { children: vec![square(30.0, 30.0), square(60.0, 40.0)], spec }),
("a clipping group", clipped.clone()),
("a group holding a symbol instance", in_group(instance)),
("a group holding a clipping group", in_group(clipped)),
];
for (what, kind) in kinds {
let mut n = Node::new(d.alloc_id(), kind);
let flat = crate::encode::art_bounds(&doc_of(&mut d.clone(), n.clone())).unwrap();
n.appearance.effects =
vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 60.0, "position": "isometricTop"}), visible: true }];
let solid_doc = doc_of(&mut d.clone(), n);
let n = &solid_doc.layers[0].children().unwrap()[0];
let (solid, rasterized) = (crate::encode::art_bounds(&solid_doc).unwrap(), crate::drawn_bounds(&solid_doc, n).unwrap());
let size = |r: Rect| r.width() + r.height();
assert!(size(solid) > size(flat) + 20.0, "{what}: {flat:?} {solid:?}");
assert!(size(rasterized) > size(flat) + 20.0, "{what}: {flat:?} {rasterized:?}");
// The renderer builds the solid from the object itself, with the document's symbols, as
// the effect builds it: one solid built, not the object's art drawn again with the effect.
let built = vectorcraft_effects::solid_art_in(Some(&solid_doc), n).and_then(|a| a.visual_bounds()).unwrap();
assert!((solid.x0 - built.x0).abs() < 1.0 && (solid.x1 - built.x1).abs() < 1.0, "{what}: {built:?} {solid:?}");
let mut r = Renderer::new();
r.art_bounds_on(&solid_doc, Rect::new(0.0, 0.0, 200.0, 200.0));
assert_eq!(r.stats.three_d_built, 1, "{what}");
}
}
/// A loaded file can hold a 3D effect on art past the limits commands check: a blend of more
/// than 4,096 objects, art nested more than 64 levels deep in a clipping group. The art then draws
/// as it does without the effect, and drawing it does not fail.
#[test]
fn art_past_the_three_d_limits_draws_as_without_the_effect() {
use vectorcraft_doc::{BlendSpacing, BlendSpec, NodeId, NodeKind};
let square = |x: f64| {
Arc::new(Node::path(
NodeId(0),
shapes::rectangle(Rect::new(x, 10.0, x + 20.0, 30.0)),
Appearance::basic(Paint::solid(Color::rgb(0.2, 0.5, 0.8)), Paint::None, 0.0),
))
};
let mut clip = (*square(0.0)).clone();
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
let mut deep = (*square(0.0)).clone();
for _ in 0..200 {
deep = Node::group(NodeId(0), vec![Arc::new(deep)]);
}
let spec = Box::new(BlendSpec { spacing: BlendSpacing::Steps(1000), ..Default::default() });
let kinds = [
("a blend of 6,000 objects", NodeKind::Blend { children: (0..6).map(|i| square(30.0 * i as f64)).collect(), spec }),
("art 200 levels deep in a clip", NodeKind::Group { children: vec![Arc::new(clip), Arc::new(deep)], clip: true }),
];
let d = Document::new(200.0, 200.0);
for (what, kind) in kinds {
let mut n = Node::new(NodeId(500), kind);
let flat = crate::encode::art_bounds(&doc_of(&mut d.clone(), n.clone())).unwrap();
n.appearance.effects =
vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 60.0, "position": "isometricTop"}), visible: true }];
let loaded = doc_of(&mut d.clone(), n);
let n = &loaded.layers[0].children().unwrap()[0];
assert!(vectorcraft_effects::validate_three_d_in(Some(&loaded), n, "threeD.extrude", &json!({})).is_err(), "{what}");
assert_eq!(crate::encode::art_bounds(&loaded), Some(flat), "{what}");
assert!(crate::drawn_bounds(&loaded, n).is_some(), "{what}");
}
}
fn doc_of(d: &mut Document, n: Node) -> Document {
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
+6 -5
View File
@@ -63,7 +63,7 @@ pub fn installed_memory() -> Option<u64> {
/// Installed memory in bytes (`MemTotal` in `/proc/meminfo`), or the memory limit of the process's
/// cgroups where that is lower.
#[cfg(target_os = "linux")]
fn physical_memory() -> Option<u64> {
pub fn physical_memory() -> Option<u64> {
let info = std::fs::read_to_string("/proc/meminfo").ok()?;
let line = info.lines().find(|l| l.starts_with("MemTotal:"))?;
let kib: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
@@ -174,14 +174,14 @@ fn mountinfo_unescape(field: &str) -> String {
/// Installed memory in bytes (`GlobalMemoryStatusEx`).
#[cfg(windows)]
fn physical_memory() -> Option<u64> {
pub fn physical_memory() -> Option<u64> {
winsafe::GlobalMemoryStatusEx().ok().map(|m| m.ullTotalPhys)
}
/// Installed memory in bytes, as `sysctl` prints it (`hw.memsize` on macOS, `hw.physmem` on
/// FreeBSD).
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
fn physical_memory() -> Option<u64> {
pub fn physical_memory() -> Option<u64> {
let (sysctl, name) = if cfg!(target_os = "macos") { ("/usr/sbin/sysctl", "hw.memsize") } else { ("/sbin/sysctl", "hw.physmem") };
let out = std::process::Command::new(sysctl).args(["-n", name]).output().ok().filter(|o| o.status.success())?;
sysctl_bytes(&out.stdout)
@@ -193,8 +193,9 @@ fn sysctl_bytes(out: &[u8]) -> Option<u64> {
std::str::from_utf8(out).ok()?.trim().parse().ok()
}
#[cfg(not(any(target_os = "linux", windows, target_os = "macos", target_os = "freebsd", target_arch = "wasm32")))]
fn physical_memory() -> Option<u64> {
/// Installed memory in bytes: not read on other targets, the web among them.
#[cfg(not(any(target_os = "linux", windows, target_os = "macos", target_os = "freebsd")))]
pub fn physical_memory() -> Option<u64> {
None
}
+2 -1
View File
@@ -15,7 +15,7 @@ mod freeform;
mod fx;
mod group;
mod images;
pub use images::{image_cached, keep_decoded_image};
pub use images::{image_cached, keep_decoded_image, physical_memory};
mod ink;
mod live;
mod lru;
@@ -283,6 +283,7 @@ use frame::{
#[cfg(test)]
use frame::{OFFSCREEN_PIXELS, Slices};
pub use geometry::region_pixels;
pub use node_checks::draws_plugin_effects;
use node_checks::holds_plugin_effect;
#[cfg(test)]
use node_checks::painted_items;
+5 -1
View File
@@ -243,10 +243,14 @@ pub(crate) fn path_bytes(p: &BezPath) -> usize {
size_of_val(p.elements())
}
/// Approximate bytes of node `n` and its members: the nodes and their path anchors.
/// Approximate bytes of node `n` and its members: the nodes and their path anchors. Ruler guides
/// are not drawn art and are not counted.
pub(crate) fn node_bytes(n: &Node) -> usize {
let mut b = 0;
n.walk(&mut |c| {
if c.is_ruler_guide() {
return;
}
b += size_of::<Node>() + NODE_OVERHEAD;
if let Some(p) = c.path_data() {
b += p.subpaths.iter().map(|s| size_of::<SubPath>() + s.anchors.len() * size_of::<Anchor>()).sum::<usize>();
+15 -3
View File
@@ -1,4 +1,4 @@
use super::{Affine, AppearanceItem, Arc, BezPath, Node, NodeKind, StrokeLayer, brush_fx, effects};
use super::{Affine, AppearanceItem, Arc, BezPath, Document, Node, NodeKind, StrokeLayer, brush_fx, effects};
/// Most pixels from the origin that a shape handed to the rasterizer may reach, or a stroke be
/// wide. A document's art stays within 4 million points, 2.6 billion pixels at the closest zoom.
@@ -108,9 +108,11 @@ pub(crate) fn holds_instance(n: &Node) -> bool {
}
/// Is `a` left out of a frame `doc` draws with `opts`, before any culling: hidden, a template or
/// guide the view hides, drawn hidden, or the instance its symbol is being edited from?
/// guide the view hides, a ruler guide (the canvas draws those), drawn hidden, or the instance its
/// symbol is being edited from?
pub(crate) fn left_out(opts: &crate::RenderOptions, doc: &vectorcraft_doc::Document, a: &Node) -> bool {
let view_item = (opts.skip_templates || opts.hide_templates) && matches!(a.kind, NodeKind::Layer { template: true, .. })
let view_item = a.is_ruler_guide()
|| (opts.skip_templates || opts.hide_templates) && matches!(a.kind, NodeKind::Layer { template: true, .. })
|| (opts.skip_templates || opts.hide_guides) && matches!(a.kind, NodeKind::Path { guide: true, .. });
!a.visible || view_item || opts.hidden.contains(&a.id) || edited_instance(opts, doc, a.id)
}
@@ -142,6 +144,16 @@ pub(crate) fn holds_plugin_effect(n: &Node) -> bool {
holds
}
/// Does `doc` draw a plug-in effect, so that installing or removing a plug-in
/// ([`effects::plugin_revision`]) can change how it draws? It does when an object in its layers, in
/// the art of its symbols or patterns, or in the art of its brushes holds one ([`holds_plugin_effect`]).
pub fn draws_plugin_effects(doc: &Document) -> bool {
let symbols = doc.symbols.iter().map(|s| &s.art);
let patterns = doc.patterns.iter().flat_map(|p| &p.art);
doc.layers.iter().chain(symbols).chain(patterns).any(|a| holds_plugin_effect(a))
|| vectorcraft_brush::library(doc).iter().any(|b| b.art().into_iter().any(holds_plugin_effect))
}
/// A symbol instance's stain ([`vectorcraft_brush::stain`]) as bits for a cache key: its color
/// model, the color's components and the amount (all 0 without a stain).
pub(crate) fn stain_bits(stain: Option<(vectorcraft_color::Color, f32)>) -> [u32; 6] {
+2 -1
View File
@@ -361,7 +361,8 @@ impl Renderer {
let Some(pe) = &f.doc.pattern_edit else { return false };
let Some(layer) = f.doc.layers.iter().find(|l| l.id == pe.layer) else { return false };
let Some(def) = f.doc.pattern(&pe.pattern) else { return false };
let children: Vec<Arc<Node>> = layer.children().cloned().unwrap_or_default();
// The tile's art: the layer's ruler guides are not part of it.
let children: Vec<Arc<Node>> = layer.children().into_iter().flatten().filter(|c| !c.is_ruler_guide()).cloned().collect();
if layer.visible && !children.is_empty() {
let dim = (def.dim_copies / 100.0).clamp(0.0, 1.0);
let (before, after) = def.preview_offsets_dimmed_sides();
+2 -1
View File
@@ -425,7 +425,8 @@ impl Renderer {
if !k.is_finite() || k <= 1e-12 {
return;
}
// The art in its own coordinates, with its own resources.
// The art in its own coordinates, with its own resources (its composite fonts included).
let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&e.doc.composite_fonts);
let symbols = e.doc.symbol_boxes().quick();
let visible = m.inverse().transform_rect_bbox(f.visible);
let inner = Frame { doc: &e.doc, symbols: &symbols, view: f.view * m, visible, px: f.px / k, output: f.output * m, ..*f };
+60 -31
View File
@@ -105,8 +105,11 @@ impl Renderer {
}
}
/// Render `doc` into a `width`×`height` image using `view` (document → pixel transform).
/// Render `doc` into a `width`×`height` image using `view` (document → pixel transform). Type
/// set in one of the document's composite fonts draws in the document's definition, also when
/// no session has registered it (a document that is not in front, or read from a file).
pub fn render(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions) -> Rendered {
let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts);
let _frame = vectorcraft_text::KeptLayoutsFrame::begin();
self.render_as(doc, width, height, view, opts, false, 1)
}
@@ -258,8 +261,7 @@ impl Renderer {
};
// `reset` keeps the threshold of the previous render: set it every time.
ctx.set_aliasing_threshold(opts.anti_alias.threshold());
let render_mode = if opts.precise { vello_cpu::RenderMode::OptimizeQuality } else { vello_cpu::RenderMode::OptimizeSpeed };
self.raster = vello_cpu::RasterizerSettings { render_mode, ..Default::default() };
self.raster = raster_settings(opts);
if opts.artboards && !opts.outline {
// Paper: white on screen, no ink on the ink planes.
ctx.set_transform(f.view);
@@ -340,6 +342,7 @@ impl Renderer {
pub fn render_region_inks(&mut self, doc: &Document, region: Rect, scale: f64, opts: &RenderOptions) -> Vec<u8> {
let (w, h) = region_pixels(region, scale);
let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0));
let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts);
let _frame = vectorcraft_text::KeptLayoutsFrame::begin();
self.render_as(doc, w, h, view, opts, true, 1).pixels
}
@@ -356,22 +359,62 @@ impl Renderer {
/// [`Self::render_node`] with `opts` (e.g. [`RenderOptions::mark_undecoded_images`]).
pub fn render_node_with(&mut self, doc: &Document, n: &Arc<Node>, w: u16, h: u16, view: Affine, opts: &RenderOptions) -> Option<Rendered> {
let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts);
self.check_plugins(doc);
self.check_symbols(doc);
let inv = view.inverse();
let visible = inv.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64));
let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64));
let pixels = self.node_pixels(doc, n, (w, h), view, visible, opts, false, 1);
Some(Rendered { width: w as u32, height: h as u32, pixels })
}
/// Node `n` drawn into `w`×`h` transparent pixels through `view`, culled to `visible` (document
/// space); `forced` draws it whatever its visibility and bounds. With [`RenderOptions::supersample`]
/// and Art Optimized edges the art is drawn with hard edges at several times the size and
/// averaged down, and [`RenderOptions::precise`] picks the quality render mode, as in a page
/// render ([`Self::render_as`], [`Self::draw_frame`]). `cell` is as in [`Self::render_as`].
#[allow(clippy::too_many_arguments)]
fn node_pixels(
&mut self,
doc: &Document,
n: &Arc<Node>,
(w, h): (u16, u16),
view: Affine,
visible: Rect,
opts: &RenderOptions,
forced: bool,
cell: u32,
) -> Vec<u8> {
if opts.supersample
&& opts.anti_alias == AntiAlias::Art
&& let Some(k) = supersample_factor(u32::from(w), u32::from(h))
{
let hard = RenderOptions { anti_alias: AntiAlias::None, supersample: false, ..opts.clone() };
// `supersample_factor` keeps each side within MAX_CONTEXT_SIDE, so it fits in a u16.
let (bw, bh) = (u32::from(w) * k, u32::from(h) * k);
let hairline = std::mem::replace(&mut self.hairline, f64::from(k));
let big = self.node_pixels(doc, n, (bw as u16, bh as u16), Affine::scale(f64::from(k)) * view, visible, &hard, forced, k);
self.hairline = hairline;
return downsample(&big, bw as usize, bh as usize, k as usize);
}
let px = 1.0 / view.determinant().abs().sqrt().max(1e-12);
let mut ctx = single_threaded_context(w, h);
ctx.set_aliasing_threshold(opts.anti_alias.threshold());
let symbols = doc.symbol_boxes().quick();
let frame = Frame { mt: false, doc, symbols: &symbols, view, visible, px, opts, ink: Ink::Display, output: view, plates: None };
let output = Affine::scale(1.0 / f64::from(cell.max(1))) * view;
let frame = Frame { mt: false, doc, symbols: &symbols, view, visible, px, opts, ink: Ink::Display, output, plates: None };
let raster = std::mem::replace(&mut self.raster, raster_settings(opts));
self.begin_frame();
(self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None);
self.clip_paths.clear();
self.draw_arc(&mut ctx, &frame, n);
let pm = self.finish(&mut ctx, vello_cpu::RasterizerSettings::default());
if forced {
self.draw_forced_arc(&mut ctx, &frame, n);
} else {
self.draw_arc(&mut ctx, &frame, n);
}
let pm = self.finish(&mut ctx, self.raster);
self.end_frame();
Some(Rendered { width: w as u32, height: h as u32, pixels: pm.data_as_u8_slice().to_vec() })
self.raster = raster;
pm.data_as_u8_slice().to_vec()
}
/// Render any node (also one outside the tree, e.g. opacity-mask art) fitted into
@@ -395,6 +438,7 @@ impl Renderer {
background: Option<[u8; 4]>,
opts: &RenderOptions,
) -> Option<Rendered> {
let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts);
self.check_plugins(doc);
self.check_symbols(doc);
let b = brush_fx::cull_bounds(&doc.symbol_boxes(), n)?;
@@ -402,28 +446,7 @@ impl Renderer {
// A 1-pixel margin on each side, unless the thumbnail is too small to keep one.
let s = (w as f64 - 2.0).max(1.0) / b.width().max(b.height()).max(1e-6);
let view = Affine::translate((w as f64 / 2.0, w as f64 / 2.0)) * Affine::scale(s) * Affine::translate(-b.center().to_vec2());
let mut ctx = single_threaded_context(w, w);
ctx.set_aliasing_threshold(opts.anti_alias.threshold());
let symbols = doc.symbol_boxes().quick();
let frame = Frame {
mt: false,
doc,
symbols: &symbols,
view,
visible: b.inflate(1.0, 1.0),
px: 1.0 / s,
opts,
ink: Ink::Display,
output: view,
plates: None,
};
self.begin_frame();
(self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None);
self.clip_paths.clear();
self.draw_forced_arc(&mut ctx, &frame, &Arc::new(n.clone()));
let pm = self.finish(&mut ctx, vello_cpu::RasterizerSettings::default());
self.end_frame();
let mut pixels = pm.data_as_u8_slice().to_vec();
let mut pixels = self.node_pixels(doc, &Arc::new(n.clone()), (w, w), view, b.inflate(1.0, 1.0), opts, true, 1);
if let Some(bg) = background {
over_background(&mut pixels, bg);
}
@@ -431,6 +454,12 @@ impl Renderer {
}
}
/// The rasterizer settings for `opts`: the quality render mode when [`RenderOptions::precise`] is set.
fn raster_settings(opts: &RenderOptions) -> vello_cpu::RasterizerSettings {
let render_mode = if opts.precise { vello_cpu::RenderMode::OptimizeQuality } else { vello_cpu::RenderMode::OptimizeSpeed };
vello_cpu::RasterizerSettings { render_mode, ..Default::default() }
}
/// Premultiplied RGBA `pixels` drawn over the solid color `background` (`[r, g, b, a]`, straight).
pub(crate) fn over_background(pixels: &mut [u8], [r, g, b, a]: [u8; 4]) {
let alpha = u32::from(a);
+7 -3
View File
@@ -596,6 +596,8 @@ impl Renderer {
Some(a) => self.draw_live_body(ctx, f, &a, true),
None => self.draw_live_node(ctx, f, n),
},
// The canvas draws ruler guides over the art; the renderer draws nothing for them.
NodeKind::RulerGuide { .. } => {}
}
}
@@ -680,18 +682,20 @@ impl Renderer {
self.draw_knockout(ctx, f, children);
} else if let Some((_, path)) = self.isolation_path(f) {
// Isolation mode, in a container on the way down to the isolated one: each run of
// members around it draws dimmed as one (#833).
// members around it draws dimmed as one (#833). A run of ruler guides alone draws
// nothing, so it gets no dimmed pass.
let draws = |run: &[Arc<Node>]| run.iter().any(|c| !c.is_ruler_guide());
let mut rest = children;
while let Some(i) = rest.iter().position(|c| path.contains(&c.id)) {
let (around, from) = rest.split_at(i);
if !around.is_empty() {
if draws(around) {
self.dimmed(ctx, f, &mut |r, c, fr| around.iter().for_each(|n| r.draw_arc(c, fr, n)));
}
let Some((on_path, after)) = from.split_first() else { break };
self.draw_arc(ctx, f, on_path);
rest = after;
}
if !rest.is_empty() {
if draws(rest) {
self.dimmed(ctx, f, &mut |r, c, fr| rest.iter().for_each(|n| r.draw_arc(c, fr, n)));
}
} else {
+60
View File
@@ -425,6 +425,29 @@ fn an_instance_is_culled_by_the_art_nested_9_slice_guides_move() {
assert_eq!(img.pixel(95, 50), [255, 0, 0, 255]);
}
#[test]
fn an_instance_that_only_moves_is_culled_by_the_art_its_9_slice_guides_move() {
// A symbol made in the app: art in the 20 × 20 square, a natural size of 2 × 2, and guides a
// quarter in. An instance that only moves draws it at 20 × 20, ten times its natural size. The
// bar from x −9.9 to 0.1 crosses the left guide and is placed by the middle column's map,
// x → 1.9x: moved 115 pt right, it draws from x 96.19 to 115.19 while its art box starts at
// x 105, out of view.
let mut d = Document::new(100.0, 100.0);
let bar = rect_node(&mut d, Rect::new(-9.9, -1.0, 0.1, 1.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Bar".into(), art: Arc::new(bar) });
let frame = rect_node(&mut d, Rect::new(-10.0, -10.0, 10.0, 10.0), Paint::None, Paint::None, 0.0);
let inner = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::IDENTITY });
let art = Node::group(d.alloc_id(), vec![Arc::new(frame), Arc::new(inner)]);
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Panel".into(), art: Arc::new(art) });
d.unknown.insert(vectorcraft_doc::SYMBOL_SIZES.into(), serde_json::json!({ "Panel": [2.0, 2.0] }));
d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default()));
let (id, l) = (d.alloc_id(), d.layers[0].id);
let xf = Affine::translate((115.0, 50.0));
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Panel".into(), xf })).unwrap();
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
assert_eq!(img.pixel(98, 50), [255, 0, 0, 255]);
}
#[test]
fn an_instance_with_a_geometry_effect_is_culled_by_its_reshaped_art() {
// The art, (0, 0) to (20, 20), drawn from (-100, 10) is out of view, and the Transform effect
@@ -952,6 +975,43 @@ fn node_renders_follow_the_anti_alias_option() {
assert!(drawn(&thumb) && !partial(&thumb), "render_node_thumbnail_with: every alpha is 0 or 255");
}
/// Node renders follow `supersample` and `precise` as a page render does: two rectangles that meet
/// at x = 50.37 leave no seam when supersampled, and a node render gives the pixels of a page
/// render of the same art with the same options.
#[test]
fn node_renders_follow_the_supersample_and_precise_options() {
let mut d = Document::new(100.0, 100.0);
let left = rect_node(&mut d, Rect::new(10.0, 10.0, 50.37, 90.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
let right = rect_node(&mut d, Rect::new(50.37, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(0.2, 0.6, 0.9)), Paint::None, 0.0);
// Blended at half opacity over both, so the group composites offscreen.
let mut over = rect_node(&mut d, Rect::new(30.0, 60.0, 70.0, 80.0), Paint::solid(Color::rgb(0.9, 0.7, 0.1)), Paint::None, 0.0);
over.opacity = 0.5;
over.blend = vectorcraft_color::BlendMode::Multiply;
let group = Node::group(d.alloc_id(), vec![Arc::new(left), Arc::new(right), Arc::new(over)]);
let l = d.layers[0].id;
d.insert(Some(l), 0, group.clone()).unwrap();
let node = Arc::new(group.clone());
let page = |opts: &RenderOptions| RenderOptions { background: None, artboards: false, ..opts.clone() };
let mut r = Renderer::new();
for (supersample, precise) in [(false, false), (true, false), (false, true), (true, true)] {
let opts = RenderOptions { supersample, precise, ..Default::default() };
let case = format!("supersample {supersample}, precise {precise}");
let img = r.render_node_with(&d, &node, 100, 100, Affine::IDENTITY, &opts).unwrap();
let whole = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &page(&opts));
assert!(img.pixels == whole.pixels, "{case}: render_node_with draws as a page render does");
// Pixel column 50 at row 50: the black rectangle covers 37% of it and the blue one the rest.
let seam = img.pixels[(50 * 100 + 50) * 4 + 3];
assert_eq!(seam == 255, supersample, "{case}: render_node_with seam alpha {seam}");
// A 37-pixel thumbnail draws 0.4375 pixels a point, the seam at x = 18.66.
let thumb = r.render_node_thumbnail_with(&d, &group, 37, None, &opts).unwrap();
let view = Affine::translate((18.5, 18.5)) * Affine::scale(0.4375) * Affine::translate((-50.0, -50.0));
let whole = Renderer::new().render(&d, 37, 37, view, &page(&opts));
assert!(thumb.pixels == whole.pixels, "{case}: the thumbnail draws as a page render does");
let seam = thumb.pixels[(18 * 37 + 18) * 4 + 3];
assert_eq!(seam == 255, supersample, "{case}: thumbnail seam alpha {seam}");
}
}
#[test]
fn opacity_mask_with_undecodable_image_leaves_out_object() {
use vectorcraft_doc::{Effect, ImageBlob, ImageObject, NodeKind, OpacityMask};
+72
View File
@@ -138,6 +138,78 @@ fn composite_font_parts_are_not_highlighted_as_substituted_glyphs() {
assert!(g.substituted_glyphs.elements().is_empty() && g.substituted_fonts.elements().is_empty());
}
/// Type in one of a document's composite fonts draws in the document's definition when nothing has
/// registered it: in a render, an ink render, a thumbnail and a placed document's art, as type set
/// in the part's font draws.
/// The font database is left without it afterwards.
#[test]
fn a_documents_own_composite_font_draws_without_being_registered() {
use vectorcraft_doc::{CompositeFont, CompositePart, CompositeParts};
let _adding = adding_fonts();
const NAME: &str = "CF Unregistered";
let db = vectorcraft_text::FontDb::global();
let typed = |family: &str| {
let mut d = Document::new(300.0, 150.0);
let style = CharStyle { size: 60.0, font_family: family.into(), ..Default::default() };
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 100.0), "Hamburg", style))));
let l = d.layers[0].id;
let id = d.insert(Some(l), 0, n).unwrap();
(d, id)
};
let (mut composite, id) = typed(NAME);
let mut parts = CompositeParts::all(CompositePart::new("Source Sans 3", "Regular"));
parts.roman = CompositePart::new("JetBrains Mono", "Regular");
composite.composite_fonts.push(CompositeFont { name: NAME.into(), parts });
let (plain, plain_id) = typed("JetBrains Mono");
assert!(db.composite_font(NAME).is_none());
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let view = Affine::IDENTITY;
assert_eq!(
Renderer::new().render(&composite, 300, 150, view, &opts).pixels,
Renderer::new().render(&plain, 300, 150, view, &opts).pixels,
"rendered"
);
let region = Rect::new(0.0, 0.0, 300.0, 150.0);
assert_eq!(
Renderer::new().render_region_inks(&composite, region, 1.0, &opts),
Renderer::new().render_region_inks(&plain, region, 1.0, &opts),
"rendered as ink amounts"
);
let thumbnail = |d: &Document, id| Renderer::new().render_thumbnail(d, id, 64).map(|r| r.pixels);
assert_eq!(thumbnail(&composite, id), thumbnail(&plain, plain_id), "thumbnail");
let placed = |d: &Document| crate::placed_document::render_inside(&mut Renderer::new(), d, &d.layers[0], 300, 150, view).map(|r| r.pixels);
assert_eq!(placed(&composite), placed(&plain), "a placed document's art");
// A page that places the document, drawn exactly (no bitmap of the placed art).
let page = |d: &Document, id: NodeId, key: &str| {
use vectorcraft_doc::{ImageBlob, LinkInfo, PlacedDocument};
let blob = ImageBlob::new("application/json", key.as_bytes().to_vec());
let p = PlacedDocument {
link: LinkInfo::new("/art/type.vectorcraft"),
key: key.into(),
bounding: false,
page_box: Default::default(),
width: 300.0,
height: 150.0,
xf: Affine::IDENTITY,
placement: Default::default(),
};
let art = d.node(id).unwrap().clone();
vectorcraft_doc::placed_document::prime(&p, blob.bytes.clone(), (d.clone(), art, Rect::new(0.0, 0.0, 300.0, 150.0)));
let mut page = Document::new(300.0, 150.0);
page.images.insert(key.into(), blob);
let n = Node::new(page.alloc_id(), NodeKind::PlacedDocument(Box::new(p)));
let l = page.layers[0].id;
page.insert(Some(l), 0, n).unwrap();
Renderer::new().render(&page, 300, 150, view, &opts).pixels
};
assert_eq!(
page(&composite, id, "fontchange-composite-placed"),
page(&plain, plain_id, "fontchange-plain-placed"),
"a placed document's art drawn exactly"
);
assert!(db.composite_font(NAME).is_none(), "given back after each render");
}
/// Type at 80% opacity, in Multiply and with a drop shadow redraws in a font that becomes
/// available: the glyph geometry kept for it is laid out again.
#[test]
+38
View File
@@ -1824,6 +1824,44 @@ fn pattern_and_symbol_art_with_plugin_effects_follow_the_plugins_installed() {
plugin_change_draws_as_new(with_art(d, vec![group]), ID, Some(triangle(82.0, 32.0, 10.0)), Some(triangle(82.0, 32.0, 30.0)));
}
/// A document draws plug-in effects ([`draws_plugin_effects`]) when an object in its layers, in a
/// symbol's or a pattern's art, or in a brush's art has a visible plug-in effect, and not when its
/// objects have no effect, another effect or a hidden plug-in effect.
#[test]
fn a_document_draws_plugin_effects_where_any_of_its_art_has_one() {
use vectorcraft_brush::{ArtBrush, Brush, BrushKind};
use vectorcraft_geom::shapes::rectangle;
let plugin = || effect("plugin.test.render.draws", serde_json::json!({}));
let square = |d: &mut Document, effects: Vec<vectorcraft_doc::Effect>| {
filled(d, rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Paint::solid(Color::BLACK), effects)
};
let mut cases: Vec<(&str, Document, bool)> = vec![];
for (name, effects, draws) in [
("no effect", vec![], false),
("another effect", vec![drop_shadow()], false),
("hidden plug-in effect", vec![vectorcraft_doc::Effect { visible: false, ..plugin() }], false),
("plug-in effect", vec![plugin()], true),
] {
let mut d = Document::new(200.0, 100.0);
let n = square(&mut d, effects);
cases.push((name, with_art(d, vec![n]), draws));
}
let mut d = Document::new(200.0, 100.0);
let art = square(&mut d, vec![plugin()]);
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Shaped".into(), art: Arc::new(art) });
cases.push(("symbol art", d, true));
let mut d = Document::new(200.0, 100.0);
let art = square(&mut d, vec![plugin()]);
d.patterns.push(vectorcraft_doc::pattern::PatternDef::new("Shaped", vec![Arc::new(art)]));
cases.push(("pattern art", d, true));
let mut d = Document::new(200.0, 100.0);
let art = square(&mut d, vec![plugin()]);
vectorcraft_brush::store(&mut d, &[Brush { name: "Shaped".into(), kind: BrushKind::Art(ArtBrush { art, ..Default::default() }) }]);
cases.push(("brush art", d, true));
let wrong: Vec<&str> = cases.iter().filter(|(_, d, draws)| draws_plugin_effects(d) != *draws).map(|(name, ..)| *name).collect();
assert!(wrong.is_empty(), "{wrong:?}");
}
fn effect(id: &str, params: serde_json::Value) -> vectorcraft_doc::Effect {
vectorcraft_doc::Effect { id: id.into(), params, visible: true }
}