diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index f1fc5b1146..5119c694ea 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -370,11 +370,13 @@ pub struct UiSetParams { /// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool, /// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the /// active layer's storage tiles). layerFilter (an object patching by ("kind", "name", "effect", - /// "mode", "color", "smartObject" or "artboard", which lists the artboards that hold the - /// selected layers), kinds (a list of layer kinds), name (matches part of the layer name), - /// effect ("bevelEmboss", "stroke", "innerShadow", "innerGlow", "satin", "overlay", - /// "outerGlow" or "dropShadow"), mode (a blend mode name), color (a layer colour label, or - /// "none") and smart (a list of "linked", "missing" and "embedded") for the Layers panel + /// "mode", "attribute", "color", "smartObject" or "artboard", which lists the artboards that + /// hold the selected layers), kinds (a list of layer kinds), name (matches part of the layer + /// name), effect ("bevelEmboss", "stroke", "innerShadow", "innerGlow", "satin", "overlay", + /// "outerGlow" or "dropShadow"), mode (a blend mode name), attribute ("visible", "locked", + /// "empty", "linked", "clipped", "layerMask", "vectorMask", "layerEffects", + /// "advancedBlending", or "notVisible" … "noAdvancedBlending"), color (a layer colour label, + /// or "none") and smart (a list of "linked", "missing" and "embedded") for the Layers panel /// filter). strokeTrace (true, false, "stderr" or "off": the stroke latency trace at runtime; a /// CSV file is set at launch with --stroke-trace, not over this channel). updatesOpen (bool, /// the Help › Check for Updates window). diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 48e958e646..d190d110b9 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -890,7 +890,7 @@ fn ui_set_drives_the_layers_panel_artboard_filter() { assert_eq!(app.ui.layer_filter_by, FilterBy::Artboard); let bad = call(&mut app, &ctx, "ui.set", json!({"layerFilter": {"by": "artboards"}})); assert_eq!(bad["ok"], false, "{bad}"); - assert!(bad.to_string().contains("kind, name, effect, mode, color, smartObject or artboard"), "{bad}"); + assert!(bad.to_string().contains("kind, name, effect, mode, attribute, color, smartObject or artboard"), "{bad}"); assert_eq!(app.ui.layer_filter_by, FilterBy::Artboard, "a bad value changed nothing"); } diff --git a/crates/ui-egui/src/panels/layer_filter_ui.rs b/crates/ui-egui/src/panels/layer_filter_ui.rs index 30d85e3f84..d9c9041dd7 100644 --- a/crates/ui-egui/src/panels/layer_filter_ui.rs +++ b/crates/ui-egui/src/panels/layer_filter_ui.rs @@ -1,9 +1,10 @@ //! The Layers panel's filter row (#5048): a filter-type dropdown, as in Photoshop, then the //! controls for that type. Kind toggles layer kinds, Name matches part of the layer name, Effect -//! picks one effect kind (#10880), Mode picks one blend mode, Color one layer colour label (#5898), -//! Smart Object toggles linked, missing and embedded smart objects, Artboard lists only the -//! artboards that hold the selected layers. The criteria live in `UiState` (`layer_filter*`), so -//! the control channel can read and set them. +//! picks one effect kind (#10880), Mode picks one blend mode, Attribute one layer attribute or +//! its opposite (#10881), Color one layer colour label (#5898), Smart Object toggles linked, +//! missing and embedded smart objects, Artboard lists only the artboards that hold the selected +//! layers. The criteria live in `UiState` (`layer_filter*`), so the control channel can read and +//! set them. use std::collections::HashSet; @@ -23,6 +24,7 @@ pub enum FilterBy { Name, Effect, Mode, + Attribute, Color, SmartObject, Artboard, @@ -37,6 +39,7 @@ pub struct Filter { pub name: String, pub mode: BlendMode, pub effect: effect::EffectKind, + pub attribute: attribute::Attribute, pub color: LabelColor, /// The Smart Object toggles that are on, from [`SMART`]. pub smart: Vec, @@ -45,6 +48,9 @@ pub struct Filter { pub missing: HashSet, /// The rows the Artboard filter lists ([`board_rows`]); empty lists every row. pub board_rows: HashSet, + /// The empty layers the Attribute filter's Empty and Not Empty use + /// ([`attribute::empty_layers`]); `None` reads each layer's pixels. + pub empty: Option>>, } impl Filter { @@ -55,16 +61,19 @@ impl Filter { name: ui.layer_filter_name.clone(), mode: ui.layer_filter_mode, effect: ui.layer_filter_effect, + attribute: ui.layer_filter_attribute, color: ui.layer_filter_color, smart: ui.layer_filter_smart.clone(), missing: HashSet::new(), board_rows: HashSet::new(), + empty: None, } } /// Returns [`Filter::of`] for `app`'s settings, with the active document's missing linked /// files while the panel filters by Smart Object, and `board_rows` set to the rows of the - /// artboards that hold the active document's selected layers while it filters by Artboard. + /// artboards that hold the active document's selected layers while it filters by Artboard, + /// and `empty` set while it filters by Empty or Not Empty. pub fn of_app(app: &PhotocraftApp) -> Self { let mut f = Self::of(&app.ui); if f.by == FilterBy::SmartObject { @@ -75,18 +84,21 @@ impl Filter { { f.board_rows = board_rows(&d.doc, &d.selected_layers()); } + if f.by == FilterBy::Attribute { + f.empty = attribute::empty_layers(app, f.attribute); + } f } - /// Whether the panel is filtered. Like Photoshop, Effect, Mode and Color always filter (by - /// Bevel & Emboss, by Normal and by no colour at first); Kind, Name and Smart Object only once a - /// toggle is on or some text is typed; Artboard only while some layers are selected and each of - /// them is in an artboard. + /// Whether the panel is filtered. Like Photoshop, Effect, Mode, Attribute and Color always + /// filter (by Bevel & Emboss, by Normal, by Visible and by no colour at first); Kind, Name and + /// Smart Object only once a toggle is on or some text is typed; Artboard only while some layers + /// are selected and each of them is in an artboard. pub fn active(&self) -> bool { match self.by { FilterBy::Kind => !self.kinds.is_empty(), FilterBy::Name => !self.name.trim().is_empty(), - FilterBy::Effect | FilterBy::Mode | FilterBy::Color => true, + FilterBy::Effect | FilterBy::Mode | FilterBy::Attribute | FilterBy::Color => true, FilterBy::SmartObject => !self.smart.is_empty(), FilterBy::Artboard => !self.board_rows.is_empty(), } @@ -102,6 +114,7 @@ impl Filter { FilterBy::Name => l.name.to_lowercase().contains(&self.name.trim().to_lowercase()), FilterBy::Effect => self.effect.shows(l), FilterBy::Mode => l.blend == self.mode, + FilterBy::Attribute => self.attribute.shows(l, self.empty.as_deref()), FilterBy::Color => l.label == self.color, FilterBy::SmartObject => { let LayerContent::Smart(sm) = &l.content else { return false }; @@ -157,21 +170,23 @@ pub const KINDS: [&str; 5] = ["pixel", "adjustment", "type", "shape", "smart"]; /// not there, and embedded in the document. pub const SMART: [&str; 3] = ["linked", "missing", "embedded"]; -/// `ui.set {layerFilter: {by?, kinds?, name?, effect?, mode?, color?, smart?}}`: `current` with -/// the given fields replaced, or an error naming the bad field (nothing changes then). `by` is -/// `kind`, `name`, `effect`, `mode`, `color`, `smartObject` or `artboard`; `kinds` a list of -/// [`KINDS`]; `effect` an [`effect::EffectKind`] id; `mode` one of the layer blend modes such as -/// `Multiply` (not Pass Through, which the Mode list doesn't offer, #10878); `color` a label colour -/// id (`none`, `red`, … `gray`, [`LabelColor::id`]); `smart` a list of [`SMART`]. Artboard has no -/// criteria of its own: the selected layers pick the artboards. +/// `ui.set {layerFilter: {by?, kinds?, name?, effect?, mode?, attribute?, color?, smart?}}`: +/// `current` with the given fields replaced, or an error naming the bad field (nothing changes +/// then). `by` is `kind`, `name`, `effect`, `mode`, `attribute`, `color`, `smartObject` or +/// `artboard`; `kinds` a list of [`KINDS`]; `effect` an [`effect::EffectKind`] id; `mode` one of +/// the layer blend modes such as `Multiply` (not Pass Through, which the Mode list doesn't offer, +/// #10878); `attribute` an [`attribute::Attribute`] id; `color` a label colour id (`none`, `red`, +/// … `gray`, [`LabelColor::id`]); `smart` a list of [`SMART`]. Artboard has no criteria of its +/// own: the selected layers pick the artboards. pub fn patched(current: &UiState, v: &serde_json::Value) -> Result { let Some(o) = v.as_object() else { return Err("layerFilter must be an object".into()) }; - if let Some(k) = o.keys().find(|k| !["by", "kinds", "name", "effect", "mode", "color", "smart"].contains(&k.as_str())) { - return Err(format!("unknown layerFilter field `{k}` (fields: by, kinds, name, effect, mode, color, smart)")); + if let Some(k) = o.keys().find(|k| !["by", "kinds", "name", "effect", "mode", "attribute", "color", "smart"].contains(&k.as_str())) { + return Err(format!("unknown layerFilter field `{k}` (fields: by, kinds, name, effect, mode, attribute, color, smart)")); } let mut f = Filter::of(current); if let Some(by) = o.get("by") { - f.by = serde_json::from_value(by.clone()).map_err(|_| "layerFilter.by must be kind, name, effect, mode, color, smartObject or artboard".to_string())?; + f.by = serde_json::from_value(by.clone()) + .map_err(|_| "layerFilter.by must be kind, name, effect, mode, attribute, color, smartObject or artboard".to_string())?; } if let Some(kinds) = o.get("kinds") { let list = kinds.as_array().ok_or("layerFilter.kinds must be a list")?; @@ -198,6 +213,9 @@ pub fn patched(current: &UiState, v: &serde_json::Value) -> Result attribute::controls(app, ui), FilterBy::Color => { // Photoshop's list: None, then the label colours in the Layers panel menu's order. let colors: Vec<(LabelColor, &str)> = @@ -328,6 +349,11 @@ pub mod selection; #[cfg(test)] mod effect_tests; + +pub mod attribute; + +#[cfg(test)] +mod attribute_tests; #[cfg(test)] mod selection_tests; #[cfg(test)] diff --git a/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs b/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs new file mode 100644 index 0000000000..e627a9d8a8 --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/attribute.rs @@ -0,0 +1,258 @@ +//! The Layers panel's Attribute filter (#10881): lists the layers with one attribute (visible, +//! locked, empty, linked, clipped, masks, effects, advanced blending) or without it. + +use std::collections::HashSet; +use std::sync::{Arc, Mutex, PoisonError, Weak}; + +use photocraft_color::SampleType; +use photocraft_doc::{AdvancedBlending, Document, Layer, LayerContent, LayerId}; +use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; +use photocraft_raster::Surface; +use serde::{Deserialize, Serialize}; + +use crate::{PhotocraftApp, widgets}; + +/// The attributes the Attribute filter picks from, in Photoshop's order: the nine attributes, +/// then their opposites. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Attribute { + #[default] + Visible, + Locked, + Empty, + Linked, + Clipped, + LayerMask, + VectorMask, + LayerEffects, + AdvancedBlending, + NotVisible, + NotLocked, + NotEmpty, + NotLinked, + NotClipped, + NoLayerMask, + NoVectorMask, + NoLayerEffects, + NoAdvancedBlending, +} + +impl Attribute { + pub const ALL: [Attribute; 18] = [ + Attribute::Visible, + Attribute::Locked, + Attribute::Empty, + Attribute::Linked, + Attribute::Clipped, + Attribute::LayerMask, + Attribute::VectorMask, + Attribute::LayerEffects, + Attribute::AdvancedBlending, + Attribute::NotVisible, + Attribute::NotLocked, + Attribute::NotEmpty, + Attribute::NotLinked, + Attribute::NotClipped, + Attribute::NoLayerMask, + Attribute::NoVectorMask, + Attribute::NoLayerEffects, + Attribute::NoAdvancedBlending, + ]; + + /// The id `ui.set {layerFilter: {attribute}}` takes. + pub fn id(self) -> &'static str { + match self { + Attribute::Visible => "visible", + Attribute::Locked => "locked", + Attribute::Empty => "empty", + Attribute::Linked => "linked", + Attribute::Clipped => "clipped", + Attribute::LayerMask => "layerMask", + Attribute::VectorMask => "vectorMask", + Attribute::LayerEffects => "layerEffects", + Attribute::AdvancedBlending => "advancedBlending", + Attribute::NotVisible => "notVisible", + Attribute::NotLocked => "notLocked", + Attribute::NotEmpty => "notEmpty", + Attribute::NotLinked => "notLinked", + Attribute::NotClipped => "notClipped", + Attribute::NoLayerMask => "noLayerMask", + Attribute::NoVectorMask => "noVectorMask", + Attribute::NoLayerEffects => "noLayerEffects", + Attribute::NoAdvancedBlending => "noAdvancedBlending", + } + } + + fn label(self) -> &'static str { + match self { + Attribute::Visible => tl!("Visible"), + Attribute::Locked => tl!("Locked"), + Attribute::Empty => tl!("Empty"), + Attribute::Linked => tl!("Linked"), + Attribute::Clipped => tl!("Clipped"), + Attribute::LayerMask => tl!("Layer Mask"), + Attribute::VectorMask => tl!("Vector Mask"), + Attribute::LayerEffects => tl!("Layer Effects"), + Attribute::AdvancedBlending => tl!("Advanced Blending"), + Attribute::NotVisible => tl!("Not Visible"), + Attribute::NotLocked => tl!("Not Locked"), + Attribute::NotEmpty => tl!("Not Empty"), + Attribute::NotLinked => tl!("Not Linked"), + Attribute::NotClipped => tl!("Not Clipped"), + Attribute::NoLayerMask => tl!("No Layer Mask"), + Attribute::NoVectorMask => tl!("No Vector Mask"), + Attribute::NoLayerEffects => tl!("No Layer Effects"), + Attribute::NoAdvancedBlending => tl!("No Adv. Blending"), + } + } + + /// Whether `l` is listed under this attribute. Each "Not …"/"No …" entry lists the layers + /// (and groups) its positive entry doesn't. `empty` is the document's empty layers + /// ([`empty_layers_of`]); without it, Empty reads `l`'s pixels without knowing the canvas, so a + /// layer with an opaque default pixel is never empty. + pub fn shows(self, l: &Layer, empty: Option<&HashSet>) -> bool { + match self { + Attribute::Visible => l.visible, + // Any lock counts, the Background's too. + Attribute::Locked => l.locks.any(), + // A pixel layer with no visible pixels; an empty group isn't listed. + Attribute::Empty => empty.map_or_else(|| is_empty(l, None), |set| set.contains(&l.id)), + Attribute::Linked => l.link_group.is_some(), + // The clipped layer, not its base. + Attribute::Clipped => l.clipped, + Attribute::LayerMask => l.mask.is_some(), + // A shape layer's outline is its vector mask. + Attribute::VectorMask => l.vector_mask.is_some() || matches!(l.content, LayerContent::Shape(_)), + // Also a layer whose only effect is turned off, unlike the Effect filter. + Attribute::LayerEffects => !l.effects.items.is_empty(), + Attribute::AdvancedBlending => advanced_blending(l), + Attribute::NotVisible => !Attribute::Visible.shows(l, empty), + Attribute::NotLocked => !Attribute::Locked.shows(l, empty), + Attribute::NotEmpty => !Attribute::Empty.shows(l, empty), + Attribute::NotLinked => !Attribute::Linked.shows(l, empty), + Attribute::NotClipped => !Attribute::Clipped.shows(l, empty), + Attribute::NoLayerMask => !Attribute::LayerMask.shows(l, empty), + Attribute::NoVectorMask => !Attribute::VectorMask.shows(l, empty), + Attribute::NoLayerEffects => !Attribute::LayerEffects.shows(l, empty), + Attribute::NoAdvancedBlending => !Attribute::AdvancedBlending.shows(l, empty), + } + } +} + +/// Whether `l` is a pixel layer with no visible pixels on `canvas`; an empty group isn't listed. +fn is_empty(l: &Layer, canvas: Option) -> bool { + matches!(&l.content, LayerContent::Raster(s) if !has_visible_pixels(s, canvas)) +} + +/// Whether `s` has a pixel with alpha > 0. The Eraser only clears alpha, so an erased pixel keeps +/// its colour bytes and `content_bounds` still counts it (as Reveal All's `visible_bounds` notes, +/// #9379). A surface without an alpha channel covers everything. With an opaque default pixel, +/// every part of `canvas` without a stored tile shows it, so only stored tiles that cover the +/// canvas and are clear on it make the layer empty (pixels outside the canvas are left out there, +/// as the default reaches beyond it anyway); without a `canvas` such a layer always counts. +fn has_visible_pixels(s: &Surface, canvas: Option) -> bool { + let fmt = s.format(); + if !fmt.alpha { + return true; + } + let clip = match (s.default_pixel().last().is_none_or(|a| *a > 0.0), canvas) { + (false, _) => None, + (true, Some(canvas)) if covers(s, canvas) => Some(canvas), + (true, _) => return true, + }; + let (n, bpp, size) = (fmt.channels(), fmt.bytes_per_pixel(), fmt.sample.bytes()); + let Some(at) = n.checked_sub(1).and_then(|i| i.checked_mul(size)).filter(|_| bpp > 0) else { return false }; + // Integer alpha is above 0 where any of its bytes is, so a block of pixels is OR-ed together + // (which vectorises) before looking for one; only float alpha needs decoding. + let visible = |block: &[u8]| match fmt.sample { + SampleType::F32 => block.chunks_exact(bpp).any(|px| px.get(at..at + size).is_some_and(|a| a.try_into().is_ok_and(|b| f32::from_ne_bytes(b) > 0.0))), + SampleType::U8 | SampleType::U16 => { + block.chunks_exact(bpp).fold(0u8, |acc, px| px.get(at..at + size).map_or(acc, |a| a.iter().fold(acc, |acc, b| acc | b))) != 0 + } + }; + let tile_visible = |c: &TileCoord, t: &Arc| { + let bytes = t.bytes(); + let r = c.rect(); + let Some(clip) = clip.filter(|clip| !clip.contains_rect(&r)) else { return bytes.chunks(bpp.saturating_mul(1024)).any(visible) }; + // Only the part of the tile on the canvas, row by row. + let on = r.intersect(&clip); + let row = (TILE_SIZE as usize).saturating_mul(bpp); + let (x0, x1) = ((on.x0 - r.x0) as usize * bpp, (on.x1 - r.x0) as usize * bpp); + (on.y0 - r.y0..on.y1 - r.y0).any(|y| bytes.get(y as usize * row + x0..y as usize * row + x1).is_some_and(visible)) + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + let tiles: Vec<_> = s.tiles().filter(|(c, _)| clip.is_none_or(|clip| !c.rect().intersect(&clip).is_empty())).collect(); + tiles.par_iter().any(|(c, t)| tile_visible(c, t)) + } + #[cfg(target_arch = "wasm32")] + s.tiles().filter(|(c, _)| clip.is_none_or(|clip| !c.rect().intersect(&clip).is_empty())).any(|(c, t)| tile_visible(c, t)) +} + +/// Whether `s` stores a tile for every part of `canvas`. +fn covers(s: &Surface, canvas: Rect) -> bool { + if canvas.is_empty() { + return true; + } + let (first, last) = (TileCoord::containing(canvas.x0, canvas.y0), TileCoord::containing(canvas.x1 - 1, canvas.y1 - 1)); + let needed = (i64::from(last.tx) - i64::from(first.tx) + 1) * (i64::from(last.ty) - i64::from(first.ty) + 1); + let stored = s.tiles().filter(|(c, _)| (first.tx..=last.tx).contains(&c.tx) && (first.ty..=last.ty).contains(&c.ty)).count(); + i64::try_from(stored).is_ok_and(|stored| stored == needed) +} + +/// The empty pixel layers of `doc`. +pub fn empty_layers_of(doc: &Document) -> HashSet { + let canvas = Some(doc.bounds()); + doc.walk().into_iter().filter(|(_, _, l)| is_empty(l, canvas)).map(|(_, _, l)| l.id).collect() +} + +/// The document [`empty_layers`] last read and what it found. A `Weak` keeps the document's +/// allocation from being reused while it is held, and makes `Arc::make_mut` copy the document on +/// its next edit even when nothing else holds it, so the same allocation always means the same +/// layers and pixels. +static EMPTY: Mutex> = Mutex::new(None); + +type EmptyCache = (Weak, Arc>); + +/// The empty layers of `app`'s active document while Empty or Not Empty is picked, found once per +/// document: a layer erased to nothing has every pixel read, too much for every repaint of the +/// Layers panel. Keyed by the document itself rather than its revision, which also moves when only +/// the selection does. +pub fn empty_layers(app: &PhotocraftApp, attribute: Attribute) -> Option>> { + if !matches!(attribute, Attribute::Empty | Attribute::NotEmpty) { + return None; + } + let d = app.session.active()?; + let mut cache = EMPTY.lock().unwrap_or_else(PoisonError::into_inner); + if let Some((doc, set)) = cache.as_ref() + && std::ptr::eq(doc.as_ptr(), Arc::as_ptr(&d.doc)) + { + return Some(set.clone()); + } + let set = Arc::new(empty_layers_of(&d.doc)); + *cache = Some((Arc::downgrade(&d.doc), set.clone())); + Some(set) +} + +/// Whether any Advanced Blending option is away from its default: a channel left out, Knockout, +/// the four "as Group" / "Transparency Shapes" / "Hides Effects" switches, or a Blend If range. +/// Fill opacity alone doesn't count. +fn advanced_blending(l: &Layer) -> bool { + l.excluded_channels != 0 || l.advanced != AdvancedBlending::default() || !l.blend_if.is_default() +} + +/// `layerFilter.attribute`: the attribute named by `v`, or an error listing the ids. +pub fn parse(v: &serde_json::Value) -> Result { + let ids = || Attribute::ALL.map(Attribute::id).join(", "); + let name = v.as_str().ok_or_else(|| format!("layerFilter.attribute must be one of {}", ids()))?; + Attribute::ALL.into_iter().find(|a| a.id() == name).ok_or_else(|| format!("layerFilter.attribute: unknown attribute `{name}` (attributes: {})", ids())) +} + +/// The Attribute filter's controls: the attribute dropdown. +pub fn controls(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let attributes: Vec<(Attribute, &str)> = Attribute::ALL.into_iter().map(|a| (a, a.label())).collect(); + let width = ui.available_width().max(96.0); + widgets::dropdown(ui, "layer-filter-attribute", tl!("Attribute"), &mut app.ui.layer_filter_attribute, &attributes, width); +} diff --git a/crates/ui-egui/src/panels/layer_filter_ui/attribute_tests.rs b/crates/ui-egui/src/panels/layer_filter_ui/attribute_tests.rs new file mode 100644 index 0000000000..fad5460990 --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/attribute_tests.rs @@ -0,0 +1,321 @@ +//! #10881: the Layers panel's Attribute filter, against the cases measured in Photoshop. + +use std::collections::BTreeSet; + +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use photocraft_doc::{BlendRange, Document, Group, Knockout, Layer, LayerContent, LayerMask, VectorMask}; +use serde_json::json; + +use super::attribute::Attribute; +use super::{Filter, FilterBy}; +use crate::PhotocraftApp; +use crate::state::UiState; + +const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0]; + +/// One layer per measured case. Every pixel layer has pixels except "empty"; the adjustment, fill +/// and shape layers come from their commands, so they carry the masks those give them. +fn session() -> photocraft_engine::Session { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + let pixel_layers = [ + "pixels", + "hidden", + "position lock", + "all locked", + "empty", + "linked a", + "linked b", + "base", + "masked", + "vector masked", + "fx", + "fx off", + "fill 50", + "adv channels", + "adv knockout", + "adv interior", + "adv clipped", + "adv shapes", + "adv layer mask", + "adv vector mask", + "adv blend if", + ]; + for name in pixel_layers { + s.execute("layer.new.layer", json!({"name": name})).unwrap(); + match name { + "base" => { + s.execute("layer.newAdjustmentLayer.levels", json!({})).unwrap(); + rename_active(&mut s, "clipped"); + s.execute("layer.createClippingMask", json!({})).unwrap(); + } + "fx" => { + s.execute("layer.layerStyle.dropShadow", json!({})).unwrap(); + } + "fx off" => { + s.execute("layer.layerStyle.replace", json!({"effects": [{"kind": "dropShadow", "params": {"enabled": false}}]})).unwrap(); + } + _ => {} + } + } + s.execute("layer.newFillLayer.solidColor", json!({"color": "#00ff00"})).unwrap(); + rename_active(&mut s, "fill"); + s.execute("shape.create", json!({"kind": "rect", "rect": [10, 10, 40, 20], "fill": "#ff0000"})).unwrap(); + rename_active(&mut s, "shape"); + + let doc = std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc); + let full = photocraft_geom::Rect::new(0, 0, 64, 48); + for name in pixel_layers.into_iter().filter(|n| *n != "empty") { + let l = layer(doc, name); + let LayerContent::Raster(surface) = &mut l.content else { panic!("{name} is a pixel layer") }; + surface.fill_rect(full, &RED); + } + layer(doc, "hidden").visible = false; + layer(doc, "position lock").locks.position = true; + layer(doc, "all locked").locks.all = true; + layer(doc, "linked a").link_group = Some(7); + layer(doc, "linked b").link_group = Some(7); + layer(doc, "masked").mask = Some(LayerMask::reveal_all()); + layer(doc, "vector masked").vector_mask = Some(VectorMask { path: Default::default(), enabled: true, linked: true, density: 1.0, feather: 0.0 }); + layer(doc, "fill 50").fill_opacity = 0.5; + layer(doc, "adv channels").excluded_channels = 0b010; + layer(doc, "adv knockout").advanced.knockout = Knockout::Shallow; + layer(doc, "adv interior").advanced.blend_interior = true; + layer(doc, "adv clipped").advanced.blend_clipped = false; + layer(doc, "adv shapes").advanced.transparency_shapes = false; + layer(doc, "adv layer mask").advanced.layer_mask_hides_effects = true; + layer(doc, "adv vector mask").advanced.vector_mask_hides_effects = true; + layer(doc, "adv blend if").blend_if.ranges = vec![[BlendRange { black: [40, 40], white: [255, 255] }, BlendRange::FULL]]; + // A visible group holding a pixel layer, and an empty group. + let mut inside = Layer::new("inside", LayerContent::Raster(photocraft_raster::Surface::new(photocraft_doc::PixelFormat::RGBA8))); + if let LayerContent::Raster(surface) = &mut inside.content { + surface.fill_rect(full, &RED); + } + doc.layers.push(Layer::new("group", LayerContent::Group(Group { children: vec![inside], expanded: true, artboard: None, frame_stroke: None }))); + doc.layers.push(Layer::new("empty group", LayerContent::Group(Group { children: Vec::new(), expanded: true, artboard: None, frame_stroke: None }))); + // A hidden group whose child is visible: Visible goes by the layer's own flag. + let mut child = Layer::new("in hidden group", LayerContent::Raster(photocraft_raster::Surface::new(photocraft_doc::PixelFormat::RGBA8))); + if let LayerContent::Raster(surface) = &mut child.content { + surface.fill_rect(full, &RED); + } + let mut hidden_group = Layer::new("hidden group", LayerContent::Group(Group { children: vec![child], expanded: true, artboard: None, frame_stroke: None })); + hidden_group.visible = false; + doc.layers.push(hidden_group); + s +} + +fn layer<'a>(doc: &'a mut Document, name: &str) -> &'a mut Layer { + let id = doc.walk().into_iter().find(|(_, _, l)| l.name == name).map(|(_, _, l)| l.id).unwrap_or_else(|| panic!("no layer {name}")); + doc.layer_mut(id).unwrap() +} + +fn rename_active(s: &mut photocraft_engine::Session, name: &str) { + let d = s.active_mut().unwrap(); + let id = d.active_layer.unwrap(); + std::sync::Arc::make_mut(&mut d.doc).layer_mut(id).unwrap().name = name.into(); +} + +/// The names `attribute` lists. +fn listed(doc: &Document, attribute: Attribute) -> BTreeSet { + let mut filter = Filter::of(&UiState { layer_filter_by: FilterBy::Attribute, layer_filter_attribute: attribute, ..UiState::default() }); + // As the panel does ([`Filter::of_app`]): Empty knows the canvas. + filter.empty = Some(std::sync::Arc::new(super::attribute::empty_layers_of(doc))); + crate::layer_tree_ui::display_rows(doc, filter.active()).into_iter().filter(|(_, l)| filter.shows(l)).map(|(_, l)| l.name.clone()).collect() +} + +fn names(list: &[&str]) -> BTreeSet { + list.iter().map(|n| n.to_string()).collect() +} + +fn all(doc: &Document) -> BTreeSet { + doc.walk().into_iter().map(|(_, _, l)| l.name.clone()).collect() +} + +#[test] +fn each_attribute_lists_the_measured_layers() { + let s = session(); + let doc = &s.active().unwrap().doc; + let everything = all(doc); + let but = |gone: &[&str]| -> BTreeSet { everything.difference(&names(gone)).cloned().collect() }; + // Every visible layer and every visible group. + assert_eq!(listed(doc, Attribute::Visible), but(&["hidden", "hidden group"])); + assert!(listed(doc, Attribute::Visible).contains("in hidden group"), "a visible child of a hidden group"); + // Any lock counts, the Background's too. + assert_eq!(listed(doc, Attribute::Locked), names(&["position lock", "all locked", "Background"])); + // A pixel layer without pixels; the empty group isn't listed. + assert_eq!(listed(doc, Attribute::Empty), names(&["empty"])); + assert_eq!(listed(doc, Attribute::Linked), names(&["linked a", "linked b"])); + // The clipped Levels layer, not its base. + assert_eq!(listed(doc, Attribute::Clipped), names(&["clipped"])); + // The adjustment and fill layers count with the masks they're created with. + assert_eq!(listed(doc, Attribute::LayerMask), names(&["masked", "clipped", "fill"])); + // A shape layer counts as having a vector mask. + assert_eq!(listed(doc, Attribute::VectorMask), names(&["vector masked", "shape"])); + // A layer whose only effect is turned off counts. + assert_eq!(listed(doc, Attribute::LayerEffects), names(&["fx", "fx off"])); + // Each Advanced Blending option on its own; Fill opacity alone doesn't count. + assert_eq!( + listed(doc, Attribute::AdvancedBlending), + names(&["adv channels", "adv knockout", "adv interior", "adv clipped", "adv shapes", "adv layer mask", "adv vector mask", "adv blend if"]) + ); +} + +#[test] +fn a_layer_erased_completely_is_empty() { + // The Eraser only clears alpha, so the erased pixels keep their colour bytes. + for depth in [8, 16, 32] { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth, "background": "transparent"})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let name = s.active().unwrap().doc.walk().into_iter().map(|(_, _, l)| l.name.clone()).next().unwrap(); + let erase = |s: &mut photocraft_engine::Session, x: i32| { + s.execute("paint.stroke", json!({"points": [[x, -10], [x, 40]], "size": 30, "erase": true, "brush": {"hardness": 1.0}})).unwrap(); + }; + erase(&mut s, 10); + let doc = &s.active().unwrap().doc; + assert_eq!(listed(doc, Attribute::Empty), names(&[]), "{depth}: partly erased"); + assert_eq!(listed(doc, Attribute::NotEmpty), names(&[&name]), "{depth}"); + erase(&mut s, 30); + let doc = &s.active().unwrap().doc; + assert_eq!(listed(doc, Attribute::Empty), names(&[&name]), "{depth}: erased completely"); + assert_eq!(listed(doc, Attribute::NotEmpty), names(&[]), "{depth}"); + } +} + +#[test] +fn a_layer_with_an_opaque_default_pixel_is_not_empty() { + // No stored tiles, but the default pixel covers the whole canvas. + let mut doc = Document::new("Opaque default", photocraft_geom::Size::new(40, 30), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8); + let opaque = photocraft_raster::Surface::with_default(photocraft_doc::PixelFormat::RGBA8, &RED); + doc.layers.push(Layer::new("opaque", LayerContent::Raster(opaque))); + doc.layers.push(Layer::new("clear", LayerContent::Raster(photocraft_raster::Surface::new(photocraft_doc::PixelFormat::RGBA8)))); + assert_eq!(listed(&doc, Attribute::Empty), names(&["clear"])); + assert_eq!(listed(&doc, Attribute::NotEmpty), names(&["opaque"])); +} + +#[test] +fn an_opaque_default_layer_erased_on_the_whole_canvas_is_empty() { + // 300 px wide: the second column of tiles reaches past the canvas, where the default stays. + let mut doc = Document::new("Opaque erased", photocraft_geom::Size::new(300, 30), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8); + let clear = [1.0, 0.0, 0.0, 0.0]; + for (name, erased) in [("erased", photocraft_geom::Rect::new(0, 0, 300, 30)), ("half erased", photocraft_geom::Rect::new(0, 0, 200, 30))] { + let mut s = photocraft_raster::Surface::with_default(photocraft_doc::PixelFormat::RGBA8, &RED); + s.fill_rect(erased, &clear); + doc.layers.push(Layer::new(name, LayerContent::Raster(s))); + } + let mut dot = photocraft_raster::Surface::with_default(photocraft_doc::PixelFormat::RGBA8, &RED); + dot.fill_rect(photocraft_geom::Rect::new(0, 0, 300, 30), &clear); + dot.fill_rect(photocraft_geom::Rect::new(299, 29, 300, 30), &RED); + doc.layers.push(Layer::new("one pixel", LayerContent::Raster(dot))); + // The half erased layer has no tile over x 256..300, so the default shows there. + assert_eq!(listed(&doc, Attribute::Empty), names(&["erased"])); + assert_eq!(listed(&doc, Attribute::NotEmpty), names(&["half erased", "one pixel"])); +} + +#[test] +fn the_empty_layers_are_found_once_per_document() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "background": "transparent"})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + app.ui.layer_filter_by = FilterBy::Attribute; + app.ui.layer_filter_attribute = Attribute::Empty; + let first = Filter::of_app(&app).empty.expect("found for Empty"); + assert!(first.is_empty(), "the filled layer has pixels"); + let again = Filter::of_app(&app).empty.unwrap(); + assert!(std::sync::Arc::ptr_eq(&first, &again), "the same document reuses the result"); + // Selecting a layer moves the revision but changes no pixels. + let id = app.session.active().unwrap().doc.layers[0].id; + app.session.select_layer(id).unwrap(); + assert!(std::sync::Arc::ptr_eq(&first, &Filter::of_app(&app).empty.unwrap()), "a selection reuses the result"); + app.session.execute("paint.stroke", json!({"points": [[20, -10], [20, 40]], "size": 60, "erase": true, "brush": {"hardness": 1.0}})).unwrap(); + let after = Filter::of_app(&app); + assert_eq!(after.empty.as_ref().map(|set| set.len()), Some(1), "an edit finds them again"); + let doc = &app.session.active().unwrap().doc; + assert!(doc.walk().into_iter().all(|(_, _, l)| after.shows(l)), "the erased layer is listed"); + app.ui.layer_filter_attribute = Attribute::NotEmpty; + assert!(Filter::of_app(&app).empty.is_some(), "Not Empty uses them too"); + // Another document, with no empty layer, gets its own result. + app.session.execute("file.new", json!({"width": 40, "height": 30})).unwrap(); + assert_eq!(Filter::of_app(&app).empty.map(|set| set.len()), Some(0), "the new document's layers"); + app.ui.layer_filter_attribute = Attribute::Visible; + assert!(Filter::of_app(&app).empty.is_none(), "other attributes don't read pixels"); +} + +#[test] +fn each_opposite_lists_the_rest_groups_included() { + let s = session(); + let doc = &s.active().unwrap().doc; + let everything = all(doc); + for (positive, negative) in Attribute::ALL[..9].iter().zip(&Attribute::ALL[9..]) { + let rest: BTreeSet = everything.difference(&listed(doc, *positive)).cloned().collect(); + assert_eq!(listed(doc, *negative), rest, "{negative:?} lists what {positive:?} doesn't"); + } + // The three measured ones, spelled out. + assert_eq!(listed(doc, Attribute::NotVisible), names(&["hidden", "hidden group"])); + assert!(listed(doc, Attribute::NotLocked).contains("group"), "groups are listed too"); + assert!(!listed(doc, Attribute::NotLocked).contains("Background")); + assert_eq!(listed(doc, Attribute::NotLinked), everything.difference(&names(&["linked a", "linked b"])).cloned().collect()); +} + +#[test] +fn attribute_filters_at_once_with_visible() { + let f = Filter::of(&UiState { layer_filter_by: FilterBy::Attribute, ..UiState::default() }); + assert_eq!(f.attribute, Attribute::Visible); + assert!(f.active(), "Attribute filters as soon as it's picked"); +} + +#[test] +fn ui_set_picks_the_attribute_by_id() { + let mut ui = UiState::default(); + for attribute in Attribute::ALL { + let f = super::patched(&ui, &json!({"by": "attribute", "attribute": attribute.id()})).unwrap(); + super::apply(&mut ui, f); + assert_eq!((ui.layer_filter_by, ui.layer_filter_attribute), (FilterBy::Attribute, attribute)); + } + assert_eq!(Attribute::NoAdvancedBlending.id(), "noAdvancedBlending"); + let err = super::patched(&ui, &json!({"attribute": "hidden"})).unwrap_err(); + assert!(err.contains("hidden") && err.contains("notVisible") && err.contains("noAdvancedBlending"), "{err}"); + assert!(super::patched(&ui, &json!({"attribute": 3})).is_err()); + assert!(super::patched(&ui, &json!({"attribute": null})).is_err()); +} + +#[test] +fn saved_settings_without_the_attribute_still_load() { + let mut saved = serde_json::to_value(UiState { layer_filter_attribute: Attribute::NotLinked, ..UiState::default() }).unwrap(); + assert_eq!(saved["layer_filter_attribute"], "notLinked"); + saved.as_object_mut().unwrap().remove("layer_filter_attribute"); + let ui: UiState = serde_json::from_value(saved).unwrap(); + assert_eq!(ui.layer_filter_attribute, Attribute::Visible); +} + +/// The names of the rows the Layers panel drew. +fn drawn(h: &Harness<'_, PhotocraftApp>) -> BTreeSet { + let doc = &h.state().session.active().unwrap().doc; + let rows = crate::layer_row_ui::recorded(&h.ctx); + rows.iter().filter_map(|r| doc.walk().into_iter().find(|(_, _, l)| l.id.0 == r.layer).map(|(_, _, l)| l.name.clone())).collect() +} + +#[test] +fn the_panel_filters_by_the_attribute_picked_in_the_dropdown() { + let session = session(); + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(session, crate::Services::default()) + }); + h.run_steps(8); + h.get_by_value("Kind").click(); + h.run_steps(4); + h.query_all_by_label("Attribute").last().expect("Attribute in the open list").click(); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::Attribute); + assert!(!drawn(&h).contains("hidden"), "Visible at first"); + h.get_by_value("Visible").click(); + h.run_steps(4); + h.query_all_by_label("Not Visible").last().expect("Not Visible in the open list").click(); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_attribute, Attribute::NotVisible); + assert_eq!(drawn(&h), names(&["hidden", "hidden group"])); +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 2b197a24c0..ca0259a434 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -370,6 +370,9 @@ pub struct UiState { /// Layers panel effect filter (#10880). #[serde(default)] pub layer_filter_effect: crate::layer_filter_ui::effect::EffectKind, + /// Layers panel attribute filter (#10881). + #[serde(default)] + pub layer_filter_attribute: crate::layer_filter_ui::attribute::Attribute, /// Layers panel colour filter (#5898): the label colour listed (None: layers without one). #[serde(default)] pub layer_filter_color: photocraft_doc::LabelColor, @@ -586,6 +589,7 @@ impl Default for UiState { layer_filter_name: String::new(), layer_filter_mode: default_layer_filter_mode(), layer_filter_effect: Default::default(), + layer_filter_attribute: Default::default(), layer_filter_color: Default::default(), pen: None, pen_doc: None, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 6cff9f5f2e..7e71f2233b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -54,7 +54,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `ui.set` also accepts `camTransformOnDrop` (boolean), the Content-Aware Move tool's Transform On Drop checkbox: a drop then puts a scale/rotate box on the patch (`ui.inspect` › `camTransform`: its document/revision, drag `offset` and `frame`), driven with `ui.pointer` (handles as Free Transform's; outside the box rotates), committed with `ui.key Enter` (one `paint.contentAwareMove` with `scaleX`, `scaleY`, `rotate`) or dropped with `Escape`. - `ui.set` also accepts `tileGrid` (boolean), a developer overlay of the active layer's storage tiles: the tile grid over the document, every stored tile filled and labelled `tx,ty`, tiles with identical pixels in a shared colour and unique ones in blue. It has no menu item and View › Extras doesn't hide it. `ui.inspect` reports it as `view.tile_grid`. - `ui.set` also accepts `strokeTrace` (`true`, `false`, `"stderr"` or `"off"`): turn the stroke latency trace on or off at runtime; each stroke's summary is in `ui.inspect` `perf.strokeTrace`. A CSV file of every frame is set only at launch (`PHOTOCRAFT_STROKE_TRACE=` / `--stroke-trace `): the control channel doesn't write files outside the automation write root -- `ui.set` also accepts `layerFilter` (#5048), the Layers panel filter row: `{by?, kinds?, name?, effect?, mode?, color?, smart?}`, any subset. `by` is `kind`, `name`, `effect`, `mode`, `color`, `smartObject` or `artboard` (the filter-type dropdown); `kinds` lists the Kind toggles that are on (`pixel`, `adjustment`, `type`, `shape`, `smart`); `name` matches part of a layer name, ignoring case; `effect` (#10880) is `bevelEmboss`, `stroke`, `innerShadow`, `innerGlow`, `satin`, `overlay` (Color, Gradient or Pattern Overlay), `outerGlow` or `dropShadow`, and lists the layers with that effect turned on; `mode` is one of the 27 layer blend modes (`Multiply`, `Screen`), not Pass Through, which the Mode list doesn't offer (#10878); `color` is a layer colour label id (`none`, `red`, `orange`, `yellow`, `green`, `seafoam`, `blue`, `indigo`, `magenta`, `fuchsia`, `violet`, `gray`; #5898); `smart` lists the Smart Object toggles that are on (`linked`: linked to a file, `missing`: linked to a file that was not there when the app last looked, `embedded`). Kind, Name and Smart Object filter only once a toggle is on or text is typed; Effect, Mode and Color always filter, by Bevel & Emboss, by Normal and by no colour at first, as in Photoshop. Artboard has no field of its own: it lists the artboards that hold the selected layers, with everything inside them (closed groups stay closed), and does not filter when nothing is selected or a selected layer is in no artboard. Fields left out keep their value; a bad one is an error and changes nothing. +- `ui.set` also accepts `layerFilter` (#5048), the Layers panel filter row: `{by?, kinds?, name?, effect?, mode?, attribute?, color?, smart?}`, any subset. `by` is `kind`, `name`, `effect`, `mode`, `attribute`, `color`, `smartObject` or `artboard` (the filter-type dropdown); `kinds` lists the Kind toggles that are on (`pixel`, `adjustment`, `type`, `shape`, `smart`); `name` matches part of a layer name, ignoring case; `effect` (#10880) is `bevelEmboss`, `stroke`, `innerShadow`, `innerGlow`, `satin`, `overlay` (Color, Gradient or Pattern Overlay), `outerGlow` or `dropShadow`, and lists the layers with that effect turned on; `mode` is one of the 27 layer blend modes (`Multiply`, `Screen`), not Pass Through, which the Mode list doesn't offer (#10878); `attribute` (#10881) is `visible`, `locked`, `empty`, `linked`, `clipped`, `layerMask`, `vectorMask`, `layerEffects` or `advancedBlending`, or one of their opposites `notVisible`, `notLocked`, `notEmpty`, `notLinked`, `notClipped`, `noLayerMask`, `noVectorMask`, `noLayerEffects`, `noAdvancedBlending`; `color` is a layer colour label id (`none`, `red`, `orange`, `yellow`, `green`, `seafoam`, `blue`, `indigo`, `magenta`, `fuchsia`, `violet`, `gray`; #5898); `smart` lists the Smart Object toggles that are on (`linked`: linked to a file, `missing`: linked to a file that was not there when the app last looked, `embedded`). Kind, Name and Smart Object filter only once a toggle is on or text is typed; Effect, Mode, Attribute and Color always filter, by Bevel & Emboss, by Normal, by Visible and by no colour at first, as in Photoshop. Artboard has no field of its own: it lists the artboards that hold the selected layers, with everything inside them (closed groups stay closed), and does not filter when nothing is selected or a selected layer is in no artboard. Fields left out keep their value; a bad one is an error and changes nothing. - `ui.set` also accepts the Histogram panel: `histogramPanel` `{view?, channel?, showChannelsInColor?, showStatistics?, source?}` (any subset, kept with the preferences): `view` is `compact`, `expanded` or `allChannels`; `channel` is `composite`, `red`, `green`, `blue`, `cyan`, `magenta`, `yellow`, `black`, `a`, `b`, `luminosity` or `colors` (one the document's mode lacks shows as its composite); `source` is `entireImage`, `selectedLayer` or `adjustmentComposite` (Entire Image is counted in Compact View, when the document has one layer, or when no adjustment layer is selected for Adjustment Composite). `histogramRange` is `[from, to]` (levels 0..255) or `null`: the dragged tonal range whose Count and Percentile the statistics show. `histogramRefresh: true` is Uncached Refresh: the current state is recounted at cache level 1. `ui.inspect` › `histogram` reports the options, the channel and source as they apply, the channels the document offers, whether the shown histogram is `current` (counted for the document's revision, source and cache level the panel wants now), its `cacheLevel`, `maxCacheLevel` (Preferences › Performance › Cache Levels), `preview` (an adjustment's preview is counted) and `stats` (`mean`, `stdDev`, `median`, `pixels`, `level`, `count`, `percentile`, `clipped` percent at 0 and 255). Histograms are counted while the panel is shown. - Each painting tool keeps its own brush, as in Photoshop: `tools.setBrush` and `ui.set {brushSize}` change the active tool's brush, and switching tools swaps in that tool's own (size, hardness, mode, opacity, flow, dynamics, smoothing). A tool used for the first time starts from the brush in hand. The colours, the erase flag and the stroke seed stay shared. So `ui.set {tool: "eraser", brushSize: 12}` sets the Eraser's size, and `ui.set {tool: "brush"}` afterwards brings the Brush's own size back. - Brush Pose's `pose.overrideTiltX` and `pose.overrideTiltY` are independent booleans. For example, `engine.execute {"command":"tools.setBrush","params":{"pose":{"enabled":true,"tiltX":45,"overrideTiltX":true,"overrideTiltY":false}}}` fixes X at 45° while Y follows the stylus. Pose tilts are degrees (±90); the Brush Settings panel displays percentages (±100). Old stored `overrideTilt` values migrate to both axes. In command patches, `overrideTilt` sets both axes unless an explicit axis field in the same patch overrides it. `brush.get` includes both axis flags and the legacy combined readback (`overrideTilt` is true only when both flags are true). Axis-only journals omit that derived combined key so replay retains the other axis. Present flag values must be booleans; null and other types return an error.