//! Planner: walks the layer tree into a linear list of GPU passes over abstract buffer slots. //! //! The structure mirrors `photocraft_compose::composite_stack` / `composite_layer` / //! `composite_atop` one to one, so both backends share Photoshop semantics (pass-through vs //! isolated groups, clipping, masks, opacity × fill, adjustments, layer effects). Every pass //! reads slots and writes a fresh slot; slots are recycled as soon as nothing refers to them. //! //! Layer effects follow `photocraft_compose::effects::composite_with_effects`: the layer's //! effect maps (shadow / glow / satin / bevel coverage, stroke bands) are built once per layer //! state by [`crate::fx`] and sampled here; exterior effects paint into a copy of the backdrop, //! the layer at fill opacity takes the interior effects, and the two merge with the layer's mode //! and opacity. Effect passes are clipped to the layer's effect region and the result is copied //! back into the backdrop in place, so a small text layer costs only its own pixels. use photocraft_color::BlendMode; use photocraft_compose::adjust::{self, Transfer}; use photocraft_compose::effects::has_effects; use photocraft_doc::{Adjustment, Document, Effect, Fill, FxPaint, GlobalLight, Gradient, Layer, LayerContent, LayerId, Pattern, StrokePosition}; use photocraft_geom::Rect; use photocraft_raster::Surface; use crate::bounds; /// Index of a chunk-sized accumulator texture. pub type Slot = u32; /// Pipeline (fragment entry point) of a pass. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Kernel { /// Transparent fill (no draw). Clear, Content, Mask, Blend, Atop, Adjust, AdjMix, Lerp, /// Effect chain start: the layer at fill opacity, or an opaque copy of a clipping base. FxInit, /// Paint an effect through a coverage map (or the layer's alpha) into A. FxPaint, /// Layer + interior effects over the exterior result, then layer opacity against the backdrop. FxMerge, /// Outside strokes: accumulate one stroke's share (`kind` 0, premultiplied colour) or its /// coverage (`kind` 1). FxStroke, /// Outside strokes: resolve the accumulated strokes over the exterior result. FxStrokeEnd, /// Copy slot A into `dst` (an existing slot) over the pass clip (no draw). CopyRect, /// Copy all of slot A into the fresh slot `dst` (no draw). CopyFull, // Effect-map kernels (single-channel targets over an effect region, see `fx`). MShift, MDilate, MBlur, MGlow, MFinish, MBevelH, MBevelShade, MStroke, MBevelTex, } impl Kernel { /// Fragment entry point in `compose.wgsl` (`None` for copies and clears). pub fn entry(self) -> Option<&'static str> { Some(match self { Kernel::Clear | Kernel::CopyRect | Kernel::CopyFull => return None, Kernel::Content => "fs_content", Kernel::Mask => "fs_mask", Kernel::Blend => "fs_blend", Kernel::Atop => "fs_atop", Kernel::Adjust => "fs_adjust", Kernel::AdjMix => "fs_adjmix", Kernel::Lerp => "fs_lerp", Kernel::FxInit => "fs_fxinit", Kernel::FxPaint => "fs_fxpaint", Kernel::FxMerge => "fs_fxmerge", Kernel::FxStroke => "fs_fxstroke", Kernel::FxStrokeEnd => "fs_fxstrokeend", Kernel::MShift => "fs_mshift", Kernel::MDilate => "fs_mdilate", Kernel::MBlur => "fs_mblur", Kernel::MGlow => "fs_mglow", Kernel::MFinish => "fs_mfinish", Kernel::MBevelH => "fs_mbevelh", Kernel::MBevelShade => "fs_mbevelshade", Kernel::MStroke => "fs_mstroke", Kernel::MBevelTex => "fs_mbeveltex", }) } /// Kernels that write effect maps (R32F / R16F targets) rather than RGBA accumulators. pub fn is_map(self) -> bool { matches!( self, Kernel::MShift | Kernel::MDilate | Kernel::MBlur | Kernel::MGlow | Kernel::MFinish | Kernel::MBevelH | Kernel::MBevelShade | Kernel::MStroke | Kernel::MBevelTex ) } /// Every kernel with a pipeline. pub const DRAWN: [Kernel; 21] = [ Kernel::Content, Kernel::Mask, Kernel::Blend, Kernel::Atop, Kernel::Adjust, Kernel::AdjMix, Kernel::Lerp, Kernel::FxInit, Kernel::FxPaint, Kernel::FxMerge, Kernel::FxStroke, Kernel::FxStrokeEnd, Kernel::MShift, Kernel::MDilate, Kernel::MBlur, Kernel::MGlow, Kernel::MFinish, Kernel::MBevelH, Kernel::MBevelShade, Kernel::MStroke, Kernel::MBevelTex, ]; } /// Which resident texture a pass samples. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Role { Content, Mask, /// A stroked shape's stroke alone (its fill uses `Content`). Stroke, } /// A surface the pass needs on the GPU. #[derive(Clone, Debug)] pub struct TexUse<'a> { pub layer: LayerId, pub role: Role, pub surface: SurfaceRef<'a>, } /// An effect map sampled by a pass: map `map` of enabled effect `item` of `plan.fx[fx]`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct MapRef { pub fx: usize, pub item: usize, pub map: usize, } #[derive(Clone, Debug)] pub struct Pass<'a> { pub kernel: Kernel, pub dst: Slot, pub a: Option, pub b: Option, pub c: Option, /// A fourth slot, bound in place of `tex` (passes without layer pixels). pub d: Option, pub mode: BlendMode, pub opacity: f32, /// Layer pixels (raster / text / shape / smart cache / fill cache). pub tex: Option>, /// Colour outside `tex` (the surface's default pixel), the solid fill colour, or the effect /// colour. pub color: [f32; 4], pub mask: Option>, /// Adjustment kind (see `adjust` in compose.wgsl) and parameters; the coverage kind of an /// effect paint. pub adjust_kind: i32, pub params: [[f32; 4]; 4], pub extra: [f32; 4], /// Extra shader flags (`F_KNOCKOUT`, …). pub flags: u32, /// 4096-entry LUT rows (Levels / Curves / Gradient map / gradient stops). pub lut: Option>, pub gradient: bool, /// Effect map sampled by `FxPaint`. pub map: Option, /// Pattern painted by `FxPaint`. pub pattern: Option<&'a Pattern>, /// Only pixels inside this rect (document coordinates) are computed; the pass is skipped for /// chunks it misses. Consumers of the slot must be clipped alike. pub clip: Option, } /// Pixels a pass samples: a surface of the document, or one derived from it on the CPU (a /// combined pixel × vector mask, a shape's fill or stroke alone). #[derive(Clone, Debug)] pub enum SurfaceRef<'a> { Doc(&'a Surface), Derived(std::sync::Arc), } impl SurfaceRef<'_> { pub fn get(&self) -> &Surface { match self { SurfaceRef::Doc(s) => s, SurfaceRef::Derived(s) => s, } } } #[derive(Clone, Debug)] pub struct MaskUse<'a> { pub layer: LayerId, pub surface: SurfaceRef<'a>, pub density: f32, pub default: f32, } impl<'a> Pass<'a> { pub(crate) fn new(kernel: Kernel, dst: Slot) -> Self { Pass { kernel, dst, a: None, b: None, c: None, d: None, mode: BlendMode::Normal, opacity: 1.0, tex: None, color: [0.0; 4], mask: None, adjust_kind: 0, params: [[0.0; 4]; 4], extra: [0.0; 4], flags: 0, lut: None, gradient: false, map: None, pattern: None, clip: None, } } } /// Why a document can't be composited on the GPU (the caller falls back to the CPU compositor). #[derive(Clone, Debug, PartialEq, Eq)] pub struct Unsupported(pub String); impl std::fmt::Display for Unsupported { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "GPU compositor: {}", self.0) } } impl std::error::Error for Unsupported {} /// A layer whose effects the plan renders: its maps are built (or reused) before the chunks run. #[derive(Clone, Copy, Debug)] pub struct FxLayer<'a> { pub layer: &'a Layer, /// Region the maps cover (`compose::effect_maps`). pub region: Rect, /// Layer bounds (gradients and linked patterns are laid out in them). pub bounds: Rect, } #[derive(Debug)] pub struct Plan<'a> { pub passes: Vec>, pub slots: u32, pub root: Slot, /// Effect layers in dependency order (a group's inner effect layers come first). pub fx: Vec>, } /// Document-level inputs of a plan. #[derive(Clone, Copy)] pub struct DocCtx<'a> { pub canvas: Rect, pub transfer: Transfer, pub light: GlobalLight, pub patterns: &'a [Pattern], /// Colour mode (channel restrictions name its channels). pub mode: photocraft_color::ColorMode, /// Sample depth (adjustment results are rounded to it). pub depth: photocraft_color::SampleType, } impl<'a> DocCtx<'a> { pub fn of(doc: &'a Document) -> Self { DocCtx { canvas: doc.bounds(), transfer: Transfer::for_mode(doc.mode), light: doc.global_light, patterns: &doc.patterns, mode: doc.mode, depth: doc.depth, } } } /// Build the pass list for `doc`. pub fn plan(doc: &Document) -> Result, Unsupported> { if doc.mode == photocraft_color::ColorMode::Multichannel { return Err(Unsupported("Multichannel inks (printed on the CPU)".into())); } let mut p = Planner::new(DocCtx::of(doc)); let root = p.clear(); let root = p.stack(&doc.layers, root)?; Ok(p.finish(root)) } struct Planner<'a> { passes: Vec>, free: Vec, refs: Vec, cx: DocCtx<'a>, fx: Vec>, } /// Coverage source of an effect paint (`kind` in `fs_fxpaint`); flags refine it. #[derive(Clone, Copy)] enum Cov { /// An effect map; `outside` beyond its region. Map(MapRef, f32), /// Full coverage. One, } /// Shader flags for effect passes (keep in sync with compose.wgsl). Coverage `m` of a paint, with /// `a` the layer's alpha and `inside` = `a > 0.5/255`: /// knockout `m × (1 − a × k)` (k in `p4.w`); gate `inside ? m : 0`; rel `inside ? min(m / a, 1) : 0`; /// stroke-out `inside ? (vector ? 0 : 1) : m`. pub const F_KNOCKOUT: u32 = 16; /// Shape layer (outside strokes never show inside it). pub const F_VECTOR: u32 = 64; /// Merge onto an opaque clipping base, keeping its alpha. pub const F_ATOP: u32 = 128; pub const F_GATE: u32 = 256; pub const F_REL: u32 = 512; pub const F_STROKE_OUT: u32 = 1024; /// First outside stroke (no accumulated share yet). pub const F_FIRST: u32 = 2048; /// `Lerp` per channel: weights in `p0` (channel restrictions), no opacity or mask. pub const F_CHANNELS: u32 = 4096; /// Lab document: Normal blending mixes in CIELAB (`psblend::LAB_MIX`). pub const F_LAB: u32 = 65536; /// `Lerp`: A rounded to `p0.x` steps per unit (adjustment results on integer documents). pub const F_QUANT: u32 = 32768; /// `Lerp` as A + (B − C) premultiplied (layers clipped to pass-through groups). pub const F_ADD_DIFF: u32 = 16384; /// Blend / Atop / FxMerge of a type layer: coverage mixed at `psblend::TEXT_GAMMA`. pub const F_TEXT_GAMMA: u32 = 8192; /// `F_TEXT_GAMMA` for type layers while text gamma blending is on; the gamma goes in `p4.w`. fn gamma_flag(layer: &Layer) -> u32 { if photocraft_compose::text_gamma(layer) != 1.0 { F_TEXT_GAMMA } else { 0 } } /// `FxInit` kinds: A at `opacity` × alpha; an opaque copy of A; A's colour with alpha `opacity` /// inside B's shape; A with alpha × B's alpha. pub const INIT_SCALE: i32 = 0; pub const INIT_OPAQUE: i32 = 1; pub const INIT_INSIDE: i32 = 2; pub const INIT_ALPHA: i32 = 3; impl<'a> Planner<'a> { fn new(cx: DocCtx<'a>) -> Self { Planner { passes: Vec::new(), free: Vec::new(), refs: Vec::new(), cx, fx: Vec::new() } } fn finish(self, root: Slot) -> Plan<'a> { Plan { passes: self.passes, slots: self.refs.len() as u32, root, fx: self.fx } } fn alloc(&mut self) -> Slot { if let Some(s) = self.free.pop() { self.refs[s as usize] = 1; return s; } self.refs.push(1); (self.refs.len() - 1) as Slot } fn retain(&mut self, s: Slot) -> Slot { self.refs[s as usize] += 1; s } fn release(&mut self, s: Slot) { let r = &mut self.refs[s as usize]; *r -= 1; if *r == 0 { self.free.push(s); } } /// Emit `pass` into a fresh slot, releasing its inputs. fn emit(&mut self, mut pass: Pass<'a>) -> Slot { let dst = self.alloc(); pass.dst = dst; if self.cx.mode == photocraft_color::ColorMode::Lab { pass.flags |= F_LAB; } let (a, b, c, d) = (pass.a, pass.b, pass.c, pass.d); self.passes.push(pass); for s in [a, b, c, d].into_iter().flatten() { self.release(s); } dst } fn clear(&mut self) -> Slot { self.emit(Pass::new(Kernel::Clear, 0)) } /// composite_stack: returns the new backdrop (consumes `backdrop`). fn stack(&mut self, layers: &'a [Layer], mut backdrop: Slot) -> Result { let mut i = 0; while i < layers.len() { let base = &layers[i]; let mut j = i + 1; while j < layers.len() && layers[j].clipped && !base.clipped { j += 1; } let clipped = &layers[i + 1..j]; if base.visible { backdrop = self.layer(base, clipped, backdrop)?; if let (LayerContent::Adjustment(_), Some(q)) = (&base.content, photocraft_compose::adjustment_quantum(self.cx.depth)) { // compose: adjustment results are rounded to the document's depth. let mut p = Pass::new(Kernel::Lerp, 0); p.a = Some(backdrop); p.flags = F_QUANT; p.params[0] = [q, 0.0, 0.0, 0.0]; backdrop = self.emit(p); } } i = j.max(i + 1); } Ok(backdrop) } fn mask_use(&self, layer: &'a Layer) -> Option> { if let Some(c) = photocraft_compose::masks::combined_mask(layer, self.cx.canvas) { let default = c.default_pixel().first().copied().unwrap_or(1.0); return Some(MaskUse { layer: layer.id, surface: SurfaceRef::Derived(std::sync::Arc::new(c)), density: 1.0, default }); } let m = layer.mask.as_ref()?; if !m.enabled { return None; } Some(MaskUse { layer: layer.id, surface: SurfaceRef::Doc(&m.surface), density: m.density, default: m.surface.default_pixel().first().copied().unwrap_or(1.0), }) } /// Blending Options › Blend If has no GPU pass yet: such documents use the CPU compositor. fn check_blend_if(&self, layer: &Layer) -> Result<(), Unsupported> { if photocraft_compose::blend_if_active(layer, self.cx.mode) { return Err(Unsupported(format!("Blend If on `{}` (composited on the CPU)", layer.name))); } Ok(()) } /// composite_layer, honouring the layer's channel restrictions. fn layer(&mut self, layer: &'a Layer, clipped: &'a [Layer], backdrop: Slot) -> Result { self.check_blend_if(layer)?; match photocraft_compose::channel_weights(layer, self.cx.mode) { Some(w) => { let before = self.retain(backdrop); let after = self.layer_any(layer, clipped, backdrop)?; Ok(self.restore_channels(before, after, w)) } None => self.layer_any(layer, clipped, backdrop), } } /// `compose::restore_channels`: `after` with the channels a layer leaves out taken from /// `before` (consumes both). fn restore_channels(&mut self, before: Slot, after: Slot, w: [f32; 3]) -> Slot { let mut p = Pass::new(Kernel::Lerp, 0); p.a = Some(before); p.b = Some(after); p.flags = F_CHANNELS; p.params[0] = [w[0], w[1], w[2], 1.0]; self.emit(p) } fn layer_any(&mut self, layer: &'a Layer, clipped: &'a [Layer], backdrop: Slot) -> Result { match layer.artboard() { Some(ab) => self.artboard(layer, ab, clipped, backdrop), None => self.layer_plain(layer, clipped, backdrop), } } /// compose::composite_artboard: the board's background and the group composited over the /// backdrop, kept only inside the board (contents and effects outside it are clipped away). fn artboard(&mut self, layer: &'a Layer, ab: &photocraft_doc::Artboard, clipped: &'a [Layer], backdrop: Slot) -> Result { let board = ab.rect.intersect(&self.cx.canvas); if board.is_empty() { return Ok(backdrop); } let before = self.retain(backdrop); let mut sub = backdrop; if let Some(bg) = ab.background.rgba() { let mut c = Pass::new(Kernel::Content, 0); c.color = bg; let c = self.emit(c); let mut p = Pass::new(Kernel::Blend, 0); p.a = Some(sub); p.b = Some(c); sub = self.emit(p); } let sub = self.layer_plain(layer, clipped, sub)?; let dst = if self.refs[before as usize] == 1 { before } else { let n = self.alloc(); let mut p = Pass::new(Kernel::CopyFull, n); p.a = Some(before); self.passes.push(p); self.release(before); n }; let mut p = Pass::new(Kernel::CopyRect, dst); p.a = Some(sub); p.clip = Some(board); self.passes.push(p); self.release(sub); Ok(dst) } fn layer_plain(&mut self, layer: &'a Layer, clipped: &'a [Layer], backdrop: Slot) -> Result { let opacity = layer.opacity * layer.fill_opacity; let visible_clipped: Vec<&'a Layer> = clipped.iter().filter(|c| c.visible).collect(); if let LayerContent::Group(g) = &layer.content && layer.blend == BlendMode::PassThrough && !has_effects(layer) { let before = self.retain(backdrop); let mut after = self.stack(&g.children, backdrop)?; if opacity < 1.0 || layer.mask.is_some() || layer.vector_mask.is_some() { let mut p = Pass::new(Kernel::Lerp, 0); p.a = Some(self.retain(before)); p.b = Some(after); p.opacity = opacity; p.mask = self.mask_use(layer); after = self.emit(p); } if visible_clipped.is_empty() { self.release(before); return Ok(after); } // Layers clipped to a pass-through group (compose: their effect on the group's // isolated rendering, each placed over the original backdrop, is added to the // pass-through result, premultiplied). let iso = self.content(layer)?; let iso_keep = self.retain(iso); let mut clipped_iso = iso; for c in visible_clipped { clipped_iso = self.atop(c, clipped_iso)?; } let over = |s: &mut Self, src: Slot, before: Slot| { let mut p = Pass::new(Kernel::Blend, 0); p.a = Some(before); p.b = Some(src); p.opacity = opacity; s.emit(p) }; let b2 = self.retain(before); let without = over(self, iso_keep, b2); let with = over(self, clipped_iso, before); let mut p = Pass::new(Kernel::Lerp, 0); p.a = Some(after); p.b = Some(with); p.c = Some(without); p.flags = F_ADD_DIFF; return Ok(self.emit(p)); } if let LayerContent::Adjustment(adj) = &layer.content { let before = self.retain(backdrop); let mut adjusted = self.adjust(adj, backdrop); for c in visible_clipped { adjusted = self.atop(c, adjusted)?; } let mut p = Pass::new(Kernel::AdjMix, 0); p.a = Some(before); p.b = Some(adjusted); p.mode = layer.blend; p.opacity = opacity; p.mask = self.mask_use(layer); return Ok(self.emit(p)); } if has_effects(layer) { return self.effects(layer, &visible_clipped, backdrop, false); } if let LayerContent::Shape(sh) = &layer.content && !visible_clipped.is_empty() && let Some((fill, stroke)) = photocraft_compose::shape_split::split(sh, self.cx.canvas) { // The vector stroke goes above the clipped layers: the fill is the clipping base, // the stroke is laid over the clipped result (compose::shape_parts). let part = |s: &mut Self, role: Role, surface: Surface| { let mut p = Pass::new(Kernel::Content, 0); p.mask = s.mask_use(layer); p.color = photocraft_raster::to_rgba(&surface.format(), &surface.default_pixel()); if surface.tile_count() > 0 { p.tex = Some(TexUse { layer: layer.id, role, surface: SurfaceRef::Derived(std::sync::Arc::new(surface)) }); } s.emit(p) }; let mut content = part(self, Role::Content, fill); for c in visible_clipped { content = self.atop(c, content)?; } let stroke = part(self, Role::Stroke, stroke); let mut p = Pass::new(Kernel::Blend, 0); p.a = Some(content); p.b = Some(stroke); let content = self.emit(p); let mut p = Pass::new(Kernel::Blend, 0); p.a = Some(backdrop); p.b = Some(content); p.mode = layer.blend; p.opacity = opacity; return Ok(self.emit(p)); } let mut content = self.content(layer)?; for c in visible_clipped { content = self.atop(c, content)?; } let mut p = Pass::new(Kernel::Blend, 0); p.a = Some(backdrop); p.b = Some(content); p.mode = layer.blend; p.opacity = opacity; p.flags = gamma_flag(layer); p.extra[3] = photocraft_compose::text_gamma(layer); Ok(self.emit(p)) } /// render_content for non-adjustment layers. fn content(&mut self, layer: &'a Layer) -> Result { match &layer.content { LayerContent::Group(g) => { let empty = self.clear(); let s = self.stack(&g.children, empty)?; if layer.mask.is_some() || layer.vector_mask.is_some() { let mut p = Pass::new(Kernel::Mask, 0); p.a = Some(s); p.mask = self.mask_use(layer); return Ok(self.emit(p)); } Ok(s) } // Adjustments are handled by the caller and never get here. LayerContent::Adjustment(_) => Err(Unsupported("adjustment layer reached the content planner".into())), LayerContent::Fill(f @ Fill::Pattern { name, scale, id, angle, link, phase }) if layer.fill_cache.as_ref().is_none_or(|c| c.fill != *f) => { // compose::render_fill: the pattern tiled from the layer's frame (transparent // when missing), then the layer's masks. let empty = self.clear(); let Some(pat) = photocraft_doc::pattern::find(self.cx.patterns, id, name).filter(|p| !p.is_empty()) else { return Ok(empty) }; let frame = photocraft_compose::fill_frame(layer, self.cx.canvas); let anchor = if frame.is_empty() { (0.0, 0.0) } else { (f64::from(frame.x0), f64::from(frame.y0)) }; let paint = Paint::Pattern(pat, placement(anchor, *link, *phase, *scale, *angle)); let canvas = self.cx.canvas; let s = self.paint(empty, empty, Cov::One, &paint, BlendMode::Normal, 1.0, 0, canvas, canvas); if layer.mask.is_some() || layer.vector_mask.is_some() { let mut p = Pass::new(Kernel::Mask, 0); p.a = Some(s); p.mask = self.mask_use(layer); return Ok(self.emit(p)); } Ok(s) } _ => { let p = self.content_pass(layer); Ok(self.emit(p)) } } } /// The Content pass of a raster / text / shape / smart / fill layer. fn content_pass(&self, layer: &'a Layer) -> Pass<'a> { let mut p = Pass::new(Kernel::Content, 0); p.mask = self.mask_use(layer); match &layer.content { LayerContent::Fill(f) => match &layer.fill_cache { Some(c) if c.fill == *f => self.surface_tex(&mut p, layer.id, &c.surface), _ => self.fill(&mut p, f, photocraft_compose::fill_frame(layer, self.cx.canvas)), }, _ => { if let Some(s) = layer.surface() { self.surface_tex(&mut p, layer.id, s); } } } p } fn surface_tex(&self, p: &mut Pass<'a>, id: LayerId, s: &'a Surface) { let dp = s.default_pixel(); p.color = photocraft_raster::to_rgba(&s.format(), &dp); if s.tile_count() > 0 { p.tex = Some(TexUse { layer: id, role: Role::Content, surface: SurfaceRef::Doc(s) }); } } fn fill(&self, p: &mut Pass<'a>, f: &Fill, frame: photocraft_geom::Rect) { match f { Fill::Solid(c) => { let rgb = c.to_rgb(); p.color = [rgb[0], rgb[1], rgb[2], c.alpha]; } Fill::Gradient { stops, angle, scale, style, reverse } => { p.gradient = true; p.params[0] = [*angle, *scale, if *reverse { 1.0 } else { 0.0 }, style_index(*style)]; let c = frame; p.params[1] = [c.x0 as f32, c.y0 as f32, c.width() as f32, c.height() as f32]; let conv: Vec<(f32, [f32; 4])> = stops .iter() .map(|(t, c)| { let r = c.to_rgb(); (*t, [r[0], r[1], r[2], c.alpha]) }) .collect(); let mut rows = vec![[0.0f32; 4096]; 4]; for k in 0..4096 { let v = sample_stops4(&conv, k as f32 / 4095.0); for (ch, row) in rows.iter_mut().enumerate() { row[k] = v[ch]; } } p.lut = Some(rows); } // Pattern fills fall back to the CPU compositor (see `check`). Fill::Pattern { .. } => {} } } /// composite_atop: `layer` onto `base`, restricted to the base's alpha, honouring the /// layer's channel restrictions. fn atop(&mut self, layer: &'a Layer, base: Slot) -> Result { self.check_blend_if(layer)?; match photocraft_compose::channel_weights(layer, self.cx.mode) { Some(w) => { let before = self.retain(base); let after = self.atop_any(layer, base)?; Ok(self.restore_channels(before, after, w)) } None => self.atop_any(layer, base), } } fn atop_any(&mut self, layer: &'a Layer, base: Slot) -> Result { let opacity = layer.opacity * layer.fill_opacity; if let LayerContent::Adjustment(adj) = &layer.content { let before = self.retain(base); let adjusted = self.adjust(adj, base); let mut p = Pass::new(Kernel::AdjMix, 0); p.a = Some(before); p.b = Some(adjusted); p.mode = layer.blend; p.opacity = opacity; p.mask = self.mask_use(layer); return Ok(self.emit(p)); } if has_effects(layer) { return self.effects(layer, &[], base, true); } let content = self.content(layer)?; let mut p = Pass::new(Kernel::Atop, 0); p.a = Some(base); p.b = Some(content); p.mode = layer.blend; p.opacity = opacity; p.flags = gamma_flag(layer); p.extra[3] = photocraft_compose::text_gamma(layer); Ok(self.emit(p)) } /// adjust::apply_with on a slot (consumes it). fn adjust(&mut self, adj: &Adjustment, src: Slot) -> Slot { let mut p = Pass::new(Kernel::Adjust, 0); p.a = Some(src); let (kind, params, lut) = adjustment_program(adj, self.cx.transfer); p.adjust_kind = kind; p.params = params; p.lut = lut; self.emit(p) } /// `composite_with_effects` (atop = false) or the clipped-layer variant of `composite_atop` /// (atop = true: effects over the base treated as opaque, keeping the base's alpha). /// Consumes `backdrop`. fn effects(&mut self, layer: &'a Layer, clipped: &[&'a Layer], backdrop: Slot, atop: bool) -> Result { let canvas = self.cx.canvas; let region = bounds::effect_region(layer, canvas); let sb = photocraft_compose::paint_bounds(layer).unwrap_or_else(|| bounds::layer_bounds(layer, canvas)); let clip = if bounds::transparent_outside(layer) { region } else { canvas }; let mut content = self.content(layer)?; if !matches!(layer.content, LayerContent::Group(_)) && let Some(p) = self.passes.last_mut() { p.clip = Some(clip); } for c in clipped { content = self.atop(c, content)?; } let fx = self.fx.len(); self.fx.push(FxLayer { layer, region, bounds: sb }); let items: Vec<&'a Effect> = layer.effects.items.iter().filter(|e| e.enabled()).collect(); // Linked patterns tile from the effects reference point (else the layer's top-left). let anchor = layer.effects.reference.unwrap_or((f64::from(sb.x0), f64::from(sb.y0))); let vector_shape = matches!(layer.content, LayerContent::Shape(_)); let map = |item: usize, map: usize| MapRef { fx, item, map }; let rev: Vec<(usize, &'a Effect)> = items.iter().copied().enumerate().rev().collect(); let init = |s: &mut Self, src: Slot, b: Option, kind: i32, opacity: f32| { let mut p = Pass::new(Kernel::FxInit, 0); p.a = Some(src); p.b = b; p.adjust_kind = kind; p.opacity = opacity; p.clip = Some(clip); s.emit(p) }; // Exterior effects over the backdrop (an opaque copy of it for clipped layers). let mut w = if atop { let b = self.retain(backdrop); init(self, b, None, INIT_OPAQUE, 1.0) } else { self.retain(backdrop) }; for &(i, e) in &rev { if let Effect::DropShadow(s) = e { // The layer hides the shadow beneath it where its fill is see-through. let flags = if s.knocks_out { F_KNOCKOUT } else { 0 }; let see_through = 1.0 - layer.fill_opacity.clamp(0.0, 1.0); w = self.paint_k( w, content, Cov::Map(map(i, 0), 0.0), &Paint::Color(s.color.to_rgb()), s.common.blend, s.common.opacity, flags, clip, sb, see_through, ); } } for &(i, e) in &rev { if let Effect::OuterGlow(g) = e { let paint = self.fx_paint(&g.paint, anchor); w = self.paint(w, content, Cov::Map(map(i, 0), 0.0), &paint, g.common.blend, g.common.opacity, 0, clip, sb); } } // The layer: its colour at fill opacity inside its shape; interior effects are painted // relative to the shape (coverage within it), then the shape's alpha applies. let c = self.retain(content); let mut l = init(self, c, None, INIT_INSIDE, layer.fill_opacity); for &(_, e) in &rev { if let Effect::PatternOverlay { common, name, id, scale, angle, link, phase } = e && let Some(pat) = photocraft_doc::pattern::find(self.cx.patterns, id, name).filter(|p| !p.is_empty()) { let paint = Paint::Pattern(pat, placement(anchor, *link, *phase, *scale, *angle)); l = self.paint(l, content, Cov::One, &paint, common.blend, common.opacity, F_GATE, clip, sb); } } for &(_, e) in &rev { if let Effect::GradientOverlay { common, gradient, .. } = e { l = self.paint(l, content, Cov::One, &Paint::Gradient(gradient), common.blend, common.opacity, F_GATE, clip, sb); } } for &(_, e) in &rev { if let Effect::ColorOverlay { common, color } = e { l = self.paint(l, content, Cov::One, &Paint::Color(color.to_rgb()), common.blend, common.opacity, F_GATE, clip, sb); } } for &(i, e) in &rev { if let Effect::Satin(s) = e { l = self.paint(l, content, Cov::Map(map(i, 0), 0.0), &Paint::Color(s.color.to_rgb()), s.common.blend, s.common.opacity, F_REL, clip, sb); } } for &(i, e) in &rev { if let Effect::InnerGlow(g) = e { let paint = self.fx_paint(&g.paint, anchor); l = self.paint(l, content, Cov::Map(map(i, 0), 0.0), &paint, g.common.blend, g.common.opacity, F_REL, clip, sb); } } for &(i, e) in &rev { if let Effect::InnerShadow(s) = e { l = self.paint(l, content, Cov::Map(map(i, 0), 0.0), &Paint::Color(s.color.to_rgb()), s.common.blend, s.common.opacity, F_REL, clip, sb); } } // Inside stroke parts are painted over the layer (bottom instance first). for &(i, e) in &rev { if let Effect::Stroke(st) = e { let (in_w, _) = stroke_widths(st); if in_w > 0.0 { let paint = self.fx_paint(&st.paint, anchor); l = self.paint(l, content, Cov::Map(map(i, 1), (in_w + 0.5).clamp(0.0, 1.0)), &paint, st.common.blend, st.common.opacity, F_GATE, clip, sb); } } } for &(i, e) in &rev { if let Effect::BevelEmboss(b) = e && b.style != photocraft_doc::BevelStyle::OuterBevel { // Inner part (emboss styles: maps 0–1 inside, 2–3 outside). l = self.paint( l, content, Cov::Map(map(i, 0), 0.0), &Paint::Color(b.highlight_color.to_rgb()), b.highlight.blend, b.highlight.opacity, F_REL, clip, sb, ); l = self.paint(l, content, Cov::Map(map(i, 1), 0.0), &Paint::Color(b.shadow_color.to_rgb()), b.shadow.blend, b.shadow.opacity, F_REL, clip, sb); } } // The shape's own alpha. let c = self.retain(content); l = init(self, l, Some(c), INIT_ALPHA, 1.0); // Outside stroke parts lie beneath the layer and blend onto the exterior result with their // own mode; a higher stroke covers the ones below it (each takes its coverage × opacity // not yet taken above it, blended over the result as it was before the strokes). let outs: Vec<(usize, &photocraft_doc::StrokeFx)> = items .iter() .copied() .enumerate() .filter_map(|(i, e)| if let Effect::Stroke(st) = e { Some((i, st)) } else { None }) .filter(|(_, st)| stroke_widths(st).1 > 0.0) .collect(); if !outs.is_empty() { let (mut acc, mut cover): (Option, Option) = (None, None); for (n, &(i, st)) in outs.iter().enumerate() { let paint = self.fx_paint(&st.paint, anchor); let flags = F_STROKE_OUT | if vector_shape { F_VECTOR } else { 0 } | if n == 0 { F_FIRST } else { 0 }; // Share × blended colour, then coverage (both read the coverage so far). let mut p = self.fx_pass_kernel(Kernel::FxStroke, w, content, &paint, st.common.blend, st.common.opacity, flags, clip, sb); if matches!(paint, Paint::None) { // A missing pattern paints nothing: the stroke's share keeps the result as is. p.params[2][2] = 3.0; } p.adjust_kind = 0; p.map = Some(map(i, 0)); p.c = cover.map(|c| self.retain(c)); p.d = acc; let a = self.emit(p); let mut p = self.fx_pass_kernel(Kernel::FxStroke, w, content, &Paint::Color([0.0; 3]), BlendMode::Normal, st.common.opacity, flags, clip, sb); p.adjust_kind = 1; p.map = Some(map(i, 0)); p.c = cover; cover = Some(self.emit(p)); acc = Some(a); } let mut p = Pass::new(Kernel::FxStrokeEnd, 0); p.a = Some(w); p.b = acc; p.c = cover; p.clip = Some(clip); w = self.emit(p); } for &(i, e) in &rev { if let Effect::BevelEmboss(b) = e && let paint = photocraft_compose::effects::bevel_geom(b).paint && paint != photocraft_compose::effects::BevelPaint::Inner { let k = if paint == photocraft_compose::effects::BevelPaint::Both { 2 } else { 0 }; w = self.paint( w, content, Cov::Map(map(i, k), 0.0), &Paint::Color(b.highlight_color.to_rgb()), b.highlight.blend, b.highlight.opacity, 0, clip, sb, ); w = self.paint(w, content, Cov::Map(map(i, k + 1), 0.0), &Paint::Color(b.shadow_color.to_rgb()), b.shadow.blend, b.shadow.opacity, 0, clip, sb); } } let mut p = Pass::new(Kernel::FxMerge, 0); p.a = Some(w); p.b = Some(l); p.c = Some(self.retain(backdrop)); p.mode = if layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend }; p.opacity = layer.opacity; p.flags = if atop { F_ATOP } else { 0 } | gamma_flag(layer); p.extra[3] = photocraft_compose::text_gamma(layer); p.clip = Some(clip); let merged = self.emit(p); self.release(content); // Write the clipped result back into the backdrop (in place when nothing else holds it). let dst = if self.refs[backdrop as usize] == 1 { backdrop } else { let n = self.alloc(); let mut p = Pass::new(Kernel::CopyFull, n); p.a = Some(backdrop); self.passes.push(p); self.release(backdrop); n }; let mut p = Pass::new(Kernel::CopyRect, dst); p.a = Some(merged); p.clip = Some(clip); self.passes.push(p); self.release(merged); Ok(dst) } fn fx_paint(&self, p: &'a FxPaint, anchor: (f64, f64)) -> Paint<'a> { match p { FxPaint::Color(c) => Paint::Color(c.to_rgb()), FxPaint::Gradient(g) => Paint::Gradient(g), FxPaint::Pattern { name, id, scale } => match photocraft_doc::pattern::find(self.cx.patterns, id, name).filter(|p| !p.is_empty()) { Some(pat) => Paint::Pattern(pat, placement(anchor, true, (0.0, 0.0), *scale, 0.0)), None => Paint::None, }, } } /// An effect pass reading `dst` (A, retained) and the layer `content` (B, retained) with its /// paint set up. #[allow(clippy::too_many_arguments)] fn fx_pass(&mut self, dst: Slot, content: Slot, paint: &Paint<'a>, blend: BlendMode, opacity: f32, flags: u32, clip: Rect, sb: Rect) -> Pass<'a> { self.fx_pass_kernel(Kernel::FxPaint, dst, content, paint, blend, opacity, flags, clip, sb) } #[allow(clippy::too_many_arguments)] fn fx_pass_kernel( &mut self, kernel: Kernel, dst: Slot, content: Slot, paint: &Paint<'a>, blend: BlendMode, opacity: f32, flags: u32, clip: Rect, sb: Rect, ) -> Pass<'a> { let mut p = Pass::new(kernel, 0); p.a = Some(self.retain(dst)); p.b = Some(self.retain(content)); p.mode = blend; p.opacity = opacity; p.flags = flags; p.clip = Some(clip); match paint { Paint::None => {} Paint::Color(c) => p.color = [c[0], c[1], c[2], 1.0], Paint::Gradient(g) => { p.params[0] = [g.angle, g.scale, if g.reverse { 1.0 } else { 0.0 }, style_index(g.style)]; p.params[1] = [sb.x0 as f32, sb.y0 as f32, sb.width() as f32, sb.height() as f32]; p.params[2][0] = g.offset.0; p.params[2][1] = g.offset.1; p.params[2][2] = 1.0; let mut rows = vec![[0.0f32; 4096]; 4]; for k in 0..4096 { let v = photocraft_compose::effects::sample_gradient(g, k as f32 / 4095.0); for (ch, row) in rows.iter_mut().enumerate() { row[k] = v[ch]; } } p.lut = Some(rows); } Paint::Pattern(pat, pl) => { p.params[2][2] = 2.0; p.params[3] = [pl.origin.0 as f32, pl.origin.1 as f32, pl.cs.0 as f32, pl.cs.1 as f32]; p.extra = [pl.inv_scale as f32, pat.width as f32, pat.height as f32, 0.0]; p.pattern = Some(pat); } } p } /// One effect paint into `dst` (consumed); reads the layer's alpha from `content` (kept). #[allow(clippy::too_many_arguments)] fn paint(&mut self, dst: Slot, content: Slot, cov: Cov, paint: &Paint<'a>, blend: BlendMode, opacity: f32, flags: u32, clip: Rect, sb: Rect) -> Slot { self.paint_k(dst, content, cov, paint, blend, opacity, flags, clip, sb, 1.0) } /// [`Self::paint`] with the knockout strength (`F_KNOCKOUT`: coverage × (1 − alpha × k)). #[allow(clippy::too_many_arguments)] fn paint_k( &mut self, dst: Slot, content: Slot, cov: Cov, paint: &Paint<'a>, blend: BlendMode, opacity: f32, flags: u32, clip: Rect, sb: Rect, knockout: f32, ) -> Slot { if matches!(paint, Paint::None) { // Missing pattern: compose paints nothing. return dst; } let mut p = self.fx_pass(dst, content, paint, blend, opacity, flags, clip, sb); self.release(dst); match cov { Cov::Map(m, outside) => { p.adjust_kind = 0; p.map = Some(m); p.params[2][3] = outside; } Cov::One => p.adjust_kind = 1, } p.extra[3] = knockout; self.emit(p) } } /// Paint source of an effect. enum Paint<'a> { None, Color([f32; 3]), Gradient(&'a Gradient), Pattern(&'a Pattern, Placement), } /// `compose::pattern::Placement` (origin, inverse rotation, inverse scale). #[derive(Clone, Copy, Debug)] struct Placement { origin: (f64, f64), cs: (f64, f64), inv_scale: f64, } /// `Placement::anchored`: linked patterns tile from `anchor` (the effects reference point). fn placement(anchor: (f64, f64), link: bool, phase: (f32, f32), scale: f32, angle: f32) -> Placement { let base = if link { anchor } else { (0.0, 0.0) }; let origin = (base.0 + f64::from(phase.0), base.1 + f64::from(phase.1)); let s = f64::from(scale); let inv_scale = if s.is_finite() && s > 1e-3 { 1.0 / s } else { 1.0 }; let a = f64::from(angle).to_radians(); Placement { origin, cs: (a.cos(), a.sin()), inv_scale } } /// (inside width, outside width) of a stroke. pub fn stroke_widths(st: &photocraft_doc::StrokeFx) -> (f32, f32) { match st.position { StrokePosition::Outside => (0.0, st.size), StrokePosition::Inside => (st.size, 0.0), StrokePosition::Center => (st.size / 2.0, st.size / 2.0), } } fn style_index(s: photocraft_doc::GradientStyle) -> f32 { match s { photocraft_doc::GradientStyle::Linear => 0.0, photocraft_doc::GradientStyle::Radial => 1.0, photocraft_doc::GradientStyle::Angle => 2.0, photocraft_doc::GradientStyle::Reflected => 3.0, photocraft_doc::GradientStyle::Diamond => 4.0, } } fn sample_stops4(stops: &[(f32, [f32; 4])], t: f32) -> [f32; 4] { match stops { [] => [0.0; 4], [only] => only.1, _ => { if t <= stops[0].0 { return stops[0].1; } for w in stops.windows(2) { let (a, b) = (&w[0], &w[1]); if t <= b.0 { let k = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 0.0 }; return std::array::from_fn(|i| a.1[i] + (b.1[i] - a.1[i]) * k); } } stops[stops.len() - 1].1 } } } fn lut_rows(f: impl Fn(usize, f32) -> f32, rows: usize) -> Vec<[f32; 4096]> { (0..rows) .map(|r| { let mut row = [0.0f32; 4096]; for (k, v) in row.iter_mut().enumerate() { *v = f(r, k as f32 / 4095.0); } row }) .collect() } type Program = (i32, [[f32; 4]; 4], Option>); /// Adjustment → (kernel kind, parameters, LUT rows). Kinds are the `switch` in `adjust()`. pub fn adjustment_program(adj: &Adjustment, transfer: Transfer) -> Program { let mut p = [[0.0f32; 4]; 4]; match adj { Adjustment::Invert => (1, p, None), Adjustment::Threshold { level } => { p[0][0] = (level * 255.0).round(); (2, p, None) } Adjustment::Posterize { levels } => { p[0][0] = (*levels).clamp(2, 255) as f32; (3, p, None) } Adjustment::BrightnessContrast { brightness, contrast, legacy: true } => { let c = contrast.clamp(-100.0, 99.0); let k = if c >= 0.0 { 1.0 / (1.0 - c / 100.0) } else { 1.0 + c / 100.0 }; p[0] = [brightness / 255.0, k, 0.0, 0.0]; (4, p, None) } Adjustment::BrightnessContrast { brightness, contrast, .. } => { // Modern B/C: the shader rebuilds the curves from raw slider values (see compose.wgsl // `mbright`/`mcontrast`, mirroring compose::adjust::modern_brightness/modern_contrast). p[0] = [*brightness, *contrast, 0.0, 0.0]; (5, p, None) } Adjustment::Exposure { exposure, offset, gamma } => { let g = match transfer { Transfer::Srgb => 0.0, Transfer::Gamma(g) => g, }; p[0] = [2f32.powf(*exposure), *offset, gamma.max(0.01), g]; (6, p, None) } Adjustment::Levels { master, per_channel } => (7, p, Some(lut_rows(|i, v| adjust::levels(&per_channel[i], adjust::levels(master, v)), 3))), Adjustment::Curves { master, per_channel } => { let m = adjust::curve_lut(master); let chans: Vec> = per_channel.iter().map(|c| adjust::curve_lut(c)).collect(); let lut = |t: &[f32], v: f32| { let x = v.clamp(0.0, 1.0) * (t.len() - 1) as f32; let i = x.floor() as usize; let j = (i + 1).min(t.len() - 1); let f = x - i as f32; t[i] * (1.0 - f) + t[j] * f }; // The CPU LUT is `m(ch[k])` per entry; entries are on the same 4096 grid. let rows = (0..3) .map(|r| { let mut row = [0.0f32; 4096]; for (k, v) in row.iter_mut().enumerate() { *v = lut(&m, chans[r][k.min(chans[r].len() - 1)]); } row }) .collect(); (7, p, Some(rows)) } Adjustment::HueSaturation { hue, saturation, lightness, colorize } => { p[0] = [*hue, saturation / 100.0, lightness / 100.0, if *colorize { 1.0 } else { 0.0 }]; (8, p, None) } Adjustment::Vibrance { vibrance, saturation } => { p[0] = [vibrance / 100.0, saturation / 100.0, 0.0, 0.0]; (9, p, None) } Adjustment::ChannelMixer { matrix, monochrome } => { p[0] = matrix[0]; p[1] = matrix[1]; p[2] = matrix[2]; p[3][0] = if *monochrome { 1.0 } else { 0.0 }; (10, p, None) } Adjustment::PhotoFilter { color, density, preserve_luminosity } => { p[0] = [color[0], color[1], color[2], *density]; p[1][0] = if *preserve_luminosity { 1.0 } else { 0.0 }; (11, p, None) } Adjustment::BlackWhite { weights, tint } => { p[0] = [weights[0], weights[1], weights[2], weights[3]]; p[1] = [weights[4], weights[5], if tint.is_some() { 1.0 } else { 0.0 }, 0.0]; if let Some(t) = tint { p[2] = [t[0], t[1], t[2], 0.0]; } (12, p, None) } Adjustment::GradientMap { stops, reverse } => { p[0][0] = if *reverse { 1.0 } else { 0.0 }; let s4: Vec<(f32, [f32; 4])> = stops.iter().map(|(t, c)| (*t, [c[0], c[1], c[2], 1.0])).collect(); let rows = lut_rows(|r, t| if s4.is_empty() { t } else { sample_stops4(&s4, t)[r] }, 3); (13, p, Some(rows)) } Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity } => { p[0] = [shadows[0], shadows[1], shadows[2], 0.0]; p[1] = [midtones[0], midtones[1], midtones[2], 0.0]; p[2] = [highlights[0], highlights[1], highlights[2], 0.0]; p[3][0] = if *preserve_luminosity { 1.0 } else { 0.0 }; (14, p, None) } Adjustment::SelectiveColor { relative, adjustments } => { // 9 ranges × CMYK don't fit the 16 params: they go in LUT row 0 (read texel-exact). p[0][0] = if *relative { 1.0 } else { 0.0 }; let mut row = [0.0f32; 4096]; for (i, v) in adjustments.iter().flatten().enumerate() { row[i] = *v; } (15, p, Some(vec![row])) } Adjustment::ColorLookup { lut: Some(table), size, tetrahedral, dither, .. } if *size >= 2 && table.len() >= (*size as usize).pow(3) * 3 => { // The flattened table (n³ RGB triplets) spans as many 4096-wide rows as it needs. let n = *size as usize; let len = n * n * n * 3; let rows = table[..len] .chunks(4096) .map(|c| { let mut row = [0.0f32; 4096]; row[..c.len()].copy_from_slice(c); row }) .collect(); p[0] = [n as f32, if *tetrahedral { 1.0 } else { 0.0 }, if *dither { 1.0 } else { 0.0 }, 0.0]; (16, p, Some(rows)) } // Identity on the CPU too (not evaluated there). Adjustment::ColorLookup { .. } | Adjustment::Unsupported { .. } => (0, p, None), } } /// Mode index used by the shader (declaration order of [`BlendMode`]). pub fn mode_index(m: BlendMode) -> i32 { match m { BlendMode::PassThrough => 0, BlendMode::Normal => 1, BlendMode::Dissolve => 2, BlendMode::Darken => 3, BlendMode::Multiply => 4, BlendMode::ColorBurn => 5, BlendMode::LinearBurn => 6, BlendMode::DarkerColor => 7, BlendMode::Lighten => 8, BlendMode::Screen => 9, BlendMode::ColorDodge => 10, BlendMode::LinearDodge => 11, BlendMode::LighterColor => 12, BlendMode::Overlay => 13, BlendMode::SoftLight => 14, BlendMode::HardLight => 15, BlendMode::VividLight => 16, BlendMode::LinearLight => 17, BlendMode::PinLight => 18, BlendMode::HardMix => 19, BlendMode::Difference => 20, BlendMode::Exclusion => 21, BlendMode::Subtract => 22, BlendMode::Divide => 23, BlendMode::Hue => 24, BlendMode::Saturation => 25, BlendMode::Color => 26, BlendMode::Luminosity => 27, } } #[cfg(test)] mod tests { use super::*; use photocraft_color::{Color, ColorMode, SampleType}; use photocraft_geom::Size; #[test] fn slots_are_recycled() { let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::WHITE); for i in 0..10 { d.layers.push(Layer::raster(format!("l{i}"), d.pixel_format())); } let p = plan(&d).unwrap(); assert!(p.slots <= 3, "{} slots", p.slots); assert_eq!(p.passes.len(), 1 + 11 * 2); } #[test] fn effects_are_planned_in_place() { let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::WHITE); let mut l = Layer::raster("fx", d.pixel_format()); l.surface_mut().unwrap().fill_rect(Rect::new(2, 2, 5, 5), &[1.0, 0.0, 0.0, 1.0]); l.effects.items.push(photocraft_doc::Effect::ColorOverlay { common: photocraft_doc::FxCommon::new(BlendMode::Normal, 1.0), color: Color::WHITE }); l.effects.items.push(photocraft_doc::Effect::default_drop_shadow()); d.layers.push(l); let p = plan(&d).unwrap(); assert_eq!(p.fx.len(), 1); let m = photocraft_compose::effects::margin(&d.layers[1]); assert_eq!(p.fx[0].region, Rect::new(2, 2, 5, 5).inflate(m).intersect(&d.bounds().inflate(m))); // The effect result lands back in the backdrop slot. let last = p.passes.last().unwrap(); assert_eq!(last.kernel, Kernel::CopyRect); assert_eq!(last.dst, p.root); assert!(p.slots <= 6, "{} slots", p.slots); } #[test] fn blend_if_falls_back_to_the_cpu() { let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::WHITE); let mut l = Layer::raster("bi", d.pixel_format()); l.blend_if.set(0, [photocraft_doc::BlendRange { black: [40, 40], white: [255, 255] }, photocraft_doc::BlendRange::FULL]); d.layers.push(l.clone()); assert!(plan(&d).unwrap_err().0.contains("Blend If")); // Clipped layers too. l.clipped = true; d.layers[1].blend_if = Default::default(); d.layers.push(l); assert!(plan(&d).is_err()); } }