M4: text on a path and pattern fills (#118)

Text layers can follow a path (start offset, flip, alignment; edited in place; canvas, PNG, SVG, PDF; SVG textPath imports as real text on a path). A new pattern paint tiles a source layer in grid or hex rows with scale, spacing and alignment, live as the source changes (SVG pattern, PDF vector tiling pattern).
This commit is contained in:
BFlat
2026-10-11 13:25:33 +07:00
committed by GitHub
parent 09600d6da2
commit f62cb4b1f6
43 changed files with 1266 additions and 193 deletions
+66
View File
@@ -0,0 +1,66 @@
//! Pattern fills: the source layer drawn into one repeating cell at the resolution it shows at,
//! repeated by a tiling shader.
use std::cell::Cell;
use std::sync::Arc;
use figcraft_color::{PatternCell, PatternPaint};
use figcraft_doc::{Document, NodeId};
use figcraft_geom::Affine;
use tiny_skia::{FilterQuality, Pixmap, SpreadMode, Transform};
use crate::draw::{Ctx, draw_node};
use crate::paint::Prepared;
/// The largest side of a cell's pixmap; a cell showing larger is drawn coarser.
const MAX_CELL_PX: f64 = 2048.0;
/// How deep patterns nest (a source filled with a pattern); deeper ones, and a source filled
/// with its own pattern, draw nothing.
pub const MAX_DEPTH: u32 = 2;
thread_local! {
static DEPTH: Cell<u32> = const { Cell::new(0) };
}
/// Run `f` one pattern level deeper; `None` past [`MAX_DEPTH`].
pub fn nested<T>(f: impl FnOnce() -> Option<T>) -> Option<T> {
let d = DEPTH.with(Cell::get);
if d >= MAX_DEPTH {
return None;
}
DEPTH.with(|c| c.set(d + 1));
let out = f();
DEPTH.with(|c| c.set(d));
out
}
/// Pattern `pt` on a `w`×`h` box drawn into one cell at about `scale` device pixels per unit and
/// `time` seconds into animated GIFs:
/// the pixmap, the cell and the pixmap's pixels per unit on each axis (whole pixels per cell, so
/// the repeat lines up exactly).
pub fn cell_pixmap(doc: &Document, pt: &PatternPaint, w: f64, h: f64, scale: f64, time: f64) -> Option<(Pixmap, PatternCell, (f64, f64))> {
let src = doc.node(NodeId(pt.source)).filter(|n| !n.is_page())?;
let cell = pt.cell(src.width, src.height, w, h)?;
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
let k = scale.min(MAX_CELL_PX / cell.size.0.max(cell.size.1));
let (pw, ph) = ((cell.size.0 * k).ceil().clamp(1.0, MAX_CELL_PX), (cell.size.1 * k).ceil().clamp(1.0, MAX_CELL_PX));
let (kx, ky) = (pw / cell.size.0, ph / cell.size.1);
let mut pix = Pixmap::new(pw as u32, ph as u32)?;
let ctx = Ctx { doc, scale: kx.max(ky) * cell.scale, outline_mode: false, outlined: &[], skip: &[], lod: false, under: None, time };
let unrotated = src.transform.inverse();
nested(|| {
for (tx, ty) in &cell.tiles {
let xf = Affine::scale_non_uniform(kx, ky) * Affine::translate((*tx, *ty)) * Affine::scale(cell.scale) * unrotated;
draw_node(&ctx, &mut pix, src.id, xf, 0);
}
Some(())
})?;
Some((pix, cell, (kx, ky)))
}
/// The shader for pattern `pt` on a `w`×`h` box shown at `scale` device pixels per unit.
pub fn prepare(doc: &Document, pt: &PatternPaint, w: f64, h: f64, scale: f64, time: f64, opacity: f32) -> Option<Prepared> {
let (pix, cell, (kx, ky)) = cell_pixmap(doc, pt, w, h, scale, time)?;
let transform = Transform::from_row((1.0 / kx) as f32, 0.0, 0.0, (1.0 / ky) as f32, cell.origin.0 as f32, cell.origin.1 as f32);
Some(Prepared::Pattern { pixmap: Arc::new(pix), mode: SpreadMode::Repeat, quality: FilterQuality::Bilinear, opacity, transform })
}