mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-29 21:06:50 -06:00
M8: 50k-layer zoomed-out renders and faster save/load (#63)
Canvas previews zoomed out to 50% or less draw levels of detail (crates/render/src/lod.rs): subtrees under 2 device px are splatted as their averaged paint colours (box filter, no paths or effects), text under 4 px is greeked into bars, small solid rectangles are splatted and rectangular clip layers are composited without the blend pipeline. Image paints and masks keep the exact path. Exports, thumbnails and every other render stay exact (Options::lod is off by default); render bands are assembled with a row copy instead of a pixmap draw. .figcraft documents are serialized straight into the compressor and parsed straight from the text (a header pass reads version and image index without building a value tree); layer and text fields at their default values are left out (63 -> 22 MB of JSON at 50k layers, older files still load); JSON members use deflate level 3. At 50k layers: zoomed-to-fit render ~58 -> ~8 ms, save ~1.0 s -> ~0.09 s, load ~0.86 s -> ~0.16 s. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+34
-11
@@ -9,8 +9,8 @@ use figcraft_doc::{Document, EffectKind, Node, NodeId, NodeKind, StrokeAlign};
|
||||
use figcraft_geom::{Affine, BezPath, Rect, Shape};
|
||||
use tiny_skia::{FillRule, IntRect, Mask, MaskType, Pixmap, PixmapPaint, Transform};
|
||||
|
||||
use crate::fx;
|
||||
use crate::paint::{prepare, ts_blend};
|
||||
use crate::{fx, lod};
|
||||
|
||||
/// Shared state for one render.
|
||||
pub struct Ctx<'a> {
|
||||
@@ -20,6 +20,8 @@ pub struct Ctx<'a> {
|
||||
pub outline_mode: bool,
|
||||
/// Nodes not to draw (e.g. text being edited draws through the editor).
|
||||
pub skip: &'a [NodeId],
|
||||
/// Approximate what's too small to see in detail (canvas previews; see [`crate::lod`]).
|
||||
pub lod: bool,
|
||||
}
|
||||
|
||||
pub fn ts_transform(a: Affine) -> Transform {
|
||||
@@ -253,9 +255,15 @@ fn device_rect(xf: Affine, r: Rect) -> Rect {
|
||||
figcraft_geom::transform_rect_bbox(xf, r)
|
||||
}
|
||||
|
||||
/// The area text may paint without its glyphs being looked at: its box, generously grown.
|
||||
fn text_box_bounds(n: &Node) -> Rect {
|
||||
n.local_rect().inflate(n.height.max(10.0), n.height.max(10.0) * 0.5)
|
||||
}
|
||||
|
||||
/// Local bounds a node paints into (its box grown by stroke/effect reach, plus unclipped children).
|
||||
/// Text that overflows its box counts where its glyphs are, so it isn't culled while visible.
|
||||
fn local_paint_bounds(doc: &Document, n: &Node, depth: usize) -> Rect {
|
||||
/// Text that overflows its box counts where its glyphs are, so it isn't culled while visible;
|
||||
/// text greeked at `greek_scale` (see [`lod::greeked`]) paints only inside [`text_box_bounds`].
|
||||
fn local_paint_bounds(doc: &Document, n: &Node, depth: usize, greek_scale: Option<f64>) -> Rect {
|
||||
let reach = n.paint_reach();
|
||||
let mut r = n.local_rect().inflate(reach, reach);
|
||||
let clips = n.clip && n.kind.is_frame_like();
|
||||
@@ -264,13 +272,16 @@ fn local_paint_bounds(doc: &Document, n: &Node, depth: usize) -> Rect {
|
||||
if !c.visible {
|
||||
continue;
|
||||
}
|
||||
let cb = local_paint_bounds(doc, c, depth + 1);
|
||||
let cb = local_paint_bounds(doc, c, depth + 1, greek_scale);
|
||||
r = r.union(figcraft_geom::transform_rect_bbox(c.transform, cb));
|
||||
}
|
||||
}
|
||||
// Text can overflow its box.
|
||||
if let NodeKind::Text(_) = n.kind {
|
||||
r = r.inflate(n.height.max(10.0), n.height.max(10.0) * 0.5);
|
||||
r = r.union(text_box_bounds(n).inflate(reach, reach));
|
||||
if greek_scale.is_some_and(|s| lod::greeked(n, s)) {
|
||||
return r;
|
||||
}
|
||||
if let Some((l, dy)) = figcraft_text::layout_node(n)
|
||||
&& let Some(ink) = l.ink_bounds()
|
||||
{
|
||||
@@ -299,7 +310,7 @@ pub fn effect_margin(n: &Node) -> f64 {
|
||||
pub fn paint_bounds(doc: &Document, id: NodeId) -> Option<Rect> {
|
||||
let n = doc.node(id)?;
|
||||
let m = effect_margin(n).abs();
|
||||
let local = local_paint_bounds(doc, n, 0);
|
||||
let local = local_paint_bounds(doc, n, 0, None);
|
||||
Some(figcraft_geom::transform_rect_bbox(doc.world_transform(id), local).inflate(m, m))
|
||||
}
|
||||
|
||||
@@ -329,7 +340,7 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep
|
||||
}
|
||||
let xf = parent_xf * n.transform;
|
||||
let (pw, ph) = (pix.width(), pix.height());
|
||||
let local = local_paint_bounds(ctx.doc, n, 0);
|
||||
let local = local_paint_bounds(ctx.doc, n, 0, ctx.lod.then_some(ctx.scale));
|
||||
let dev = device_rect(xf, local);
|
||||
// Effects that read outside (blur) need a margin when culling.
|
||||
let blur_margin = effect_margin(n) * ctx.scale;
|
||||
@@ -341,6 +352,10 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep
|
||||
draw_outline(ctx, pix, n, xf, depth);
|
||||
return;
|
||||
}
|
||||
if ctx.lod && dev.width().max(dev.height()) < lod::TINY_PX && lod::splattable(ctx.doc, n, 0) {
|
||||
lod::splat_subtree(ctx, pix, n, xf, None, 1.0, depth);
|
||||
return;
|
||||
}
|
||||
if !needs_layer(n) {
|
||||
draw_content(ctx, pix, n, xf, depth);
|
||||
return;
|
||||
@@ -497,6 +512,7 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize)
|
||||
draw_children(ctx, pix, n, xf, depth);
|
||||
}
|
||||
NodeKind::Group => draw_children(ctx, pix, n, xf, depth),
|
||||
NodeKind::Text(_) if ctx.lod && lod::greeked(n, ctx.scale) => lod::greek_text(pix, n, xf, text_box_bounds(n)),
|
||||
NodeKind::Text(t) => {
|
||||
let Some((layout, dy)) = figcraft_text::layout_node(n) else { return };
|
||||
let gxf = xf * Affine::translate((0.0, dy));
|
||||
@@ -528,16 +544,19 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize)
|
||||
let _ = t;
|
||||
}
|
||||
_ => {
|
||||
let outline = node_outline(doc, n);
|
||||
let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. });
|
||||
if let Some((path, rule)) = &outline
|
||||
let splatted = ctx.lod && closed && lod::splat_fills(pix, n, xf);
|
||||
let clips = n.clip && n.kind.is_frame_like() && !n.children.is_empty();
|
||||
// The outline fills, clips children to rounded corners and strokes booleans.
|
||||
let outline = if splatted && !(clips && n.corners.uniform() != Some(0.0)) { None } else { node_outline(doc, n) };
|
||||
if !splatted
|
||||
&& let Some((path, rule)) = &outline
|
||||
&& closed
|
||||
&& let Some(tp) = ts_path(path)
|
||||
{
|
||||
fill_with(pix, &tp, &n.fills, w, h, xf, *rule, doc, None);
|
||||
}
|
||||
if !matches!(n.kind, NodeKind::BooleanOp { .. }) && !n.children.is_empty() {
|
||||
let clips = n.clip && n.kind.is_frame_like();
|
||||
if clips {
|
||||
draw_children_clipped(ctx, pix, n, xf, depth, outline.as_ref().map(|(p, _)| p));
|
||||
} else {
|
||||
@@ -629,7 +648,11 @@ fn draw_children_clipped(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, dept
|
||||
draw_children(ctx, &mut layer, n, lxf, depth);
|
||||
let rect_clip = n.corners.uniform() == Some(0.0) && is_axis_aligned(xf);
|
||||
if rect_clip {
|
||||
pix.draw_pixmap(lx, ly, layer.as_ref(), &PixmapPaint::default(), Transform::identity(), None);
|
||||
if ctx.lod {
|
||||
lod::blit_over(pix, lx, ly, &layer);
|
||||
} else {
|
||||
pix.draw_pixmap(lx, ly, layer.as_ref(), &PixmapPaint::default(), Transform::identity(), None);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(cp) = clip.and_then(ts_path)
|
||||
|
||||
+20
-10
@@ -8,6 +8,7 @@
|
||||
pub mod draw;
|
||||
pub mod fx;
|
||||
pub mod images;
|
||||
pub mod lod;
|
||||
pub mod paint;
|
||||
pub mod retained;
|
||||
|
||||
@@ -47,6 +48,10 @@ pub struct Options {
|
||||
pub outline_mode: bool,
|
||||
/// Nodes to leave out (an editor drawing them itself).
|
||||
pub skip: Vec<NodeId>,
|
||||
/// A preview (the canvas): when zoomed out, approximate what's too small on screen to see in
|
||||
/// detail, see [`lod`]. Off for everything that must be exact (exports, thumbnails); no
|
||||
/// effect in outline mode.
|
||||
pub lod: bool,
|
||||
}
|
||||
|
||||
fn new_pixmap(w: u32, h: u32) -> Option<Pixmap> {
|
||||
@@ -117,16 +122,18 @@ fn render_bands(doc: &Document, page: NodeId, view: &View, opts: &Options, bands
|
||||
})
|
||||
.collect();
|
||||
let mut out = new_pixmap(view.width, view.height)?;
|
||||
let row = out.width() as usize * 4;
|
||||
for (y0, part) in parts {
|
||||
let Some(part) = part else { continue };
|
||||
out.draw_pixmap(
|
||||
0,
|
||||
y0 as i32,
|
||||
part.as_ref(),
|
||||
&tiny_skia::PixmapPaint { blend_mode: tiny_skia::BlendMode::Source, ..Default::default() },
|
||||
tiny_skia::Transform::identity(),
|
||||
None,
|
||||
);
|
||||
// Bands are as wide as the view: their rows go straight into place.
|
||||
let start = y0 as usize * row;
|
||||
let dst = out.data_mut();
|
||||
let n = part.data().len().min(dst.len().saturating_sub(start));
|
||||
if part.width() == view.width.clamp(1, 16_384)
|
||||
&& let (Some(d), Some(s)) = (dst.get_mut(start..start + n), part.data().get(..n))
|
||||
{
|
||||
d.copy_from_slice(s);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
@@ -141,7 +148,8 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti
|
||||
if let Some(bg) = bg {
|
||||
pix.fill(paint::ts_color(bg));
|
||||
}
|
||||
let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: opts.outline_mode, skip: &opts.skip };
|
||||
let lod = opts.lod && !opts.outline_mode && view.scale <= lod::MAX_SCALE;
|
||||
let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: opts.outline_mode, skip: &opts.skip, lod };
|
||||
let xf = view.transform();
|
||||
let Some(p) = doc.node(page) else { return Some(pix) };
|
||||
for c in &p.children {
|
||||
@@ -152,7 +160,7 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti
|
||||
|
||||
/// Render several nodes (in z-order) into an existing pixmap through `view`.
|
||||
pub fn render_nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap) {
|
||||
let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: false, skip: &[] };
|
||||
let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: false, skip: &[], lod: false };
|
||||
for id in ids {
|
||||
let parent_xf = doc.node(*id).and_then(|n| n.parent).map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY);
|
||||
draw::draw_node(&ctx, pix, *id, view.transform() * parent_xf, 0);
|
||||
@@ -314,6 +322,8 @@ pub fn png_insert_chunk(png: Vec<u8>, kind: &[u8; 4], data: &[u8]) -> Vec<u8> {
|
||||
#[cfg(test)]
|
||||
mod band_tests;
|
||||
#[cfg(test)]
|
||||
mod lod_tests;
|
||||
#[cfg(test)]
|
||||
mod retained_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
//! Level of detail for canvas previews ([`crate::Options::lod`]): what a zoomed-out view can't
|
||||
//! show in detail is drawn approximately, so a page of tens of thousands of layers renders in a
|
||||
//! frame.
|
||||
//!
|
||||
//! - A layer whose whole subtree paints into less than [`TINY_PX`] device pixels each way is
|
||||
//! *splatted*: every visible layer in it adds its paints' colours, weighted by how much of its
|
||||
//! box they cover, to the pixels its box overlaps (a box filter). No paths, layers or effects.
|
||||
//! - Text below [`GREEK_PX`] (its largest font size on screen) is *greeked*: one bar per line,
|
||||
//! across the line's glyphs, at about the ink density of text.
|
||||
//!
|
||||
//! - Small axis-aligned rectangles with solid fills are splatted instead of filled as paths, and
|
||||
//! rectangular clipping layers are composited without a full blending pipeline.
|
||||
//!
|
||||
//! Only views at [`MAX_SCALE`] or below are approximated: 1:1 and closer previews are exact, and
|
||||
//! so are exports, thumbnails and every other render. Whatever LOD can't approximate faithfully
|
||||
//! (image paints, masks) takes the exact path at any scale.
|
||||
|
||||
use figcraft_color::{BlendMode, Paint, PaintKind};
|
||||
use figcraft_doc::{Document, ListStyle, Node, NodeKind, StrokeAlign, TextAutoResize, TextData};
|
||||
use figcraft_geom::{Affine, Rect};
|
||||
use tiny_skia::Pixmap;
|
||||
|
||||
use crate::draw::{Ctx, ts_transform};
|
||||
|
||||
/// Views zoomed out further than this (device pixels per unit, at most) use LOD: closer views
|
||||
/// are always exact.
|
||||
pub const MAX_SCALE: f64 = 0.5;
|
||||
/// Subtrees smaller than this on screen (device pixels, each way) are splatted.
|
||||
pub const TINY_PX: f64 = 2.0;
|
||||
/// Text whose largest font size is below this on screen (device pixels) is greeked.
|
||||
pub const GREEK_PX: f64 = 4.0;
|
||||
/// Share of a line's (or a text box's) area that glyph ink covers.
|
||||
const TEXT_INK: f32 = 0.25;
|
||||
/// Height of a greeked line's bar, relative to the font size (about the x-height and a bit).
|
||||
const BAR_HEIGHT: f64 = 0.55;
|
||||
/// Where a single line's bar sits in its box, as shares of the box height: from about the
|
||||
/// x-height down to the baseline of an "auto" line height.
|
||||
const LINE_BAR: (f64, f64) = (0.35, 0.8);
|
||||
/// Deepest subtree splatted (deeper ones take the exact path).
|
||||
const MAX_DEPTH: usize = 64;
|
||||
/// Largest rectangle (device pixels²) whose fills are splatted rather than filled as a path.
|
||||
const SMALL_AREA: f64 = 1024.0;
|
||||
/// Corners rounded by less than this (device pixels) are drawn square when splatting.
|
||||
const SQUARE_CORNER_PX: f64 = 1.0;
|
||||
|
||||
/// The largest font size a text uses.
|
||||
fn max_font_size(t: &TextData) -> f64 {
|
||||
t.runs.iter().map(|r| r.style.font_size).fold(t.style.font_size, f64::max)
|
||||
}
|
||||
|
||||
/// Whether text node `n` is greeked at `scale` device pixels per unit.
|
||||
pub fn greeked(n: &Node, scale: f64) -> bool {
|
||||
matches!(&n.kind, NodeKind::Text(t) if max_font_size(t) * scale < GREEK_PX)
|
||||
}
|
||||
|
||||
/// The paints of a text: the node's and its runs' own.
|
||||
fn text_paints<'a>(n: &'a Node, t: &'a TextData) -> impl Iterator<Item = &'a Paint> {
|
||||
n.fills.iter().chain(t.runs.iter().filter_map(|r| r.style.fills.as_ref()).flatten())
|
||||
}
|
||||
|
||||
/// Whether the subtree of `n` can be splatted: no image paints (their colours aren't known
|
||||
/// without sampling them) and no masks (which change what their siblings show).
|
||||
pub fn splattable(doc: &Document, n: &Node, depth: usize) -> bool {
|
||||
if depth > MAX_DEPTH {
|
||||
return false;
|
||||
}
|
||||
let image = |p: &Paint| p.visible && matches!(p.kind, PaintKind::Image(_));
|
||||
let own = match &n.kind {
|
||||
NodeKind::Text(t) => text_paints(n, t).any(image),
|
||||
_ => n.fills.iter().any(image),
|
||||
};
|
||||
if own || n.strokes.iter().any(image) {
|
||||
return false;
|
||||
}
|
||||
n.children.iter().filter_map(|c| doc.node(*c)).filter(|c| c.visible).all(|c| !c.is_mask && splattable(doc, c, depth + 1))
|
||||
}
|
||||
|
||||
/// A paint's average colour as straight RGBA (alpha times the paint's opacity); `None` for
|
||||
/// paints that draw nothing or can't be averaged (images).
|
||||
fn paint_color(p: &Paint) -> Option<[f32; 4]> {
|
||||
if !p.visible || p.opacity <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let op = p.opacity.clamp(0.0, 1.0);
|
||||
let c = match &p.kind {
|
||||
PaintKind::Solid { color } => [color.r, color.g, color.b, color.a],
|
||||
PaintKind::Gradient(g) => {
|
||||
let stops = g.sorted_stops();
|
||||
let n = stops.len().max(1) as f32;
|
||||
stops.iter().fold([0.0; 4], |acc, s| [acc[0] + s.color.r / n, acc[1] + s.color.g / n, acc[2] + s.color.b / n, acc[3] + s.color.a / n])
|
||||
}
|
||||
PaintKind::Image(_) => return None,
|
||||
};
|
||||
let c = c.map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 });
|
||||
(c[3] * op > 0.0).then_some([c[0], c[1], c[2], c[3] * op])
|
||||
}
|
||||
|
||||
/// The share of a node's box its fill covers.
|
||||
fn fill_share(n: &Node) -> f32 {
|
||||
let (w, h) = (n.width.abs(), n.height.abs());
|
||||
match &n.kind {
|
||||
NodeKind::Ellipse { sweep, inner, .. } => {
|
||||
let arc = (sweep.abs() / std::f64::consts::TAU).min(1.0);
|
||||
let ring = 1.0 - inner.clamp(0.0, 1.0).powi(2);
|
||||
(std::f64::consts::FRAC_PI_4 * arc * ring) as f32
|
||||
}
|
||||
NodeKind::Polygon { .. } => 0.6,
|
||||
NodeKind::Star { .. } | NodeKind::Vector { .. } | NodeKind::BooleanOp { .. } => 0.5,
|
||||
_ => {
|
||||
// Rounded corners cut (4 − π) r² from the box.
|
||||
let r = (n.corners.radii.iter().sum::<f64>() / 4.0).clamp(0.0, w.min(h) / 2.0);
|
||||
let area = w * h;
|
||||
if area > 0.0 { (1.0 - (4.0 - std::f64::consts::PI) * r * r / area).clamp(0.0, 1.0) as f32 } else { 1.0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The local box a node's strokes paint in, and the share of it they cover.
|
||||
fn stroke_box(n: &Node) -> (Rect, f32) {
|
||||
let wt = n.stroke.sides.map(|s| s.iter().copied().fold(0.0, f64::max)).unwrap_or(n.stroke.weight).max(0.0);
|
||||
let out = match n.stroke.align {
|
||||
StrokeAlign::Inside => 0.0,
|
||||
StrokeAlign::Center => wt / 2.0,
|
||||
StrokeAlign::Outside => wt,
|
||||
};
|
||||
// Open paths (lines) are stroked along their length; a line's box has no height of its own.
|
||||
let closed = figcraft_doc::geometry::is_closed(n);
|
||||
let out = if closed { out } else { wt / 2.0 };
|
||||
let r = n.local_rect().inflate(out, out);
|
||||
let (w, h) = (n.width.abs(), n.height.abs());
|
||||
let length = if closed { 2.0 * (w + h) } else { w.hypot(h) };
|
||||
let area = r.area().abs();
|
||||
let share = if area > 0.0 { (length * wt / area).clamp(0.0, 1.0) } else { 0.0 };
|
||||
(r, share as f32)
|
||||
}
|
||||
|
||||
/// How much of a text box glyph ink covers, estimated from its characters (no layout).
|
||||
fn text_share(t: &TextData, w: f64, h: f64) -> f32 {
|
||||
let area = (w * h).abs();
|
||||
let fs = max_font_size(t).max(0.0);
|
||||
let chars = t.characters.chars().filter(|c| !c.is_whitespace()).count() as f64;
|
||||
// An average glyph is about half an em wide and a line about 1.2 em tall.
|
||||
let ink = chars * 0.5 * fs * 1.2 * fs;
|
||||
if area > 0.0 && ink.is_finite() { TEXT_INK * (ink / area).clamp(0.0, 1.0) as f32 } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Fill a small rectangle (frame, component, instance…) by splatting: axis-aligned, its corners
|
||||
/// rounded by less than [`SQUARE_CORNER_PX`] and only solid, normally blended fills, where a box
|
||||
/// filter is what anti-aliasing computes anyway, without a path. Returns whether it did.
|
||||
pub fn splat_fills(pix: &mut Pixmap, n: &Node, xf: Affine) -> bool {
|
||||
use NodeKind as K;
|
||||
if !matches!(n.kind, K::Rectangle | K::Frame | K::Component { .. } | K::ComponentSet { .. } | K::Instance { .. } | K::Section { .. })
|
||||
|| !axis_aligned(xf)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let c = xf.as_coeffs();
|
||||
let radius = n.corners.radii.iter().copied().fold(0.0, f64::max);
|
||||
let dev = figcraft_geom::transform_rect_bbox(xf, n.local_rect());
|
||||
let simple =
|
||||
|p: &Paint| !p.visible || (matches!(p.kind, PaintKind::Solid { .. }) && matches!(p.blend, BlendMode::Normal | BlendMode::PassThrough));
|
||||
if !(radius * c[0].abs().max(c[3].abs()) < SQUARE_CORNER_PX && dev.area().abs() <= SMALL_AREA && n.fills.iter().all(simple)) {
|
||||
return false;
|
||||
}
|
||||
for color in n.fills.iter().filter_map(paint_color) {
|
||||
splat(pix, dev, color);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Splat node `n` (whose transform is already in `xf`) and its subtree onto `pix`. `clip` is the
|
||||
/// device rectangle clipping frames above leave visible; `alpha` the opacity they multiply in.
|
||||
pub fn splat_subtree(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, clip: Option<Rect>, alpha: f32, depth: usize) {
|
||||
if depth > 256 || ctx.skip.contains(&n.id) || !n.visible || n.is_mask {
|
||||
return;
|
||||
}
|
||||
let alpha = alpha * n.opacity.clamp(0.0, 1.0);
|
||||
if alpha <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let local = n.local_rect();
|
||||
let paint = |pix: &mut Pixmap, r: Rect, paints: &mut dyn Iterator<Item = &Paint>, share: f32| {
|
||||
for c in paints.filter_map(paint_color) {
|
||||
splat_local(pix, r, xf, clip, [c[0], c[1], c[2], c[3] * alpha * share]);
|
||||
}
|
||||
};
|
||||
match &n.kind {
|
||||
NodeKind::Page { .. } | NodeKind::Group => {}
|
||||
NodeKind::Text(t) => paint(pix, local, &mut text_paints(n, t), text_share(t, n.width, n.height)),
|
||||
_ => {
|
||||
if figcraft_doc::geometry::is_closed(n) {
|
||||
paint(pix, local, &mut n.fills.iter(), fill_share(n));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matches!(n.kind, NodeKind::BooleanOp { .. }) {
|
||||
let clip = if n.clip && n.kind.is_frame_like() {
|
||||
let own = figcraft_geom::transform_rect_bbox(xf, local);
|
||||
Some(clip.map_or(own, |c| c.intersect(own)))
|
||||
} else {
|
||||
clip
|
||||
};
|
||||
for c in n.children.iter().filter_map(|c| ctx.doc.node(*c)) {
|
||||
splat_subtree(ctx, pix, c, xf * c.transform, clip, alpha, depth + 1);
|
||||
}
|
||||
}
|
||||
if n.has_visible_stroke() && !matches!(n.kind, NodeKind::Text(_)) {
|
||||
let (r, share) = stroke_box(n);
|
||||
paint(pix, r, &mut n.strokes.iter(), share);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether text is one plain line whose box is exactly its extent (auto width, one paragraph, no
|
||||
/// runs, lists or indents): greeked from the box alone, without laying it out.
|
||||
fn single_line(t: &TextData) -> bool {
|
||||
t.auto_resize == TextAutoResize::WidthAndHeight
|
||||
&& t.runs.is_empty()
|
||||
&& t.list == ListStyle::None
|
||||
&& t.paragraph_indent == 0.0
|
||||
&& !t.characters.contains(['\n', '\r', '\u{2028}', '\u{2029}'])
|
||||
}
|
||||
|
||||
/// Greek text node `n` (its transform in `xf`): a bar per laid-out line, across the line's
|
||||
/// glyphs, kept inside `limit` (local; what the node's bounds allow it to paint).
|
||||
pub fn greek_text(pix: &mut Pixmap, n: &Node, xf: Affine, limit: Rect) {
|
||||
if let NodeKind::Text(t) = &n.kind
|
||||
&& single_line(t)
|
||||
{
|
||||
if !t.characters.trim().is_empty() {
|
||||
let bar = Rect::new(0.0, n.height * LINE_BAR.0, n.width, n.height * LINE_BAR.1).intersect(limit);
|
||||
for c in n.fills.iter().filter_map(paint_color) {
|
||||
fill_local(pix, bar, xf, [c[0], c[1], c[2], c[3] * TEXT_INK]);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some((layout, dy)) = figcraft_text::layout_node(n) else { return };
|
||||
for line in &layout.lines {
|
||||
let glyphs = layout.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]);
|
||||
let Some(first) = glyphs.first() else { continue };
|
||||
let size = glyphs.iter().map(|g| g.size.abs()).fold(0.0, f64::max);
|
||||
let x0 = glyphs.iter().map(|g| g.x.min(g.x + g.advance)).fold(f64::INFINITY, f64::min);
|
||||
let x1 = glyphs.iter().map(|g| g.x.max(g.x + g.advance)).fold(f64::NEG_INFINITY, f64::max);
|
||||
let bar = Rect::new(x0, line.baseline + dy - size * BAR_HEIGHT, x1, line.baseline + dy).intersect(limit);
|
||||
if !(bar.width() > 0.0 && bar.height() > 0.0) {
|
||||
continue;
|
||||
}
|
||||
let fills = layout.styles.get(first.style).and_then(|s| s.fills.as_ref()).unwrap_or(&n.fills);
|
||||
for c in fills.iter().filter_map(paint_color) {
|
||||
fill_local(pix, bar, xf, [c[0], c[1], c[2], c[3] * TEXT_INK]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `xf` keeps rectangles axis-aligned (no rotation or skew).
|
||||
fn axis_aligned(xf: Affine) -> bool {
|
||||
let c = xf.as_coeffs();
|
||||
c[1] == 0.0 && c[2] == 0.0
|
||||
}
|
||||
|
||||
/// Fill local rectangle `r` through `xf` with straight RGBA `color`: a box-filtered splat when
|
||||
/// it stays axis-aligned, an anti-aliased path otherwise.
|
||||
fn fill_local(pix: &mut Pixmap, r: Rect, xf: Affine, color: [f32; 4]) {
|
||||
if axis_aligned(xf) {
|
||||
splat(pix, figcraft_geom::transform_rect_bbox(xf, r), color);
|
||||
return;
|
||||
}
|
||||
let (Some(rect), Some(c)) = (
|
||||
tiny_skia::Rect::from_ltrb(r.x0 as f32, r.y0 as f32, r.x1 as f32, r.y1 as f32),
|
||||
tiny_skia::Color::from_rgba(color[0], color[1], color[2], color[3]),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let paint = tiny_skia::Paint { shader: tiny_skia::Shader::SolidColor(c), anti_alias: true, ..Default::default() };
|
||||
pix.fill_rect(rect, &paint, ts_transform(xf), None);
|
||||
}
|
||||
|
||||
/// Splat local rectangle `r` through `xf`, inside `clip`: its device bounding box, with the
|
||||
/// colour thinned by how much of that box the (possibly rotated) rectangle fills.
|
||||
fn splat_local(pix: &mut Pixmap, r: Rect, xf: Affine, clip: Option<Rect>, color: [f32; 4]) {
|
||||
let bbox = figcraft_geom::transform_rect_bbox(xf, r);
|
||||
let c = xf.as_coeffs();
|
||||
let area = (r.area() * (c[0] * c[3] - c[1] * c[2])).abs();
|
||||
let bbox_area = bbox.area().abs();
|
||||
// Zero-area boxes (lines) cover nothing; they're stroked through `stroke_box`.
|
||||
if bbox_area.is_nan() || bbox_area <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let fill = (area / bbox_area).clamp(0.0, 1.0) as f32;
|
||||
let target = clip.map_or(bbox, |c| c.intersect(bbox));
|
||||
splat(pix, target, [color[0], color[1], color[2], color[3] * fill]);
|
||||
}
|
||||
|
||||
/// Composite straight RGBA `color` over device rectangle `r`, each pixel weighted by how much of
|
||||
/// it `r` covers (a box filter): sub-pixel layers land as their colour averaged over the pixels
|
||||
/// they straddle, so they neither vanish nor jump from pixel to pixel as the view moves.
|
||||
pub fn splat(pix: &mut Pixmap, r: Rect, color: [f32; 4]) {
|
||||
let a = color[3].clamp(0.0, 1.0);
|
||||
let (w, h) = (f64::from(pix.width()), f64::from(pix.height()));
|
||||
let (x0, y0, x1, y1) = (r.x0.max(0.0), r.y0.max(0.0), r.x1.min(w), r.y1.min(h));
|
||||
if !(a > 0.0 && x0.is_finite() && y0.is_finite() && x1 > x0 && y1 > y0) {
|
||||
return;
|
||||
}
|
||||
// Premultiplied source at full coverage, in 0..=255.
|
||||
let src = [color[0].clamp(0.0, 1.0) * 255.0, color[1].clamp(0.0, 1.0) * 255.0, color[2].clamp(0.0, 1.0) * 255.0, 255.0];
|
||||
let opaque = src.map(|c| (c + 0.5) as u8);
|
||||
let mix = |d: &mut [u8], cov: f32| {
|
||||
if cov >= 1.0 {
|
||||
// Fully covered by an opaque colour (the inside of most fills).
|
||||
d.copy_from_slice(&opaque);
|
||||
} else if cov > 0.0 {
|
||||
let keep = 1.0 - cov;
|
||||
// `as u8` saturates; + 0.5 rounds.
|
||||
for (d, s) in d.iter_mut().zip(src) {
|
||||
*d = (s * cov + f32::from(*d) * keep + 0.5) as u8;
|
||||
}
|
||||
}
|
||||
};
|
||||
let stride = pix.width() as usize * 4;
|
||||
let data = pix.data_mut();
|
||||
// Within the pixmap: the casts below stay in range.
|
||||
let (px0, py0, px1, py1) = (x0.floor() as usize, y0.floor() as usize, x1.ceil() as usize, y1.ceil() as usize);
|
||||
// How much of the first and last columns the rectangle covers (all of the ones between).
|
||||
let (first, last) = if px1 - px0 == 1 { ((x1 - x0) as f32, 0.0) } else { ((px0 as f64 + 1.0 - x0) as f32, (x1 - (px1 - 1) as f64) as f32) };
|
||||
for py in py0..py1 {
|
||||
let cy = (y1.min(py as f64 + 1.0) - y0.max(py as f64)) as f32 * a;
|
||||
let Some(row) = data.get_mut(py * stride + px0 * 4..py * stride + px1 * 4) else { continue };
|
||||
let mut pixels = row.chunks_exact_mut(4);
|
||||
if let Some(d) = pixels.next() {
|
||||
mix(d, first * cy);
|
||||
}
|
||||
if let Some(d) = pixels.next_back() {
|
||||
mix(d, last * cy);
|
||||
}
|
||||
for d in pixels {
|
||||
mix(d, cy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Composite premultiplied `src` over `dst` with its top-left at `(x, y)` (source-over, the
|
||||
/// pixels outside `dst` dropped): what `draw_pixmap` does for a plain layer, without its pipeline
|
||||
/// set-up, which dominates for the many small layers of a zoomed-out page.
|
||||
pub fn blit_over(dst: &mut Pixmap, x: i32, y: i32, src: &Pixmap) {
|
||||
let (dw, dh) = (i64::from(dst.width()), i64::from(dst.height()));
|
||||
let (sw, sh) = (i64::from(src.width()), i64::from(src.height()));
|
||||
let (x, y) = (i64::from(x), i64::from(y));
|
||||
// The overlap, in source columns and rows.
|
||||
let (c0, c1) = ((-x).max(0), sw.min(dw - x));
|
||||
let (r0, r1) = ((-y).max(0), sh.min(dh - y));
|
||||
if c1 <= c0 || r1 <= r0 {
|
||||
return;
|
||||
}
|
||||
let (sstride, dstride) = (sw as usize * 4, dw as usize * 4);
|
||||
let (c0, c1) = (c0 as usize * 4, c1 as usize * 4);
|
||||
let dx = (x * 4) as isize;
|
||||
let s = src.data();
|
||||
let d = dst.data_mut();
|
||||
for r in r0..r1 {
|
||||
let srow = r as usize * sstride;
|
||||
let drow = (r + y) as usize * dstride;
|
||||
let (Some(sp), Some(dp)) = (s.get(srow + c0..srow + c1), d.get_mut((drow + c0).wrapping_add_signed(dx)..(drow + c1).wrapping_add_signed(dx)))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for (sp, dp) in sp.chunks_exact(4).zip(dp.chunks_exact_mut(4)) {
|
||||
let a = sp[3];
|
||||
if a == 255 {
|
||||
dp.copy_from_slice(sp);
|
||||
} else if a > 0 {
|
||||
let keep = 255 - u32::from(a);
|
||||
for (d, s) in dp.iter_mut().zip(sp) {
|
||||
*d = (u32::from(*s) + (u32::from(*d) * keep + 127) / 255).min(255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
//! Level of detail: only canvas previews (`Options::lod`) at small scales are approximated, the
|
||||
//! approximation stays close to the exact render (no holes, same colours on average), and exports
|
||||
//! never see it.
|
||||
|
||||
use std::sync::{Arc, Once};
|
||||
|
||||
use figcraft_color::{Color, ImageScaleMode, Paint};
|
||||
use figcraft_doc::{Document, ImageData, NodeId, NodeKind, TextData};
|
||||
use figcraft_geom::{Affine, Rect};
|
||||
|
||||
use crate::retained_tests::assert_same;
|
||||
use crate::{Options, Pixmap, Retained, View, lod, render_node, render_view, render_view_single};
|
||||
|
||||
fn fonts() {
|
||||
static ONCE: Once = Once::new();
|
||||
ONCE.call_once(figcraft_text::scan_system_fonts);
|
||||
}
|
||||
|
||||
fn solid(hex: &str) -> Vec<Paint> {
|
||||
vec![Paint::solid(Color::from_hex(hex).unwrap())]
|
||||
}
|
||||
|
||||
const COLS: usize = 12;
|
||||
const ROWS: usize = 8;
|
||||
|
||||
/// A page of `COLS` × `ROWS` cards (a clipping frame holding a header bar, a rounded rectangle, an
|
||||
/// ellipse, a group of small squares and two texts), like a zoomed-out page of screens.
|
||||
fn cards() -> (Document, NodeId) {
|
||||
fonts();
|
||||
let mut d = Document::new("lod");
|
||||
let page = d.first_page().unwrap();
|
||||
for i in 0..COLS * ROWS {
|
||||
let (x, y) = ((i % COLS) as f64 * 220.0, (i / COLS) as f64 * 160.0);
|
||||
let card = d.create(page, NodeKind::Frame, "Card", x, y, 200.0, 140.0).unwrap();
|
||||
d.get_mut(card).unwrap().clip = true;
|
||||
let bar = d.create(card, NodeKind::Rectangle, "Bar", 0.0, 0.0, 200.0, 24.0).unwrap();
|
||||
d.get_mut(bar).unwrap().fills = solid("2D6CDF");
|
||||
let tile = d.create(card, NodeKind::Rectangle, "Tile", 12.0, 36.0, 60.0, 60.0).unwrap();
|
||||
let n = d.get_mut(tile).unwrap();
|
||||
n.fills = solid(["E8505B", "F9A03F", "35B37E"][i % 3]);
|
||||
n.corners.radii = [8.0; 4];
|
||||
let dot = d.create(card, NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "Dot", 150.0, 40.0, 30.0, 30.0).unwrap();
|
||||
d.get_mut(dot).unwrap().fills = solid("7A3FD1");
|
||||
let group = d.create(card, NodeKind::Group, "Squares", 84.0, 100.0, 100.0, 12.0).unwrap();
|
||||
for k in 0..6 {
|
||||
let s = d.create(group, NodeKind::Rectangle, "Square", k as f64 * 16.0, 0.0, 12.0, 12.0).unwrap();
|
||||
d.get_mut(s).unwrap().fills = solid("333333");
|
||||
}
|
||||
let mut title = TextData::new(format!("Card number {i}"));
|
||||
title.style.font_size = 16.0;
|
||||
d.create(card, NodeKind::Text(title), "Title", 84.0, 36.0, 110.0, 20.0).unwrap();
|
||||
let mut body = TextData::new("Some body text that wraps onto a second line");
|
||||
body.auto_resize = figcraft_doc::TextAutoResize::Height;
|
||||
body.style.font_size = 11.0;
|
||||
d.create(card, NodeKind::Text(body), "Body", 84.0, 60.0, 110.0, 30.0).unwrap();
|
||||
}
|
||||
(d, page)
|
||||
}
|
||||
|
||||
fn lod() -> Options {
|
||||
Options { lod: true, ..Default::default() }
|
||||
}
|
||||
|
||||
fn view(scale: f64) -> View {
|
||||
View {
|
||||
origin: (-20.0, -20.0),
|
||||
scale,
|
||||
width: ((COLS as f64 * 220.0 + 40.0) * scale) as u32,
|
||||
height: ((ROWS as f64 * 160.0 + 40.0) * scale) as u32,
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest difference between the mean colours of `block` × `block` blocks of two renders,
|
||||
/// and the mean difference over all channels. A hole (a layer missing) shows as a block whose
|
||||
/// colour is far off; approximations move colours a little.
|
||||
fn block_diff(a: &Pixmap, b: &Pixmap, block: u32) -> (f64, f64) {
|
||||
assert_eq!((a.width(), a.height()), (b.width(), b.height()));
|
||||
let (w, h) = (a.width(), a.height());
|
||||
let mut worst: f64 = 0.0;
|
||||
for by in (0..h).step_by(block as usize) {
|
||||
for bx in (0..w).step_by(block as usize) {
|
||||
let mut sums = [0.0f64; 8];
|
||||
let mut count = 0.0;
|
||||
for y in by..(by + block).min(h) {
|
||||
for x in bx..(bx + block).min(w) {
|
||||
let i = ((y * w + x) * 4) as usize;
|
||||
for c in 0..4 {
|
||||
sums[c] += f64::from(a.data()[i + c]);
|
||||
sums[4 + c] += f64::from(b.data()[i + c]);
|
||||
}
|
||||
count += 1.0;
|
||||
}
|
||||
}
|
||||
for c in 0..4 {
|
||||
worst = worst.max((sums[c] - sums[4 + c]).abs() / count);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mean = a.data().iter().zip(b.data()).map(|(x, y)| f64::from(x.abs_diff(*y))).sum::<f64>() / a.data().len() as f64;
|
||||
(worst, mean)
|
||||
}
|
||||
|
||||
/// Mean brightness (0–255) of a render.
|
||||
fn lum(p: &Pixmap) -> f64 {
|
||||
p.data().chunks(4).map(|c| f64::from(c[0]) + f64::from(c[1]) + f64::from(c[2])).sum::<f64>() / (p.data().len() / 4 * 3) as f64
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lod_is_off_unless_asked_for() {
|
||||
assert!(!Options::default().lod);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoomed_out_previews_are_close_to_exact() {
|
||||
let (d, p) = cards();
|
||||
for scale in [0.02, 0.05, 0.12, 0.25] {
|
||||
let v = view(scale);
|
||||
let exact = render_view(&d, p, &v, &Options::default()).unwrap();
|
||||
let approx = render_view(&d, p, &v, &lod()).unwrap();
|
||||
assert_ne!(exact.data(), approx.data(), "scale {scale}: LOD should kick in");
|
||||
let (worst, mean) = block_diff(&exact, &approx, 4);
|
||||
// Sub-pixel shapes are anti-aliased a little heavier on the exact path; holes (missing
|
||||
// layers) would differ by whole colours.
|
||||
let (a, b) = (lum(&exact), lum(&approx));
|
||||
assert!(
|
||||
worst < 48.0 && mean < 8.0 && (a - b).abs() < 4.0,
|
||||
"scale {scale}: blocks differ by up to {worst:.1}, {mean:.2} on average, brightness {a:.1} vs {b:.1}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_up_previews_are_exact() {
|
||||
// Above `MAX_SCALE` (1:1 and closer included) previews are exact.
|
||||
let (d, p) = cards();
|
||||
for scale in [lod::MAX_SCALE + 0.01, 1.0, 2.0] {
|
||||
let v = View { origin: (-10.0, -10.0), scale, width: 480, height: 360 };
|
||||
let exact = render_view(&d, p, &v, &Options::default()).unwrap();
|
||||
let approx = render_view(&d, p, &v, &lod()).unwrap();
|
||||
assert_eq!(exact.data(), approx.data(), "scale {scale}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_never_use_lod() {
|
||||
// An export at a tiny scale is the exact render of the same area, not the preview.
|
||||
let (d, p) = cards();
|
||||
let card = d.node(p).unwrap().children[0];
|
||||
let scale = 0.05;
|
||||
let png = render_node(&d, card, scale).unwrap();
|
||||
let b = crate::export_bounds(&d, card).unwrap();
|
||||
let v = View { origin: (b.x0, b.y0), scale, width: png.width(), height: png.height() };
|
||||
let mut exact = Pixmap::new(v.width, v.height).unwrap();
|
||||
crate::render_nodes_into(&d, &[card], &v, &mut exact);
|
||||
assert_eq!(png.data(), exact.data());
|
||||
let mut preview = Pixmap::new(v.width, v.height).unwrap();
|
||||
let ctx = crate::draw::Ctx { doc: &d, scale, outline_mode: false, skip: &[], lod: true };
|
||||
let parent = d.world_transform(p);
|
||||
crate::draw::draw_node(&ctx, &mut preview, card, v.transform() * parent, 0);
|
||||
assert_ne!(png.data(), preview.data(), "the preview of the same area is approximated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_mode_ignores_lod() {
|
||||
let (d, p) = cards();
|
||||
let v = view(0.05);
|
||||
let a = render_view(&d, p, &v, &Options { outline_mode: true, ..Default::default() }).unwrap();
|
||||
let b = render_view(&d, p, &v, &Options { outline_mode: true, lod: true, ..Default::default() }).unwrap();
|
||||
assert_eq!(a.data(), b.data());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_layers_keep_their_ink() {
|
||||
// A sub-pixel black square on white darkens the pixels it straddles by its area, wherever it
|
||||
// sits on the pixel grid (no layer vanishes or jumps as the view moves).
|
||||
let mut d = Document::new("tiny");
|
||||
let p = d.first_page().unwrap();
|
||||
let r = d.create(p, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
d.get_mut(r).unwrap().fills = solid("000000");
|
||||
for offset in [0.0, 0.25, 0.5, 0.8] {
|
||||
let v = View { origin: (-20.0 - offset * 12.5, -20.0), scale: 0.08, width: 6, height: 6 };
|
||||
let pix = render_view(&d, p, &v, &Options { lod: true, background: Some(Color::WHITE), ..Default::default() }).unwrap();
|
||||
let ink: f64 = pix.data().chunks(4).map(|c| f64::from(255 - c[0]) / 255.0).sum();
|
||||
// 0.8 × 0.8 device pixels.
|
||||
assert!((ink - 0.64).abs() < 0.02, "offset {offset}: ink {ink}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_and_masks_take_the_exact_path() {
|
||||
let mut d = Document::new("exact");
|
||||
let p = d.first_page().unwrap();
|
||||
let img = image::RgbaImage::from_fn(8, 8, |x, y| image::Rgba([(x * 30) as u8, (y * 30) as u8, 90, 255]));
|
||||
let mut bytes = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
|
||||
d.images.insert("img".into(), ImageData { bytes: Arc::new(bytes), width: 8, height: 8 });
|
||||
let photo = d.create(p, NodeKind::Rectangle, "Photo", 0.0, 0.0, 20.0, 20.0).unwrap();
|
||||
d.get_mut(photo).unwrap().fills = vec![Paint::image("img", ImageScaleMode::Fill)];
|
||||
let group = d.create(p, NodeKind::Group, "Masked", 40.0, 0.0, 20.0, 20.0).unwrap();
|
||||
let mask = d.create(group, NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "Mask", 0.0, 0.0, 20.0, 20.0).unwrap();
|
||||
d.get_mut(mask).unwrap().is_mask = true;
|
||||
let under = d.create(group, NodeKind::Rectangle, "Under", 0.0, 0.0, 20.0, 20.0).unwrap();
|
||||
d.get_mut(under).unwrap().fills = solid("FF0000");
|
||||
for n in [photo, group] {
|
||||
assert!(!lod::splattable(&d, d.node(n).unwrap(), 0));
|
||||
}
|
||||
// The photo renders exactly as in an export.
|
||||
let v = View { origin: (-2.0, -2.0), scale: 0.06, width: 2, height: 2 };
|
||||
let exact = render_view(&d, p, &v, &Options::default()).unwrap();
|
||||
let approx = render_view(&d, p, &v, &lod()).unwrap();
|
||||
assert_eq!(exact.data(), approx.data());
|
||||
// The masked group is masked (its content may be approximated under the mask).
|
||||
let v = View { origin: (38.0, -2.0), scale: 0.06, width: 2, height: 2 };
|
||||
let exact = render_view(&d, p, &v, &Options::default()).unwrap();
|
||||
let approx = render_view(&d, p, &v, &lod()).unwrap();
|
||||
assert_same(&exact, &approx, "masked group");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn greeked_text_stays_inside_its_bounds() {
|
||||
fonts();
|
||||
let mut d = Document::new("greek");
|
||||
let p = d.first_page().unwrap();
|
||||
let mut t = TextData::new("Greeked text on a line\nand a second line");
|
||||
t.style.font_size = 20.0;
|
||||
let id = d.create(p, NodeKind::Text(t), "T", 100.0, 100.0, 300.0, 60.0).unwrap();
|
||||
let n = d.node(id).unwrap();
|
||||
assert!(lod::greeked(n, 0.1) && !lod::greeked(n, 0.5));
|
||||
let v = View { origin: (0.0, 0.0), scale: 0.1, width: 60, height: 30 };
|
||||
let pix = render_view(&d, p, &v, &Options { lod: true, background: Some(Color::WHITE), ..Default::default() }).unwrap();
|
||||
let bounds = crate::paint_bounds(&d, id).unwrap();
|
||||
let mut inked = 0;
|
||||
for (i, c) in pix.data().chunks(4).enumerate() {
|
||||
if c[0] < 250 {
|
||||
let (x, y) = ((i % 60) as f64 / 0.1, (i / 60) as f64 / 0.1);
|
||||
assert!(bounds.inflate(10.0, 10.0).contains((x, y)), "ink at {x},{y} outside {bounds:?}");
|
||||
inked += 1;
|
||||
}
|
||||
}
|
||||
assert!(inked > 10, "greeked text draws its lines ({inked} pixels)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_previews_match_full_ones() {
|
||||
// Pans and edits of a zoomed-out preview patch the kept image into what a full preview shows.
|
||||
let (d, p) = cards();
|
||||
let doc = Arc::new(d);
|
||||
let mut r = Retained::default();
|
||||
let at = |x: f64, y: f64| View { origin: (x, y), scale: 0.07, width: 160, height: 90 };
|
||||
let check = |r: &mut Retained, doc: &Arc<Document>, v: &View| {
|
||||
let (shown, pix, work) = r.render(doc, p, v, &lod()).unwrap();
|
||||
let pix = pix.clone();
|
||||
let full = render_view(doc, p, &shown, &lod()).unwrap();
|
||||
assert_same(&pix, &full, &format!("{work:?}"));
|
||||
work
|
||||
};
|
||||
check(&mut r, &doc, &at(0.0, 0.0));
|
||||
for (x, y) in [(70.0, 0.0), (70.0, 45.0), (-30.0, 90.0)] {
|
||||
assert!(matches!(check(&mut r, &doc, &at(x, y)), crate::Work::Partial { .. }));
|
||||
}
|
||||
let card = doc.node(p).unwrap().children[5];
|
||||
let mut moved = (*doc).clone();
|
||||
let n = moved.get_mut(card).unwrap();
|
||||
n.transform = Affine::translate((30.0, 10.0)) * n.transform;
|
||||
let moved = Arc::new(moved);
|
||||
assert!(matches!(check(&mut r, &moved, &at(-30.0, 90.0)), crate::Work::Partial { .. }));
|
||||
// A preview and an exact render never share a kept image.
|
||||
let (_, _, work) = r.render(&moved, p, &at(-30.0, 90.0), &Options::default()).unwrap();
|
||||
assert_eq!(work, crate::Work::Full);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn bands_match_one_piece_previews() {
|
||||
let (d, p) = cards();
|
||||
let v = view(0.1);
|
||||
let one = render_view_single(&d, p, &v, &lod()).unwrap();
|
||||
for bands in [2, 5] {
|
||||
let banded = crate::render_bands(&d, p, &v, &lod(), bands).unwrap();
|
||||
assert_same(&one, &banded, &format!("{bands} bands"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splat_and_blit_survive_junk() {
|
||||
let mut pix = Pixmap::new(4, 4).unwrap();
|
||||
for r in [
|
||||
Rect::new(f64::NAN, 0.0, 1.0, 1.0),
|
||||
Rect::new(-1e300, -1e300, 1e300, 1e300),
|
||||
Rect::new(10.0, 10.0, 20.0, 20.0),
|
||||
Rect::new(3.0, 3.0, 1.0, 1.0),
|
||||
Rect::new(-5.0, 1.5, 0.5, f64::INFINITY),
|
||||
] {
|
||||
lod::splat(&mut pix, r, [0.5, 0.5, 0.5, 1.0]);
|
||||
lod::splat(&mut pix, r, [f32::NAN, 2.0, -1.0, 0.5]);
|
||||
}
|
||||
// An exactly covered pixel takes the colour; half a pixel blends half of it.
|
||||
let mut pix = Pixmap::new(4, 1).unwrap();
|
||||
lod::splat(&mut pix, Rect::new(1.0, 0.0, 2.0, 1.0), [1.0, 0.0, 0.0, 1.0]);
|
||||
lod::splat(&mut pix, Rect::new(2.5, 0.0, 3.0, 1.0), [0.0, 0.0, 1.0, 1.0]);
|
||||
assert_eq!(&pix.data()[4..8], &[255, 0, 0, 255]);
|
||||
assert_eq!(&pix.data()[8..12], &[0, 0, 128, 128]);
|
||||
let src = Pixmap::new(3, 3).unwrap();
|
||||
let mut dst = Pixmap::new(4, 4).unwrap();
|
||||
for (x, y) in [(-2, -2), (3, 3), (-10, 0), (0, 10), (i32::MIN, i32::MAX), (i32::MAX, i32::MIN)] {
|
||||
lod::blit_over(&mut dst, x, y, &src);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ struct Frame {
|
||||
doc: Arc<Document>,
|
||||
page: NodeId,
|
||||
outline: bool,
|
||||
lod: bool,
|
||||
skip: Vec<NodeId>,
|
||||
background: Option<figcraft_color::Color>,
|
||||
scale: u64,
|
||||
@@ -132,6 +133,7 @@ impl Retained {
|
||||
if let Some(last) = self.last.take()
|
||||
&& last.page == page
|
||||
&& last.outline == opts.outline_mode
|
||||
&& last.lod == opts.lod
|
||||
&& last.skip == opts.skip
|
||||
&& last.background == opts.background
|
||||
&& last.scale == snapped.scale.to_bits()
|
||||
@@ -172,6 +174,7 @@ impl Retained {
|
||||
doc: doc.clone(),
|
||||
page,
|
||||
outline: opts.outline_mode,
|
||||
lod: opts.lod,
|
||||
skip: opts.skip.clone(),
|
||||
background: opts.background,
|
||||
scale: view.scale.to_bits(),
|
||||
|
||||
@@ -92,7 +92,7 @@ fn check(r: &mut Retained, doc: &Arc<Document>, page: NodeId, v: &View) -> Work
|
||||
/// Equal up to anti-aliasing: a part rendered on its own sees its paths translated by a whole
|
||||
/// number of pixels, and float rounding can move an edge sample (a fraction of one pixel's
|
||||
/// coverage along a curve). Stale or missing areas differ by whole colours, over many pixels.
|
||||
fn assert_same(a: &Pixmap, b: &Pixmap, what: &str) {
|
||||
pub(crate) fn assert_same(a: &Pixmap, b: &Pixmap, what: &str) {
|
||||
assert_eq!((a.width(), a.height()), (b.width(), b.height()));
|
||||
let (mut slight, mut big, mut first) = (0usize, 0usize, None);
|
||||
for (i, (x, y)) in a.data().iter().zip(b.data()).enumerate() {
|
||||
|
||||
Reference in New Issue
Block a user