mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 15:52:52 +00:00
Merge remote-tracking branch 'origin/main' into feat/width-smooth-profile
# Conflicts: # docs/target-app-parity.md
This commit is contained in:
@@ -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()?
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user