mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 18:10:01 -06:00
684 lines
29 KiB
Rust
684 lines
29 KiB
Rust
//! The document walk both writers share: each visible object over the region becomes drawing
|
|
//! operations ([`Op`]) in paint order, in document space. What a format can't hold is decided
|
|
//! here once: with clipping (EMF), gradients become images clipped to what they paint and
|
|
//! patterns their tiles clipped to the shape; without (WMF), clipped art is written whole and
|
|
//! gradients and patterns as one colour.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
use kurbo::PathEl;
|
|
use vectorcraft_color::freeform::{painted_box, spread_scale};
|
|
use vectorcraft_color::{BlendMode, GradientKind, GradientPaint, Paint};
|
|
use vectorcraft_doc::{AppearanceItem, Dash, Document, ImageObject, LineCap, LineJoin, Node, NodeKind, StrokeLayer, SymbolBoxes};
|
|
use vectorcraft_effects::stroke;
|
|
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Shape};
|
|
|
|
use crate::dib::Rgba;
|
|
|
|
/// How deep symbols, brush art and pattern tiles may nest in one another (deeper art is left out).
|
|
const MAX_NEST: u32 = 8;
|
|
/// Most pattern tiles written in all. One fill tiles at most 250,000 (`PatternDef::offsets_covering`),
|
|
/// but a tile whose art is filled with a pattern again multiplies that at every level of nesting.
|
|
const MAX_TILES: usize = 250_000;
|
|
/// Most pixels along a side of a gradient's image (its colours are smooth).
|
|
const MAX_GRADIENT_PX: f64 = 512.0;
|
|
/// Opacity at or above this is written as opaque.
|
|
const OPAQUE: f32 = 0.998;
|
|
|
|
const TRANSPARENCY: &str = "transparency is left out of paths: transparent fills and strokes are written opaque";
|
|
const BLENDING: &str = "blending modes are left out";
|
|
const MASKS: &str = "opacity masks are left out";
|
|
const RASTER_FX: &str = "raster effects (shadows, glows, blurs, feathering) are left out";
|
|
const CLIPPING: &str = "clipping masks are not applied: the art they clip is written whole";
|
|
const FLAT_GRADIENTS: &str = "gradients are written as one colour, the average of their stops";
|
|
const FLAT_PATTERNS: &str = "pattern fills are written as one colour";
|
|
const TOO_MANY_TILES: &str = "pattern fills past 250,000 tiles in all are written as one colour";
|
|
const TEXT_OUTLINES: &str = "type is written as outlines: it can't be searched, selected or read aloud as text";
|
|
const PATTERN_TILES: &str = "pattern fills are written as their tiles, one shape each";
|
|
const PATTERN_SHAPES: &str = "pattern fills are written as their tiles, one shape for each part of the tile";
|
|
/// How far (points) art may lie past a convex clip, or a pattern's art past its tile, and still
|
|
/// count as inside (what the device units round away).
|
|
const INSIDE_TOL: f64 = 1e-6;
|
|
/// The warning for an image left out because it can't be decoded: the note raster exports give
|
|
/// (`vectorcraft-engine`'s `rasterfx::undecoded_warning`).
|
|
const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture";
|
|
|
|
/// A stroke as a pen draws it.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub(crate) struct Pen {
|
|
pub rgb: [u8; 3],
|
|
/// Points.
|
|
pub width: f64,
|
|
pub cap: LineCap,
|
|
pub join: LineJoin,
|
|
pub miter: f64,
|
|
/// A dash pattern that draws dashes (points).
|
|
pub dash: Option<Dash>,
|
|
}
|
|
|
|
/// One drawing operation, in document space.
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) enum Op {
|
|
Fill {
|
|
path: BezPath,
|
|
rule: FillRule,
|
|
rgb: [u8; 3],
|
|
},
|
|
Stroke {
|
|
path: BezPath,
|
|
pen: Pen,
|
|
},
|
|
/// An image: its pixel space (0..w, 0..h) maps into the document by `xf`; its alpha times
|
|
/// `opacity`.
|
|
Image {
|
|
image: Arc<Rgba>,
|
|
xf: Affine,
|
|
opacity: f32,
|
|
},
|
|
/// What follows, up to the matching [`Op::Unclip`], is clipped to `path` (inside the clips
|
|
/// around it). Only formats with clipping get these.
|
|
Clip {
|
|
path: BezPath,
|
|
rule: FillRule,
|
|
},
|
|
Unclip,
|
|
}
|
|
|
|
fn overlaps(a: Rect, b: Rect) -> bool {
|
|
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1
|
|
}
|
|
|
|
fn rgb_of(c: &vectorcraft_color::Color) -> [u8; 3] {
|
|
let [r, g, b, _] = c.to_rgba8(1.0);
|
|
[r, g, b]
|
|
}
|
|
|
|
/// The first visible solid fill colour in `n` (a pattern tile's art).
|
|
fn first_solid(n: &Node) -> Option<vectorcraft_color::Color> {
|
|
let own = n.appearance.items.iter().find_map(|i| match i {
|
|
AppearanceItem::Fill(f) if f.visible => f.paint.color(),
|
|
_ => None,
|
|
});
|
|
own.or_else(|| n.children().into_iter().flatten().find_map(|c| first_solid(c)))
|
|
}
|
|
|
|
/// `bp` cut into its dashes (as it is when `dash` draws none), for pens that can't draw them.
|
|
pub(crate) fn dashed(bp: &BezPath, dash: Option<&Dash>) -> BezPath {
|
|
dash.and_then(|d| stroke::dash(bp, d)).map_or_else(|| bp.clone(), |d| d.path)
|
|
}
|
|
|
|
fn q(v: f32) -> u8 {
|
|
(v.clamp(0.0, 1.0) * 255.0).round() as u8
|
|
}
|
|
|
|
pub(crate) struct Scene<'a> {
|
|
doc: &'a Document,
|
|
/// The art boxes of `doc`'s symbols, which the walk measures symbol instances by when it
|
|
/// leaves out objects outside the region ([`SymbolBoxes::quick`]).
|
|
symbols: SymbolBoxes<'a>,
|
|
region: Rect,
|
|
/// The format clips (EMF).
|
|
clips: bool,
|
|
ops: Vec<Op>,
|
|
warnings: Vec<String>,
|
|
/// Decoded images by key (`None`: can't be decoded, left out).
|
|
images: HashMap<String, Option<Arc<Rgba>>>,
|
|
brushes: Option<Vec<vectorcraft_brush::Brush>>,
|
|
/// The opacity of the containers around the current object.
|
|
opacity: f32,
|
|
/// Symbols, brush art and pattern tiles being written inside one another.
|
|
nest: u32,
|
|
/// Pattern tiles left to write ([`MAX_TILES`]).
|
|
tiles_left: usize,
|
|
/// Pattern tiles being written that may be merged: their parts aren't left out off the page,
|
|
/// so each tile draws the same parts (the fill's clip still trims them).
|
|
whole_tiles: bool,
|
|
}
|
|
|
|
impl<'a> Scene<'a> {
|
|
pub fn new(doc: &'a Document, region: Rect, clips: bool) -> Self {
|
|
Self {
|
|
doc,
|
|
symbols: doc.symbol_boxes().quick(),
|
|
region,
|
|
clips,
|
|
ops: vec![],
|
|
warnings: vec![],
|
|
images: HashMap::new(),
|
|
brushes: None,
|
|
opacity: 1.0,
|
|
nest: 0,
|
|
tiles_left: MAX_TILES,
|
|
whole_tiles: false,
|
|
}
|
|
}
|
|
|
|
/// The operations (clipped to the region when the format clips) and the warnings.
|
|
pub fn run(mut self) -> (Vec<Op>, Vec<String>) {
|
|
let doc = self.doc;
|
|
if self.clips {
|
|
self.ops.push(Op::Clip { path: self.region.to_path(0.1), rule: FillRule::NonZero });
|
|
}
|
|
for l in &doc.layers {
|
|
self.node(l, false);
|
|
}
|
|
if self.clips {
|
|
self.ops.push(Op::Unclip);
|
|
}
|
|
(self.ops, self.warnings)
|
|
}
|
|
|
|
fn warn(&mut self, w: &str) {
|
|
if !self.warnings.iter().any(|x| x == w) {
|
|
self.warnings.push(w.to_string());
|
|
}
|
|
}
|
|
|
|
fn node(&mut self, n: &Node, force: bool) {
|
|
if let NodeKind::Layer { template: true, .. } = n.kind {
|
|
return;
|
|
}
|
|
if !force && !n.visible {
|
|
return;
|
|
}
|
|
if !n.is_layer() && !self.whole_tiles {
|
|
match n.visual_bounds_with(&self.symbols) {
|
|
Some(b) if !overlaps(b, self.region) => return,
|
|
None if !n.is_container() => return,
|
|
_ => {}
|
|
}
|
|
}
|
|
self.note_losses(n);
|
|
let opacity = self.opacity;
|
|
self.opacity *= n.opacity.clamp(0.0, 1.0);
|
|
match &n.kind {
|
|
NodeKind::Layer { children, clip, .. } | NodeKind::Group { children, clip } => self.children(children, *clip),
|
|
NodeKind::Path { path, rule, guide, .. } => {
|
|
if !*guide {
|
|
self.shape(n, &path.to_bezpath(), *rule);
|
|
}
|
|
}
|
|
NodeKind::Compound { children, rule } => {
|
|
let mut bp = BezPath::new();
|
|
for p in children.iter().filter_map(|c| c.path_data()) {
|
|
bp.extend(p.to_bezpath());
|
|
}
|
|
self.shape(n, &bp, *rule);
|
|
}
|
|
NodeKind::Text(_) => {
|
|
if let Some(o) = vectorcraft_effects::outline_text(n) {
|
|
self.warn(TEXT_OUTLINES);
|
|
self.node(&o, true);
|
|
}
|
|
}
|
|
NodeKind::Image(im) => self.image(im),
|
|
NodeKind::SymbolInstance { symbol, xf } => {
|
|
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
|
|
&& self.nest < MAX_NEST
|
|
&& let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n)
|
|
&& let Some(art) =
|
|
if self.clips { self.doc.place_symbol_art(symbol, &art, *xf) } else { self.doc.place_symbol_art_unclipped(symbol, &art, *xf) }
|
|
{
|
|
self.nest += 1;
|
|
self.node(&art, true);
|
|
self.nest -= 1;
|
|
}
|
|
}
|
|
// Live blends, envelopes, meshes and repeats are written as their evaluated art.
|
|
NodeKind::Blend { .. }
|
|
| NodeKind::Envelope { .. }
|
|
| NodeKind::Mesh(_)
|
|
| NodeKind::Repeat(_)
|
|
| NodeKind::PlacedDocument(_)
|
|
| NodeKind::Intertwine { .. } => {
|
|
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
|
|
for c in g.children().into_iter().flatten() {
|
|
self.node(c, false);
|
|
}
|
|
}
|
|
}
|
|
self.opacity = opacity;
|
|
}
|
|
|
|
/// A container's children. A clipping container's first child clips the rest (its fill paints
|
|
/// below them, its stroke over them); without clipping they are written whole.
|
|
fn children(&mut self, children: &[Arc<Node>], clip: bool) {
|
|
let Some((first, rest)) = children.split_first().filter(|_| clip) else {
|
|
children.iter().for_each(|c| self.node(c, false));
|
|
return;
|
|
};
|
|
let paint = first.clip_paint();
|
|
let outline = if self.clips {
|
|
// Nothing to clip by hides the clipped art (as on the canvas).
|
|
let Some((path, rule)) = vectorcraft_effects::clip_outline(first) else { return };
|
|
Some((path, rule))
|
|
} else {
|
|
self.warn(CLIPPING);
|
|
None
|
|
};
|
|
let clipped = outline.is_some();
|
|
if let Some((path, rule)) = outline {
|
|
self.ops.push(Op::Clip { path, rule });
|
|
}
|
|
if let Some(f) = &paint.fill {
|
|
self.node(f, false);
|
|
}
|
|
rest.iter().for_each(|c| self.node(c, false));
|
|
if clipped {
|
|
self.ops.push(Op::Unclip);
|
|
}
|
|
if let Some(s) = &paint.stroke {
|
|
self.node(s, false);
|
|
}
|
|
}
|
|
|
|
/// Warn about what a metafile can't hold in `n`'s look.
|
|
fn note_losses(&mut self, n: &Node) {
|
|
if n.blend != BlendMode::Normal {
|
|
self.warn(BLENDING);
|
|
}
|
|
if n.extras().mask.as_ref().is_some_and(|m| !m.disabled) {
|
|
self.warn(MASKS);
|
|
}
|
|
let raster = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id));
|
|
if raster(&n.appearance.effects) || n.appearance.items.iter().any(|i| raster(i.effects())) {
|
|
self.warn(RASTER_FX);
|
|
}
|
|
}
|
|
|
|
/// Warn when a path paints at `opacity` (times the containers').
|
|
fn check_opacity(&mut self, opacity: f32) {
|
|
if self.opacity * opacity < OPAQUE {
|
|
self.warn(TRANSPARENCY);
|
|
}
|
|
}
|
|
|
|
/// A path's fills and strokes, in paint order.
|
|
fn shape(&mut self, n: &Node, bp: &BezPath, rule: FillRule) {
|
|
if bp.elements().is_empty() {
|
|
return;
|
|
}
|
|
let bounds = bp.bounding_box();
|
|
for item in &n.appearance.items {
|
|
match item {
|
|
AppearanceItem::Fill(f) if f.visible => {
|
|
if f.blend != BlendMode::Normal {
|
|
self.warn(BLENDING);
|
|
}
|
|
self.fill(bp, rule, &f.paint, f.opacity, bounds);
|
|
}
|
|
AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 && st.width.is_finite() => self.stroke(bp, rule, st, bounds),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fill `bp` with `paint` at `opacity`; `bounds` places a gradient that isn't placed.
|
|
fn fill(&mut self, bp: &BezPath, rule: FillRule, paint: &Paint, opacity: f32, bounds: Rect) {
|
|
match paint {
|
|
Paint::None => {}
|
|
Paint::Gradient(g) if self.clips => self.gradient(bp, rule, g, opacity, bounds),
|
|
Paint::Pattern { pattern, xf } if self.clips && self.nest < MAX_NEST && self.tiles_left > 0 && self.doc.pattern(pattern).is_some() => {
|
|
let area = bp.bounding_box().intersect(self.region);
|
|
let tiles = self.doc.pattern(pattern).map(|def| def.instances_in(*xf, area)).unwrap_or_default();
|
|
if tiles.len() > self.tiles_left {
|
|
self.tiles_left = 0;
|
|
self.warn(TOO_MANY_TILES);
|
|
self.flat_fill(bp, rule, paint, opacity);
|
|
return;
|
|
}
|
|
self.tiles_left -= tiles.len();
|
|
// Tiles that can't overlap one another (grid and brick tilings whose art stays in
|
|
// its tile) may be merged: see `merge_tiles`.
|
|
let per_tile = self.doc.pattern(pattern).filter(|def| tiles_apart(def)).map(|def| def.art.len());
|
|
let outer = self.opacity;
|
|
self.opacity *= opacity.clamp(0.0, 1.0);
|
|
self.ops.push(Op::Clip { path: bp.clone(), rule });
|
|
self.nest += 1;
|
|
let mut chunks = Vec::new();
|
|
let (whole, left) = (self.whole_tiles, self.tiles_left);
|
|
self.whole_tiles = per_tile.is_some();
|
|
for (i, t) in tiles.iter().enumerate() {
|
|
if per_tile.is_some_and(|k| k > 0 && i % k == 0) {
|
|
chunks.push(self.ops.len());
|
|
}
|
|
self.node(t, true);
|
|
}
|
|
self.whole_tiles = whole;
|
|
let merged = per_tile.is_some() && merge_tiles(&mut self.ops, &chunks);
|
|
// Tiles that couldn't merge are written as before, their parts off the page left out.
|
|
if per_tile.is_some() && !merged {
|
|
if let Some(&first) = chunks.first() {
|
|
self.ops.truncate(first);
|
|
}
|
|
// Patterns inside the tiles take from the budget once.
|
|
self.tiles_left = left;
|
|
tiles.iter().for_each(|t| self.node(t, true));
|
|
}
|
|
self.nest -= 1;
|
|
if !tiles.is_empty() {
|
|
self.warn(if merged { PATTERN_SHAPES } else { PATTERN_TILES });
|
|
}
|
|
self.ops.push(Op::Unclip);
|
|
self.opacity = outer;
|
|
}
|
|
p => self.flat_fill(bp, rule, p, opacity),
|
|
}
|
|
}
|
|
|
|
/// Fill `bp` with one colour for `paint` at `opacity`.
|
|
fn flat_fill(&mut self, bp: &BezPath, rule: FillRule, paint: &Paint, opacity: f32) {
|
|
let Some(rgb) = self.flat(paint) else { return };
|
|
self.check_opacity(opacity);
|
|
self.ops.push(Op::Fill { path: bp.clone(), rule, rgb });
|
|
}
|
|
|
|
/// One colour for a paint: a solid colour, the average of a gradient's stops, or a pattern's
|
|
/// first solid fill.
|
|
fn flat(&mut self, p: &Paint) -> Option<[u8; 3]> {
|
|
match p {
|
|
Paint::None => None,
|
|
Paint::Solid { color, .. } => Some(rgb_of(color)),
|
|
Paint::Gradient(g) => {
|
|
self.warn(FLAT_GRADIENTS);
|
|
let stops = &g.gradient.stops;
|
|
let n = stops.len().max(1) as f64;
|
|
let sum = stops.iter().map(|s| rgb_of(&s.color)).fold([0.0; 3], |a, c| [0, 1, 2].map(|i| a[i] + f64::from(c[i])));
|
|
Some(sum.map(|v| (v / n).round().clamp(0.0, 255.0) as u8))
|
|
}
|
|
Paint::Pattern { pattern, .. } => {
|
|
self.warn(FLAT_PATTERNS);
|
|
let first = self.doc.pattern(pattern).and_then(|def| def.art.iter().find_map(|a| first_solid(a)));
|
|
Some(first.map_or([128; 3], |c| rgb_of(&c)))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A gradient fill: an image of its colours over the shape's box, clipped to the shape,
|
|
/// sampled at the document's raster effects resolution.
|
|
fn gradient(&mut self, bp: &BezPath, rule: FillRule, g: &GradientPaint, opacity: f32, bounds: Rect) {
|
|
let area = bp.bounding_box().intersect(self.region);
|
|
let long = area.width().max(area.height());
|
|
if !(long > 1e-9 && long.is_finite()) {
|
|
return;
|
|
}
|
|
let k = (self.doc.raster_effects_ppi / 72.0).min(MAX_GRADIENT_PX / long);
|
|
let along = |len: f64| (len * k).ceil().clamp(1.0, MAX_GRADIENT_PX) as u16;
|
|
let (cols, rows) = (along(area.width()), along(area.height()));
|
|
let pixels: Vec<u8> = if g.gradient.kind == GradientKind::Freeform {
|
|
let Some(b) = painted_box(bounds) else { return };
|
|
let field = g.freeform_on(b).field_with(spread_scale(b), &|c| c.to_rgb());
|
|
field.grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect()
|
|
} else {
|
|
let geom = g.resolve(bounds);
|
|
let (cw, ch) = (area.width() / f64::from(cols), area.height() / f64::from(rows));
|
|
(0..usize::from(rows) * usize::from(cols))
|
|
.flat_map(|i| {
|
|
let (x, y) = (i % usize::from(cols), i / usize::from(cols));
|
|
let p = Point::new(area.x0 + (x as f64 + 0.5) * cw, area.y0 + (y as f64 + 0.5) * ch);
|
|
let t = geom.param_at(g.gradient.kind, p);
|
|
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
|
|
let (c, a) = g.gradient.sample(t as f32);
|
|
c.to_rgba8(a)
|
|
})
|
|
.collect()
|
|
};
|
|
let image = Rgba { width: u32::from(cols), height: u32::from(rows), pixels };
|
|
let xf =
|
|
Affine::translate(area.origin().to_vec2()) * Affine::scale_non_uniform(area.width() / f64::from(cols), area.height() / f64::from(rows));
|
|
self.ops.push(Op::Clip { path: bp.clone(), rule });
|
|
self.ops.push(Op::Image { image: Arc::new(image), xf, opacity: (self.opacity * opacity).clamp(0.0, 1.0) });
|
|
self.ops.push(Op::Unclip);
|
|
}
|
|
|
|
fn stroke(&mut self, bp: &BezPath, rule: FillRule, st: &StrokeLayer, bounds: Rect) {
|
|
if st.brush.is_some()
|
|
&& self.nest < MAX_NEST
|
|
&& let Some(art) = self.brush_art(bp, st)
|
|
{
|
|
let opacity = self.opacity;
|
|
self.opacity *= st.opacity.clamp(0.0, 1.0);
|
|
self.nest += 1;
|
|
art.iter().for_each(|piece| self.node(piece, true));
|
|
self.nest -= 1;
|
|
self.opacity = opacity;
|
|
return;
|
|
}
|
|
if st.blend != BlendMode::Normal {
|
|
self.warn(BLENDING);
|
|
}
|
|
// A pen paints one colour along the path itself: anything else is the stroke's outline,
|
|
// filled.
|
|
let flat_paint = matches!(st.paint, Paint::Solid { .. }) || !self.clips;
|
|
if !stroke::is_plain(st) || !flat_paint {
|
|
let region = stroke::outline_region(&PathData::from_bezpath(bp), rule, st).to_bezpath();
|
|
self.fill(®ion, FillRule::NonZero, &st.paint, st.opacity, st.paint_bounds(bounds));
|
|
return;
|
|
}
|
|
let Some(rgb) = self.flat(&st.paint) else { return };
|
|
self.check_opacity(st.opacity);
|
|
let pen = Pen { rgb, width: st.width, cap: st.cap, join: st.join, miter: st.miter_limit, dash: st.dash.clone().filter(|d| d.is_dashed()) };
|
|
self.ops.push(Op::Stroke { path: bp.clone(), pen });
|
|
}
|
|
|
|
fn brush_art(&mut self, bp: &BezPath, st: &StrokeLayer) -> Option<Vec<Node>> {
|
|
let doc = self.doc;
|
|
let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc));
|
|
let b = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name))?;
|
|
Some(vectorcraft_brush::stroke_pieces(doc, b, bp, st))
|
|
}
|
|
|
|
/// A placed image, decoded once per image.
|
|
fn image(&mut self, im: &ImageObject) {
|
|
let Some(img) = self.decoded(im) else { return };
|
|
let (dw, dh) = (f64::from(img.width), f64::from(img.height));
|
|
let xf = im.xf * Affine::scale_non_uniform(f64::from(im.width.max(1)) / dw, f64::from(im.height.max(1)) / dh);
|
|
self.ops.push(Op::Image { image: img, xf, opacity: self.opacity.clamp(0.0, 1.0) });
|
|
}
|
|
|
|
/// Image `im`'s pixels (scaled down to what a metafile stores), `None` with a warning when it
|
|
/// has none or can't be decoded.
|
|
fn decoded(&mut self, im: &ImageObject) -> Option<Arc<Rgba>> {
|
|
let key = im.key.as_str();
|
|
if let Some(i) = self.images.get(key) {
|
|
return i.clone();
|
|
}
|
|
let blob = self.doc.image_blob(im).filter(|b| !b.bytes.is_empty());
|
|
let img = blob
|
|
.and_then(|b| image::load_from_memory(&b.bytes).ok())
|
|
.map(|i| Rgba::fitted(i.to_rgba8()))
|
|
.filter(|i| i.width > 0 && i.height > 0)
|
|
.map(Arc::new);
|
|
if img.is_none() {
|
|
self.warn(UNDECODED);
|
|
}
|
|
self.images.insert(key.to_string(), img.clone());
|
|
img
|
|
}
|
|
}
|
|
|
|
/// Do the instances of `def` stay apart? A grid or brick tiling places its tiles side by side, so
|
|
/// art that stays in its tile never overlaps another instance's; hex tilings overlap their tiles.
|
|
fn tiles_apart(def: &vectorcraft_doc::PatternDef) -> bool {
|
|
use vectorcraft_doc::TileType;
|
|
let tiling = matches!(def.tile_type, TileType::Grid | TileType::BrickByRow { .. } | TileType::BrickByColumn { .. });
|
|
let inside = def.art.iter().all(|a| painted_bounds(a).is_some_and(|b| def.tile.inflate(INSIDE_TOL, INSIDE_TOL).contains_rect(b)));
|
|
tiling && !def.art.is_empty() && inside
|
|
}
|
|
|
|
/// The extent `n` paints: a clipping group's clip path, else its visual bounds.
|
|
fn painted_bounds(n: &Node) -> Option<Rect> {
|
|
match &n.kind {
|
|
NodeKind::Group { children, clip: true } => children.first().and_then(|c| c.path_data()).map(|p| p.to_bezpath().bounding_box()),
|
|
_ => n.visual_bounds(),
|
|
}
|
|
}
|
|
|
|
/// Write a pattern fill's tiles (`ops` from each start in `starts` to the next, the last to the
|
|
/// end) with fewer shapes, when every tile drew the same plain fills: each tile's clip is left out
|
|
/// where what it clips lies inside it already, then the n-th fill of every tile becomes one path.
|
|
/// The tiles don't overlap ([`tiles_apart`]), so the picture is the same. → whether they merged.
|
|
fn merge_tiles(ops: &mut Vec<Op>, starts: &[usize]) -> bool {
|
|
let Some(&first) = starts.first() else { return false };
|
|
let ends = starts.iter().skip(1).copied().chain([ops.len()]);
|
|
let mut tiles: Vec<Vec<(BezPath, FillRule, [u8; 3])>> = Vec::with_capacity(starts.len());
|
|
for (start, end) in starts.iter().copied().zip(ends) {
|
|
let Some(fills) = ops.get(start..end).and_then(plain_fills) else { return false };
|
|
// A tile off the page draws nothing.
|
|
if !fills.is_empty() {
|
|
tiles.push(fills);
|
|
}
|
|
}
|
|
let Some((shape, rest)) = tiles.split_first() else { return false };
|
|
let same = |t: &Vec<(BezPath, FillRule, [u8; 3])>| t.len() == shape.len() && t.iter().zip(shape).all(|(a, b)| (a.1, a.2) == (b.1, b.2));
|
|
if shape.is_empty() || !rest.iter().all(same) {
|
|
return false;
|
|
}
|
|
let mut merged: Vec<Op> = shape.iter().map(|(_, rule, rgb)| Op::Fill { path: BezPath::new(), rule: *rule, rgb: *rgb }).collect();
|
|
for tile in &tiles {
|
|
for (op, (path, _, _)) in merged.iter_mut().zip(tile) {
|
|
if let Op::Fill { path: all, .. } = op {
|
|
all.extend(path.elements().iter().copied());
|
|
}
|
|
}
|
|
}
|
|
ops.truncate(first);
|
|
ops.extend(merged);
|
|
true
|
|
}
|
|
|
|
/// A tile's ops as plain fills: fills, or fills inside one clip that lies around them all
|
|
/// already (a tile clipped to its cell). `None` for anything else (strokes, images, clips that cut).
|
|
fn plain_fills(ops: &[Op]) -> Option<Vec<(BezPath, FillRule, [u8; 3])>> {
|
|
let inner = match ops {
|
|
[Op::Clip { path, .. }, inner @ .., Op::Unclip] => {
|
|
let hull = convex_polygon(path)?;
|
|
inner
|
|
.iter()
|
|
.all(|op| matches!(op, Op::Fill { path, .. } if path.elements().iter().all(|el| el_points(el).all(|p| inside(&hull, p)))))
|
|
.then_some(inner)?
|
|
}
|
|
all => all,
|
|
};
|
|
inner
|
|
.iter()
|
|
.map(|op| match op {
|
|
Op::Fill { path, rule, rgb } => Some((path.clone(), *rule, *rgb)),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// The points of a path element (a curve lies inside its control points' hull).
|
|
fn el_points(el: &PathEl) -> impl Iterator<Item = Point> {
|
|
let pts: Vec<Point> = match *el {
|
|
PathEl::MoveTo(p) | PathEl::LineTo(p) => vec![p],
|
|
PathEl::QuadTo(a, b) => vec![a, b],
|
|
PathEl::CurveTo(a, b, c) => vec![a, b, c],
|
|
PathEl::ClosePath => vec![],
|
|
};
|
|
pts.into_iter()
|
|
}
|
|
|
|
/// `bp` as the corners of a convex polygon, counter-clockwise: one closed figure of straight
|
|
/// segments that turns one way. `None` for any other path.
|
|
fn convex_polygon(bp: &BezPath) -> Option<Vec<Point>> {
|
|
let mut pts = Vec::new();
|
|
for (i, el) in bp.elements().iter().enumerate() {
|
|
match (i, el) {
|
|
(0, PathEl::MoveTo(p)) | (1.., PathEl::LineTo(p)) => pts.push(*p),
|
|
(1.., PathEl::ClosePath) if i + 1 == bp.elements().len() => {}
|
|
_ => return None,
|
|
}
|
|
}
|
|
if pts.len() > 1 && pts.first().zip(pts.last()).is_some_and(|(a, b)| (*a - *b).hypot() <= INSIDE_TOL) {
|
|
pts.pop();
|
|
}
|
|
if pts.len() < 3 {
|
|
return None;
|
|
}
|
|
let n = pts.len();
|
|
let turn = |i: usize| {
|
|
let (a, b, c) = (pts[i], pts[(i + 1) % n], pts[(i + 2) % n]);
|
|
(b - a).cross(c - b)
|
|
};
|
|
let turns: Vec<f64> = (0..n).map(turn).collect();
|
|
if turns.iter().any(|t| !t.is_finite()) {
|
|
return None;
|
|
}
|
|
if turns.iter().all(|t| *t >= -INSIDE_TOL) && turns.iter().any(|t| *t > INSIDE_TOL) {
|
|
Some(pts)
|
|
} else if turns.iter().all(|t| *t <= INSIDE_TOL) && turns.iter().any(|t| *t < -INSIDE_TOL) {
|
|
pts.reverse();
|
|
Some(pts)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Is `p` inside (or on) the counter-clockwise convex polygon `hull`?
|
|
fn inside(hull: &[Point], p: Point) -> bool {
|
|
let n = hull.len();
|
|
(0..n).all(|i| {
|
|
let (a, b) = (hull[i], hull[(i + 1) % n]);
|
|
let edge = b - a;
|
|
let len = edge.hypot();
|
|
len > 0.0 && edge.cross(p - a) / len >= -INSIDE_TOL
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod merge_tests {
|
|
use super::*;
|
|
|
|
fn poly(pts: &[(f64, f64)]) -> BezPath {
|
|
let mut bp = BezPath::new();
|
|
for (i, (x, y)) in pts.iter().enumerate() {
|
|
if i == 0 { bp.move_to((*x, *y)) } else { bp.line_to((*x, *y)) }
|
|
}
|
|
bp.close_path();
|
|
bp
|
|
}
|
|
|
|
#[test]
|
|
fn convex_polygons_in_either_direction_hold_what_is_inside_them() {
|
|
let square = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];
|
|
let reversed: Vec<(f64, f64)> = square.iter().rev().copied().collect();
|
|
for pts in [&square[..], &reversed[..]] {
|
|
let hull = convex_polygon(&poly(pts)).unwrap();
|
|
assert!(inside(&hull, Point::new(5.0, 5.0)));
|
|
assert!(inside(&hull, Point::new(10.0, 3.0)), "on an edge");
|
|
assert!(!inside(&hull, Point::new(10.5, 3.0)));
|
|
}
|
|
// A rotated square too.
|
|
let diamond = convex_polygon(&poly(&[(5.0, 0.0), (10.0, 5.0), (5.0, 10.0), (0.0, 5.0)])).unwrap();
|
|
assert!(inside(&diamond, Point::new(5.0, 5.0)) && !inside(&diamond, Point::new(1.0, 1.0)));
|
|
}
|
|
|
|
#[test]
|
|
fn concave_curved_and_open_clips_are_not_convex_polygons() {
|
|
assert!(convex_polygon(&poly(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (5.0, 5.0), (0.0, 10.0)])).is_none(), "concave");
|
|
let mut curved = BezPath::new();
|
|
curved.move_to((0.0, 0.0));
|
|
curved.curve_to((5.0, -5.0), (10.0, -5.0), (10.0, 0.0));
|
|
curved.line_to((5.0, 10.0));
|
|
curved.close_path();
|
|
assert!(convex_polygon(&curved).is_none(), "curved");
|
|
let mut two = poly(&[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)]);
|
|
two.extend(poly(&[(5.0, 5.0), (6.0, 5.0), (6.0, 6.0)]));
|
|
assert!(convex_polygon(&two).is_none(), "two figures");
|
|
assert!(convex_polygon(&poly(&[(0.0, 0.0), (1.0, 1.0), (2.0, 2.0)])).is_none(), "a line");
|
|
}
|
|
|
|
/// A tile whose fill reaches past its clip keeps the clip, so the tiles aren't merged.
|
|
#[test]
|
|
fn a_clip_that_cuts_its_fill_is_kept() {
|
|
let clip = Op::Clip { path: poly(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]), rule: FillRule::NonZero };
|
|
let past = Op::Fill { path: poly(&[(0.0, 0.0), (12.0, 0.0), (12.0, 10.0), (0.0, 10.0)]), rule: FillRule::NonZero, rgb: [255, 0, 0] };
|
|
assert!(plain_fills(&[clip.clone(), past, Op::Unclip]).is_none());
|
|
let within = Op::Fill { path: poly(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]), rule: FillRule::NonZero, rgb: [255, 0, 0] };
|
|
assert_eq!(plain_fills(&[clip, within, Op::Unclip]).map(|f| f.len()), Some(1));
|
|
}
|
|
}
|