mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 16:33:22 -06:00
continued work; wip
This commit is contained in:
@@ -10,6 +10,8 @@ mod brush_fx;
|
||||
mod fx;
|
||||
mod live;
|
||||
mod paint;
|
||||
pub mod proof;
|
||||
mod pattern;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -22,6 +24,7 @@ use vello_cpu::{Pixmap, RenderContext, Resources};
|
||||
|
||||
pub use drawcraft_effects as effects;
|
||||
pub use live::expand_live;
|
||||
pub use pattern::render_pattern_swatch;
|
||||
pub use vello_cpu;
|
||||
|
||||
/// Rendering options.
|
||||
@@ -37,11 +40,15 @@ pub struct RenderOptions {
|
||||
pub hidden: Vec<NodeId>,
|
||||
/// Draw template layers dimmed (50%).
|
||||
pub dim_templates: bool,
|
||||
/// Soft proof / separations preview (see [`proof`]).
|
||||
pub proof: Option<proof::ProofSetup>,
|
||||
/// Overprint Preview (see [`proof`] for what overprints).
|
||||
pub overprint_preview: bool,
|
||||
}
|
||||
|
||||
impl Default for RenderOptions {
|
||||
fn default() -> Self {
|
||||
Self { outline: false, background: None, artboards: false, hidden: vec![], dim_templates: true }
|
||||
Self { outline: false, background: None, artboards: false, hidden: vec![], dim_templates: true, proof: None, overprint_preview: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,6 +192,8 @@ impl Renderer {
|
||||
/// Render `doc` into a `width`×`height` image using `view` (document → pixel transform).
|
||||
pub fn render(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions) -> Rendered {
|
||||
let start = now();
|
||||
let prepared = proof::prepare(doc, opts);
|
||||
let doc: &Document = &prepared;
|
||||
let w = width.clamp(1, u16::MAX as u32) as u16;
|
||||
let h = height.clamp(1, u16::MAX as u32) as u16;
|
||||
// Raster filters (drop shadow, glows, blur) need the single-threaded pipeline.
|
||||
@@ -222,8 +231,10 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
self.stamp += 1;
|
||||
for layer in &doc.layers {
|
||||
self.draw_arc(&mut ctx, &frame, layer);
|
||||
if !self.draw_pattern_edit(&mut ctx, &frame) {
|
||||
for layer in &doc.layers {
|
||||
self.draw_arc(&mut ctx, &frame, layer);
|
||||
}
|
||||
}
|
||||
// Drop cache entries not seen for a few frames.
|
||||
let g = self.stamp;
|
||||
@@ -238,8 +249,10 @@ impl Renderer {
|
||||
} else {
|
||||
self.ctx = Some(ctx);
|
||||
}
|
||||
let mut pixels = pm.data_as_u8_slice().to_vec();
|
||||
proof::post(&mut pixels, opts);
|
||||
self.stats.micros = now().saturating_sub(start);
|
||||
Rendered { width: w as u32, height: h as u32, pixels: pm.data_as_u8_slice().to_vec() }
|
||||
Rendered { width: w as u32, height: h as u32, pixels }
|
||||
}
|
||||
|
||||
/// Render one artboard (or any document rect) at `scale` pixels per point, transparent or on white.
|
||||
@@ -433,7 +446,7 @@ impl Renderer {
|
||||
self.draw_node(ctx, f, &art, true);
|
||||
}
|
||||
}
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) => self.draw_live_node(ctx, f, n),
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => self.draw_live_node(ctx, f, n),
|
||||
}
|
||||
self.stats.drawn += 1;
|
||||
for _ in 0..layers {
|
||||
@@ -686,14 +699,26 @@ fn painted_items(n: &Node) -> usize {
|
||||
.max(if n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.visible && !matches!(f.paint, drawcraft_color::Paint::Solid { .. } | drawcraft_color::Paint::None)) || matches!(i, AppearanceItem::Stroke(s) if s.visible && !matches!(s.paint, drawcraft_color::Paint::Solid { .. } | drawcraft_color::Paint::None))) { 2 } else { 0 })
|
||||
}
|
||||
|
||||
/// Preferred rasterizer thread count set by the app (Preferences → Performance); negative = automatic.
|
||||
static THREADS_OVERRIDE: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1);
|
||||
|
||||
/// Override the worker-thread count used by renderers created from now on (`None` = automatic).
|
||||
pub fn set_default_threads(n: Option<u16>) {
|
||||
THREADS_OVERRIDE.store(n.map_or(-1, i32::from), std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Rasterizer worker threads: 0 on wasm; otherwise up to 4 (vello's sweet spot), leaving a core free.
|
||||
fn default_threads() -> u16 {
|
||||
pub fn default_threads() -> u16 {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
0
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let o = THREADS_OVERRIDE.load(std::sync::atomic::Ordering::Relaxed);
|
||||
if o >= 0 {
|
||||
return o.min(64) as u16;
|
||||
}
|
||||
if std::env::var_os("DRAWCRAFT_RENDER_THREADS").is_some() {
|
||||
return std::env::var("DRAWCRAFT_RENDER_THREADS").ok().and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ impl Renderer {
|
||||
pub(crate) fn draw_live_body(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>, cache: bool) {
|
||||
match &a.kind {
|
||||
NodeKind::Mesh(m) => self.draw_mesh(ctx, f, a, m, cache),
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } => {
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) => {
|
||||
let items = self.live_expanded(a, cache);
|
||||
for c in items.iter() {
|
||||
self.draw_arc(ctx, f, c);
|
||||
|
||||
@@ -13,7 +13,7 @@ fn color(c: &drawcraft_color::Color, alpha: f32) -> peniko::Color {
|
||||
}
|
||||
|
||||
/// Set the context paint. Returns false if nothing should be drawn.
|
||||
pub fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, _doc: &Document) -> bool {
|
||||
pub fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, doc: &Document) -> bool {
|
||||
match p {
|
||||
Paint::None => false,
|
||||
Paint::Solid { color: c, .. } => {
|
||||
@@ -50,11 +50,7 @@ pub fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, _doc: &Docume
|
||||
ctx.set_paint(grad);
|
||||
true
|
||||
}
|
||||
Paint::Pattern { .. } => {
|
||||
// Patterns render as a neutral mid-grey until pattern swatches land (M10.5).
|
||||
ctx.set_paint(peniko::Color::from_rgba8(128, 128, 128, 255));
|
||||
true
|
||||
}
|
||||
Paint::Pattern { pattern, xf } => crate::pattern::set_pattern_paint(ctx, pattern, *xf, doc),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
//! Pattern fills and the pattern-editing preview.
|
||||
//!
|
||||
//! A pattern paint is drawn as a repeating image: the tiling's rectangular super-tile
|
||||
//! ([`PatternDef::period`]) is rasterized once at the current device scale (rounded to a power of
|
||||
//! two, so it is re-rasterized only when the zoom changes by more than 2×) and used as an image
|
||||
//! paint with `Extend::Repeat`, with the paint transform mapping pixels → pattern space →
|
||||
//! document. Tiles are cached per pattern name and invalidated when the definition changes (art
|
||||
//! `Arc` identity, tile, tile type, overlap) — the pattern's "revision".
|
||||
//!
|
||||
//! Pattern editing mode (`Document::pattern_edit`) draws only the temporary tile layer, with
|
||||
//! dimmed copies of it at the neighbouring tile positions and the tile edge.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use drawcraft_doc::pattern::{Overlap, PatternDef, TileType};
|
||||
use drawcraft_doc::{Document, Node};
|
||||
use drawcraft_geom::{Affine, Rect, Shape};
|
||||
use vello_cpu::peniko::{self, Extend, ImageQuality, ImageSampler};
|
||||
use vello_cpu::{Pixmap, RenderContext};
|
||||
|
||||
use crate::{Frame, RenderOptions, Rendered, Renderer};
|
||||
|
||||
/// Largest super-tile raster side (pixels).
|
||||
const MAX_TILE_PX: f64 = 2048.0;
|
||||
|
||||
struct TileEntry {
|
||||
art: Vec<Arc<Node>>,
|
||||
params: (Rect, TileType, Overlap),
|
||||
scale: f64,
|
||||
sx: f64,
|
||||
sy: f64,
|
||||
pixmap: Arc<Pixmap>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PatternCache {
|
||||
tiles: HashMap<String, TileEntry>,
|
||||
renderer: Option<Box<Renderer>>,
|
||||
depth: u32,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static CACHE: RefCell<PatternCache> = RefCell::new(PatternCache::default());
|
||||
}
|
||||
|
||||
fn same_art(a: &[Arc<Node>], b: &[Arc<Node>]) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| Arc::ptr_eq(x, y))
|
||||
}
|
||||
|
||||
/// Power-of-two raster scale for a pattern seen at device scale `s`, capped so the super-tile
|
||||
/// stays under [`MAX_TILE_PX`].
|
||||
pub(crate) fn raster_scale(s: f64, period: (f64, f64)) -> f64 {
|
||||
let s = s.clamp(1e-3, 1e3);
|
||||
let p2 = 2f64.powf(s.log2().ceil());
|
||||
let cap = MAX_TILE_PX / period.0.max(period.1).max(1e-6);
|
||||
p2.min(cap).max(1e-3)
|
||||
}
|
||||
|
||||
/// Rasterize `region` (pattern space) of the infinite tiling into a `w`×`h` pixmap.
|
||||
fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w: u16, h: u16) -> Pixmap {
|
||||
let (w, h) = (w.max(1), h.max(1));
|
||||
let mut ctx = RenderContext::new(w, h);
|
||||
let view = Affine::scale_non_uniform(w as f64 / region.width().max(1e-9), h as f64 / region.height().max(1e-9))
|
||||
* Affine::translate(-region.origin().to_vec2());
|
||||
let opts = RenderOptions::default();
|
||||
r.stamp += 1;
|
||||
let px_rect = Rect::new(0.0, 0.0, w as f64, h as f64);
|
||||
for o in def.offsets_covering(region) {
|
||||
let v = view * def.instance_xf(o);
|
||||
let frame = Frame { doc, view: v, visible: v.inverse().transform_rect_bbox(px_rect), px: 1.0 / v.determinant().abs().sqrt().max(1e-12), opts: &opts };
|
||||
for a in &def.art {
|
||||
r.draw_arc(&mut ctx, &frame, a);
|
||||
}
|
||||
}
|
||||
ctx.flush();
|
||||
let mut pm = Pixmap::new(w, h);
|
||||
ctx.render(&mut pm, &mut r.resources);
|
||||
pm
|
||||
}
|
||||
|
||||
/// Run `f` with the shared off-screen renderer (None when nested too deep: a pattern whose art
|
||||
/// uses a pattern whose art uses a pattern…).
|
||||
fn with_tile_renderer<T>(f: impl FnOnce(&mut Renderer) -> T) -> Option<T> {
|
||||
let r = CACHE.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
if c.depth >= 3 {
|
||||
return None;
|
||||
}
|
||||
c.depth += 1;
|
||||
Some(c.renderer.take().unwrap_or_else(|| {
|
||||
let mut r = Renderer::new();
|
||||
r.threads = 0;
|
||||
Box::new(r)
|
||||
}))
|
||||
})?;
|
||||
let mut r = r;
|
||||
let out = f(&mut r);
|
||||
CACHE.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
c.depth -= 1;
|
||||
if c.renderer.is_none() {
|
||||
c.renderer = Some(r);
|
||||
}
|
||||
});
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Set a pattern paint on `ctx` (its current transform maps the paint's user space to pixels).
|
||||
pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine, doc: &Document) -> bool {
|
||||
let Some(def) = doc.pattern(name) else {
|
||||
ctx.set_paint(peniko::Color::from_rgba8(128, 128, 128, 255));
|
||||
return true;
|
||||
};
|
||||
if def.art.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let dev = *ctx.transform() * xf;
|
||||
let s = dev.determinant().abs().sqrt();
|
||||
let (pw, ph) = def.period();
|
||||
let scale = raster_scale(s, (pw, ph));
|
||||
let params = (def.tile, def.tile_type, def.overlap);
|
||||
let hit = CACHE.with(|c| {
|
||||
c.borrow().tiles.get(name).filter(|e| e.scale == scale && e.params == params && same_art(&e.art, &def.art)).map(|e| (e.pixmap.clone(), e.sx, e.sy))
|
||||
});
|
||||
let (pm, sx, sy) = match hit {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
let w = (pw * scale).ceil().clamp(1.0, 4096.0) as u16;
|
||||
let h = (ph * scale).ceil().clamp(1.0, 4096.0) as u16;
|
||||
let Some(pm) = with_tile_renderer(|r| rasterize(r, doc, def, Rect::new(0.0, 0.0, pw, ph), w, h)) else {
|
||||
ctx.set_paint(peniko::Color::from_rgba8(128, 128, 128, 255));
|
||||
return true;
|
||||
};
|
||||
let pm = Arc::new(pm);
|
||||
let (sx, sy) = (w as f64 / pw, h as f64 / ph);
|
||||
CACHE.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
if c.tiles.len() > 64 {
|
||||
c.tiles.clear();
|
||||
}
|
||||
c.tiles.insert(name.to_string(), TileEntry { art: def.art.clone(), params, scale, sx, sy, pixmap: pm.clone() });
|
||||
});
|
||||
(pm, sx, sy)
|
||||
}
|
||||
};
|
||||
let sampler = ImageSampler { x_extend: Extend::Repeat, y_extend: Extend::Repeat, quality: ImageQuality::Medium, alpha: 1.0 };
|
||||
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler });
|
||||
ctx.set_paint_transform(xf * Affine::scale_non_uniform(1.0 / sx, 1.0 / sy));
|
||||
true
|
||||
}
|
||||
|
||||
/// A swatch thumbnail of pattern `name`: the tiling around the tile, fitted into `size`².
|
||||
pub fn render_pattern_swatch(doc: &Document, name: &str, size: u32) -> Option<Rendered> {
|
||||
let def = doc.pattern(name)?;
|
||||
let (w, h) = (def.width(), def.height());
|
||||
let side = w.max(h);
|
||||
let region = Rect::from_center_size((w / 2.0, h / 2.0), (side, side));
|
||||
let px = size.clamp(1, 1024) as u16;
|
||||
let pm = with_tile_renderer(|r| rasterize(r, doc, def, region, px, px))?;
|
||||
Some(Rendered { width: px as u32, height: px as u32, pixels: pm.data_as_u8_slice().to_vec() })
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
/// Pattern editing mode: draw the tile layer, its dimmed copies and the tile edge instead of
|
||||
/// the document. Returns false when not in pattern editing mode.
|
||||
pub(crate) fn draw_pattern_edit(&mut self, ctx: &mut RenderContext, f: &Frame) -> bool {
|
||||
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();
|
||||
if layer.visible && !children.is_empty() {
|
||||
let dim = (def.dim_copies / 100.0).clamp(0.0, 1.0);
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, None, Some(dim), None, None);
|
||||
for o in def.preview_offsets() {
|
||||
let view = f.view * Affine::translate(o);
|
||||
let visible = f.visible - o;
|
||||
let frame = Frame { doc: f.doc, view, visible, px: f.px, opts: f.opts };
|
||||
for c in &children {
|
||||
self.draw_arc(ctx, &frame, c);
|
||||
}
|
||||
}
|
||||
ctx.pop_layer();
|
||||
}
|
||||
self.draw_arc(ctx, f, layer);
|
||||
if def.show_tile_edge {
|
||||
let bp = def.tile.to_path(0.1);
|
||||
self.hairline(ctx, f, &bp, [0x2d, 0x8c, 0xeb, 255]);
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use drawcraft_color::{Color, Paint};
|
||||
use drawcraft_doc::pattern::{PatternEdit, RepeatSpec, pattern_paint};
|
||||
use drawcraft_doc::{Appearance, NodeId, NodeKind};
|
||||
use drawcraft_geom::shapes;
|
||||
|
||||
/// A 20×20 tile with a red 10×10 square in its top-left corner.
|
||||
fn doc_with_pattern(tt: TileType) -> Document {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let red = Node::path(NodeId(900), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0));
|
||||
let mut def = PatternDef::new("Checks", vec![Arc::new(red)]);
|
||||
def.tile = Rect::new(0.0, 0.0, 20.0, 20.0);
|
||||
def.tile_type = tt;
|
||||
d.patterns.push(def);
|
||||
let id = d.alloc_id();
|
||||
let n = Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0));
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn render(d: &Document, scale: f64) -> Rendered {
|
||||
let px = (200.0 * scale) as u32;
|
||||
Renderer::new().render(d, px, px, Affine::scale(scale), &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
|
||||
}
|
||||
|
||||
fn is_red(p: [u8; 4]) -> bool {
|
||||
p[0] > 200 && p[1] < 60 && p[2] < 60
|
||||
}
|
||||
fn is_white(p: [u8; 4]) -> bool {
|
||||
p[0] > 200 && p[1] > 200 && p[2] > 200
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_fill_is_periodic() {
|
||||
let d = doc_with_pattern(TileType::Grid);
|
||||
let r = render(&d, 1.0);
|
||||
for (x, y) in [(5, 5), (25, 5), (125, 145), (185, 65)] {
|
||||
assert!(is_red(r.pixel(x, y)), "({x},{y}) {:?}", r.pixel(x, y));
|
||||
}
|
||||
for (x, y) in [(15, 5), (5, 15), (135, 135), (175, 195)] {
|
||||
assert!(is_white(r.pixel(x, y)), "({x},{y}) {:?}", r.pixel(x, y));
|
||||
}
|
||||
// Periodic: pixel (x, y) equals (x + 20k, y + 20m).
|
||||
for y in (1..20).step_by(3) {
|
||||
for x in (1..20).step_by(3) {
|
||||
if x == 10 || y == 10 {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(r.pixel(x, y), r.pixel(x + 60, y + 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_fill_is_clipped_to_shape() {
|
||||
let mut d = doc_with_pattern(TileType::Grid);
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
*d.node_mut(id).unwrap().path_data_mut().unwrap() = shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0));
|
||||
let r = render(&d, 1.0);
|
||||
assert!(is_red(r.pixel(45, 45)));
|
||||
assert!(is_white(r.pixel(65, 65)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brick_pattern_shifts_rows() {
|
||||
let d = doc_with_pattern(TileType::BrickByRow { offset: 0.5 });
|
||||
let r = render(&d, 1.0);
|
||||
assert!(is_red(r.pixel(5, 5)));
|
||||
// Row 1 is shifted by half a tile.
|
||||
assert!(is_white(r.pixel(5, 25)) && is_red(r.pixel(15, 25)), "{:?} {:?}", r.pixel(5, 25), r.pixel(15, 25));
|
||||
assert!(is_red(r.pixel(5, 45)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_follows_zoom_and_paint_transform() {
|
||||
let d = doc_with_pattern(TileType::Grid);
|
||||
let r = render(&d, 2.0);
|
||||
assert!(is_red(r.pixel(10, 10)) && is_white(r.pixel(30, 10)) && is_red(r.pixel(50, 10)));
|
||||
let mut d2 = d.clone();
|
||||
let id = d2.layers[0].children().unwrap()[0].id;
|
||||
drawcraft_doc::pattern::transform_pattern_paints(d2.node_mut(id).unwrap(), Affine::translate((10.0, 0.0)));
|
||||
let r = render(&d2, 1.0);
|
||||
assert!(is_white(r.pixel(5, 5)) && is_red(r.pixel(15, 5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_pattern_is_grey() {
|
||||
let mut d = doc_with_pattern(TileType::Grid);
|
||||
d.patterns.clear();
|
||||
let p = render(&d, 1.0).pixel(50, 50);
|
||||
assert_eq!(p, [128, 128, 128, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swatch_thumbnail_renders() {
|
||||
let d = doc_with_pattern(TileType::Grid);
|
||||
let r = render_pattern_swatch(&d, "Checks", 20).unwrap();
|
||||
assert!(is_red(r.pixel(3, 3)));
|
||||
assert_eq!(r.pixel(15, 15)[3], 0);
|
||||
assert!(render_pattern_swatch(&d, "Nope", 20).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_mode_draws_only_tile_layer_with_copies() {
|
||||
let mut d = doc_with_pattern(TileType::Grid);
|
||||
let l = d.add_layer(Some("Pattern Editing Mode"));
|
||||
let art = d.patterns[0].art[0].clone();
|
||||
d.insert(Some(l), 0, (*art).clone()).unwrap();
|
||||
d.pattern_edit = Some(PatternEdit { pattern: "Checks".into(), layer: l, original: None });
|
||||
d.patterns[0].dim_copies = 50.0;
|
||||
d.patterns[0].show_tile_edge = false;
|
||||
let r = render(&d, 1.0);
|
||||
// The tile itself at full strength, copies dimmed, the filled document rect hidden.
|
||||
assert!(is_red(r.pixel(5, 5)));
|
||||
let c = r.pixel(25, 5);
|
||||
assert!(c[0] > 200 && c[1] > 100 && c[1] < 160, "{c:?}");
|
||||
assert!(is_white(r.pixel(15, 5)));
|
||||
assert!(is_white(r.pixel(125, 125)), "outside the copies");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeat_renders_instances() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let sq = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
|
||||
let id = d.alloc_id();
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, Node::new(id, NodeKind::Repeat(RepeatSpec::grid(vec![Arc::new(sq)], 10.0, 10.0)))).unwrap();
|
||||
let r = render(&d, 1.0);
|
||||
for (x, y) in [(5, 5), (25, 5), (45, 45)] {
|
||||
assert_eq!(r.pixel(x, y), [0, 0, 0, 255], "({x},{y})");
|
||||
}
|
||||
assert!(is_white(r.pixel(15, 5)) && is_white(r.pixel(65, 5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raster_scale_is_power_of_two_and_capped() {
|
||||
assert_eq!(raster_scale(1.0, (10.0, 10.0)), 1.0);
|
||||
assert_eq!(raster_scale(1.5, (10.0, 10.0)), 2.0);
|
||||
assert_eq!(raster_scale(3.0, (10.0, 10.0)), 4.0);
|
||||
// A change within 2× keeps the raster.
|
||||
assert_eq!(raster_scale(2.1, (10.0, 10.0)), raster_scale(3.9, (10.0, 10.0)));
|
||||
assert!(raster_scale(64.0, (1000.0, 10.0)) * 1000.0 <= MAX_TILE_PX);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
//! Soft proofing (View → Proof Colors), Overprint Preview and Separations Preview.
|
||||
//!
|
||||
//! * **Proof Colors** post-processes the rendered frame: every pixel goes display sRGB → proof
|
||||
//! space (CMYK press or RGB/colour-blindness simulation) → display, through a cached 17³ LUT.
|
||||
//! * **Separations Preview** recolours the *document* before rendering: each paint colour is split
|
||||
//! into process inks (colour-managed) plus a spot ink when it's linked to a spot swatch. With one
|
||||
//! plate visible the plate renders as greyscale ink coverage (black = 100%); with several, the
|
||||
//! visible inks are composited through the proof profile, spots multiplied on top.
|
||||
//! Placed raster images are not separated (limitation).
|
||||
//! * **Overprint Preview:** the document model has no per-object overprint attribute, so
|
||||
//! overprinting is limited to objects listed in `Document.unknown["overprintBlack"]` (set by
|
||||
//! Edit → Edit Colors → Overprint Black) whose fill or stroke is 100% K. Such objects are drawn
|
||||
//! with Multiply, which is what overprinting black ink does to the inks below.
|
||||
//!
|
||||
//! The app's current view state lives in [`view`] / [`set_view`]; the canvas copies it into
|
||||
//! [`crate::RenderOptions`] with [`active_proof`] and [`overprint_preview_on`].
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use drawcraft_color::cms::{self, Cms, PROCESS_PLATES};
|
||||
pub use drawcraft_color::cms::{Intent, ProofSetup, ProofTarget};
|
||||
use drawcraft_color::{BlendMode, Color, Paint};
|
||||
use drawcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind};
|
||||
|
||||
use crate::RenderOptions;
|
||||
|
||||
/// `Document.unknown` key listing the ids of objects marked Overprint Black.
|
||||
pub const OVERPRINT_KEY: &str = "overprintBlack";
|
||||
|
||||
/// Ink coverage of one paint colour.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Inks {
|
||||
/// Process inks C, M, Y, K (0..1).
|
||||
pub cmyk: [f32; 4],
|
||||
/// Spot ink (swatch name, tint 0..1).
|
||||
pub spot: Option<(String, f32)>,
|
||||
}
|
||||
|
||||
/// A printing plate.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Plate {
|
||||
pub name: String,
|
||||
pub spot: bool,
|
||||
/// Display colour of the ink (for the panel swatch).
|
||||
pub rgb: [f32; 3],
|
||||
}
|
||||
|
||||
/// The plates of `doc`: the four process plates plus one per spot swatch.
|
||||
pub fn plates(doc: &Document) -> Vec<Plate> {
|
||||
let c = cms::active();
|
||||
let mut v: Vec<Plate> = PROCESS_PLATES
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| {
|
||||
let mut ink = [0.0; 4];
|
||||
ink[i] = 1.0;
|
||||
Plate { name: (*n).into(), spot: false, rgb: c.cmyk_to_srgb(ink, false) }
|
||||
})
|
||||
.collect();
|
||||
for sw in &doc.swatches {
|
||||
if let (true, Paint::Solid { color, .. }) = (sw.spot, &sw.paint)
|
||||
&& !v.iter().any(|p| p.name == sw.name)
|
||||
{
|
||||
v.push(Plate { name: sw.name.clone(), spot: true, rgb: c.display_rgb(color) });
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn spot_swatch<'a>(doc: &'a Document, name: &str) -> Option<&'a Color> {
|
||||
doc.swatches.iter().find(|s| s.spot && s.name == name).and_then(|s| match &s.paint {
|
||||
Paint::Solid { color, .. } => Some(color),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Separate one colour into inks. Colours linked to a spot swatch print on that plate only, with
|
||||
/// the tint given by the colour's ink total relative to the swatch's.
|
||||
pub fn inks(doc: &Document, c: &Cms, color: &Color, swatch: Option<&str>, intent: Intent) -> Inks {
|
||||
if let Some(name) = swatch
|
||||
&& let Some(sc) = spot_swatch(doc, name)
|
||||
{
|
||||
let full: f32 = c.to_cmyk(sc, intent).iter().sum();
|
||||
let this: f32 = c.to_cmyk(color, intent).iter().sum();
|
||||
let tint = if full <= 1e-4 { 1.0 } else { (this / full).clamp(0.0, 1.0) };
|
||||
return Inks { cmyk: [0.0; 4], spot: Some((name.to_string(), tint)) };
|
||||
}
|
||||
Inks { cmyk: c.to_cmyk(color, intent), spot: None }
|
||||
}
|
||||
|
||||
fn map_paint(p: &mut Paint, f: &mut dyn FnMut(&Color, Option<&str>) -> Color) {
|
||||
match p {
|
||||
Paint::Solid { color, swatch } => *color = f(color, swatch.as_deref()),
|
||||
Paint::Gradient(g) => {
|
||||
for s in &mut g.gradient.stops {
|
||||
s.color = f(&s.color, None);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply `f` to every colour of a node tree (fills, strokes, gradient stops, text runs, mesh
|
||||
/// points), keeping swatch links.
|
||||
pub fn map_node_colors(n: &mut Node, f: &mut dyn FnMut(&Color, Option<&str>) -> Color) {
|
||||
for it in &mut n.appearance.items {
|
||||
match it {
|
||||
AppearanceItem::Fill(l) => map_paint(&mut l.paint, f),
|
||||
AppearanceItem::Stroke(l) => map_paint(&mut l.paint, f),
|
||||
}
|
||||
}
|
||||
match &mut n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
for r in &mut t.runs {
|
||||
map_paint(&mut r.style.fill, f);
|
||||
map_paint(&mut r.style.stroke, f);
|
||||
}
|
||||
}
|
||||
NodeKind::Mesh(m) => {
|
||||
for p in &mut m.points {
|
||||
p.color = f(&p.color, None);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(ch) = n.children_mut() {
|
||||
for c in ch.iter_mut() {
|
||||
map_node_colors(Arc::make_mut(c), f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply `f` to every colour in the document's art and symbol definitions.
|
||||
pub fn map_document_colors(doc: &mut Document, f: &mut dyn FnMut(&Color, Option<&str>) -> Color) {
|
||||
for l in &mut doc.layers {
|
||||
map_node_colors(Arc::make_mut(l), f);
|
||||
}
|
||||
for s in &mut doc.symbols {
|
||||
map_node_colors(Arc::make_mut(&mut s.art), f);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ids marked Overprint Black.
|
||||
pub fn overprint_ids(doc: &Document) -> Vec<NodeId> {
|
||||
doc.unknown
|
||||
.get(OVERPRINT_KEY)
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.iter().filter_map(|v| v.as_u64()).map(NodeId).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 100% black ink with no other process ink.
|
||||
pub fn is_pure_black(c: &Color) -> bool {
|
||||
match *c {
|
||||
Color::Cmyk { c, m, y, k } => k >= 0.995 && c <= 0.005 && m <= 0.005 && y <= 0.005,
|
||||
Color::Gray { k } => k >= 0.995,
|
||||
Color::Rgb { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_pure_black(n: &Node) -> bool {
|
||||
n.appearance.items.iter().any(|it| match it {
|
||||
AppearanceItem::Fill(l) => l.paint.color().is_some_and(|c| is_pure_black(&c)),
|
||||
AppearanceItem::Stroke(l) => l.paint.color().is_some_and(|c| is_pure_black(&c)),
|
||||
})
|
||||
}
|
||||
|
||||
fn plate_color(doc: &Document, c: &Cms, proof: &ProofSetup, visible: &[String], color: &Color, swatch: Option<&str>) -> Color {
|
||||
let ink = inks(doc, c, color, swatch, proof.intent);
|
||||
let rgb = if visible.len() == 1 {
|
||||
let name = &visible[0];
|
||||
let v = match PROCESS_PLATES.iter().position(|p| p == name) {
|
||||
Some(i) => ink.cmyk[i],
|
||||
None => ink.spot.as_ref().filter(|(n, _)| n == name).map_or(0.0, |s| s.1),
|
||||
};
|
||||
[1.0 - v; 3]
|
||||
} else {
|
||||
let mut cmyk = ink.cmyk;
|
||||
for (i, p) in PROCESS_PLATES.iter().enumerate() {
|
||||
if !visible.iter().any(|v| v == p) {
|
||||
cmyk[i] = 0.0;
|
||||
}
|
||||
}
|
||||
let mut rgb = c.proof_cmyk_to_srgb(cmyk, proof);
|
||||
if let Some((name, t)) = &ink.spot
|
||||
&& visible.contains(name)
|
||||
&& let Some(sc) = spot_swatch(doc, name)
|
||||
{
|
||||
let s = c.display_rgb(sc);
|
||||
for i in 0..3 {
|
||||
rgb[i] *= 1.0 - t * (1.0 - s[i]);
|
||||
}
|
||||
}
|
||||
rgb
|
||||
};
|
||||
let [r, g, b] = c.srgb_to_rgb(rgb);
|
||||
Color::Rgb { r, g, b }
|
||||
}
|
||||
|
||||
/// The document as it should be drawn for these options (overprints, separations).
|
||||
pub(crate) fn prepare<'a>(doc: &'a Document, opts: &RenderOptions) -> Cow<'a, Document> {
|
||||
let seps = opts.proof.as_ref().and_then(|p| p.separations.as_ref().map(|s| (p, s)));
|
||||
let overprint = opts.overprint_preview || seps.is_some();
|
||||
let ids = if overprint { overprint_ids(doc) } else { vec![] };
|
||||
if ids.is_empty() && seps.is_none() {
|
||||
return Cow::Borrowed(doc);
|
||||
}
|
||||
let mut d = doc.clone();
|
||||
for id in ids {
|
||||
if let Some(n) = d.node_mut(id)
|
||||
&& n.blend == BlendMode::Normal
|
||||
&& has_pure_black(n)
|
||||
{
|
||||
n.blend = BlendMode::Multiply;
|
||||
}
|
||||
}
|
||||
if let Some((proof, visible)) = seps {
|
||||
let c = cms::active();
|
||||
let src = doc.clone();
|
||||
map_document_colors(&mut d, &mut |col, sw| plate_color(&src, &c, proof, visible, col, sw));
|
||||
}
|
||||
Cow::Owned(d)
|
||||
}
|
||||
|
||||
/// Soft-proof the rendered (premultiplied RGBA8) pixels in place.
|
||||
pub(crate) fn post(pixels: &mut [u8], opts: &RenderOptions) {
|
||||
let Some(proof) = opts.proof.as_ref() else { return };
|
||||
if proof.separations.is_some() || matches!(proof.target, ProofTarget::MonitorRgb | ProofTarget::Srgb) {
|
||||
return;
|
||||
}
|
||||
let lut = cms::active().proof_lut(proof);
|
||||
let mut last_in = [0u8; 4];
|
||||
let mut last_out = [0u8; 4];
|
||||
for px in pixels.chunks_exact_mut(4) {
|
||||
let a = px[3];
|
||||
if a == 0 {
|
||||
continue;
|
||||
}
|
||||
if *px == last_in {
|
||||
px.copy_from_slice(&last_out);
|
||||
continue;
|
||||
}
|
||||
last_in.copy_from_slice(px);
|
||||
let un = |c: u8| if a == 255 { c } else { ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 };
|
||||
let out = lut.apply8([un(px[0]), un(px[1]), un(px[2])]);
|
||||
for i in 0..3 {
|
||||
px[i] = if a == 255 { out[i] } else { ((out[i] as u32 * a as u32 + 127) / 255) as u8 };
|
||||
}
|
||||
last_out.copy_from_slice(px);
|
||||
}
|
||||
}
|
||||
|
||||
/// View-level proof state (View → Proof Setup / Proof Colors / Overprint Preview, Separations
|
||||
/// Preview panel). Process-wide: the app has one proof state for all windows.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ProofView {
|
||||
/// View → Proof Setup.
|
||||
pub setup: ProofSetup,
|
||||
/// View → Proof Colors.
|
||||
pub proof_colors: bool,
|
||||
/// View → Overprint Preview.
|
||||
pub overprint: bool,
|
||||
/// Separations Preview: `Some(visible plates)` when on.
|
||||
pub separations: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
static VIEW: RwLock<Option<ProofView>> = RwLock::new(None);
|
||||
|
||||
pub fn view() -> ProofView {
|
||||
VIEW.read().unwrap_or_else(|e| e.into_inner()).clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn set_view(v: ProofView) {
|
||||
*VIEW.write().unwrap_or_else(|e| e.into_inner()) = Some(v);
|
||||
}
|
||||
|
||||
/// The proof to pass in [`RenderOptions::proof`] for the current view state.
|
||||
pub fn active_proof() -> Option<ProofSetup> {
|
||||
let v = view();
|
||||
match v.separations {
|
||||
Some(s) => Some(ProofSetup { separations: Some(s), ..v.setup }),
|
||||
None if v.proof_colors => Some(ProofSetup { separations: None, ..v.setup }),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether [`RenderOptions::overprint_preview`] should be on (Separations Preview implies it).
|
||||
pub fn overprint_preview_on() -> bool {
|
||||
let v = view();
|
||||
v.overprint || v.separations.is_some()
|
||||
}
|
||||
Reference in New Issue
Block a user