diff --git a/.reachability-floor.tsv b/.reachability-floor.tsv index df4def1e..c435541d 100644 --- a/.reachability-floor.tsv +++ b/.reachability-floor.tsv @@ -14,7 +14,7 @@ # The bless verb refuses to lower the floor. # # metricvalue -registry_commands 253 -shell_bound_commands 168 -reachable_percent 66.40 -floor_reachable_percent 66.40 +registry_commands 257 +shell_bound_commands 172 +reachable_percent 66.93 +floor_reachable_percent 66.93 diff --git a/crates/app/src/shell/panels.rs b/crates/app/src/shell/panels.rs index 257973db..c3cf43ec 100644 --- a/crates/app/src/shell/panels.rs +++ b/crates/app/src/shell/panels.rs @@ -7,7 +7,7 @@ use super::*; use compose::abgr_pack; -use doc::{GroupObject, ImageObject, SliceObject}; +use doc::{GroupObject, ImageObject, MaskCombineMode, SliceObject}; impl Shell { /// Edit ▸ Paste as Mask / Paste Inside: the clipboard becomes the @@ -889,6 +889,7 @@ impl Shell { self.document_properties(ui); return; }; + self.mask_stack_panel(ui, id); let object = self.editor.document().registry.get(id).cloned(); let effects = match object { Some(ObjectData::Path(path)) => { @@ -1642,6 +1643,87 @@ impl Shell { } } + /// The mask stack section of the Properties panel: the element + /// mask's levels, their state, and the way each level combines with + /// the coverage below it. + /// + /// A `FireCraft` extension (decision 0092): CS6 has one element mask + /// per object and no stack, so the level's mode defaults to the + /// union. The panel draws the section only when the selection + /// carries an element mask. + pub(super) fn mask_stack_panel(&mut self, ui: &mut egui::Ui, id: doc::ObjectId) { + let levels = engine::catalog::element_mask_stack(self.editor.document(), id); + if levels.is_empty() { + return; + } + ui.heading("Mask stack"); + let count = levels.len(); + for (index, level) in levels.iter().enumerate() { + // The levels are listed top first; the mask numbers count + // from the bottom, as CS6 numbers its masks. + let number = count - index; + ui.horizontal(|ui| { + let mut enabled = level.enabled; + if ui + .checkbox(&mut enabled, format!("Mask {number}")) + .changed() + { + self.run_command( + "mask.enable", + serde_json::json!({ "id": level.group, "enable": enabled }), + ); + } + egui::ComboBox::from_id_salt(("mask_mode", level.group)) + .selected_text(level.mode.as_str()) + .width(80.0) + .show_ui(ui, |ui| { + for candidate in MaskCombineMode::ALL { + if ui + .selectable_label(candidate == level.mode, candidate.as_str()) + .clicked() + { + self.run_command( + "mask.setStackMode", + serde_json::json!({ + "id": level.group, + "mode": candidate.as_str(), + }), + ); + } + } + }); + if ui.small_button("↑").clicked() { + self.run_command( + "mask.moveLevel", + serde_json::json!({ "id": level.group, "delta": 1 }), + ); + } + if ui.small_button("↓").clicked() { + self.run_command( + "mask.moveLevel", + serde_json::json!({ "id": level.group, "delta": -1 }), + ); + } + if ui.small_button("remove").clicked() { + self.run_command("mask.removeLevel", serde_json::json!({ "id": level.group })); + } + // The link flag rides the level's own mask group, so + // it is per level: an unlinked level's mask moves on + // its own, without its content. + let mut linked = level.linked; + if ui.checkbox(&mut linked, "link").changed() { + self.run_command( + "mask.link", + serde_json::json!({ "id": level.group, "linked": linked }), + ); + } + }); + } + if ui.button("Add level").clicked() { + self.run_command("mask.addLevel", serde_json::json!({ "id": id })); + } + } + /// Bitmap properties: the pixel geometry as a readout, then the /// shared display controls. /// diff --git a/crates/compose/src/render.rs b/crates/compose/src/render.rs index 91d80888..18af980f 100644 --- a/crates/compose/src/render.rs +++ b/crates/compose/src/render.rs @@ -14,8 +14,8 @@ use crate::cache::EffectCache; use crate::convert::{abgr, blend_mode, to_bezpath}; use crate::effects; use doc::{ - Document, FillShape, Gradient, GroupObject, Layer, NineSliceGuides, ObjectData, PathObject, - SymbolInstanceObject, SymbolType, TileBank, TileRef, + Document, FillShape, Gradient, GroupObject, Layer, MaskCombineMode, NineSliceGuides, + ObjectData, ObjectId, PathObject, SymbolInstanceObject, SymbolType, TileBank, TileRef, }; use filters::Surface; use geom::{Affine, transform::map_rect}; @@ -1146,10 +1146,254 @@ fn paint_stroke(color: i64, geometry: &vello_cpu::kurbo::BezPath, ctx: &mut Rend ctx.stroke_path(geometry); } +/// `GRTi` 1 — a grayscale (pixel) mask group: the mask child is a +/// raster whose gray level is the coverage. +const GRAYSCALE_MASK_GROUP: i64 = 1; + +/// `GRTi` 2 — a shape (vector) mask group: the mask child is a path +/// whose painted coverage is the coverage. +const SHAPE_MASK_GROUP: i64 = 2; + +/// `GRTi` 0 — a regular group, and the shape a disabled mask group +/// takes (decision 0020). +const REGULAR_GROUP: i64 = 0; + +/// True when the group is mask-structured (decision 0020): its first +/// child is the mask, the rest is the content the mask restricts. +fn is_mask_group(group: &GroupObject) -> bool { + matches!(group.group_type, GRAYSCALE_MASK_GROUP | SHAPE_MASK_GROUP) +} + +/// True when the group is a *disabled* mask group: `GRTi` 0 with the +/// mask-first child order and the mask's own artwork hidden — the +/// structure `mask.enable` restores (decision 0020). +/// +/// The artwork flag is what separates a disabled mask from a plain +/// group that happens to hold an image or a path first: a plain group +/// keeps the flag off, so it draws every child as it always did. +fn is_disabled_mask_group(group: &GroupObject, document: &Document) -> bool { + group.group_type == REGULAR_GROUP + && group.mask_hidden + && group.children.len() > 1 + && matches!( + document.registry.get(group.children[0]), + Some(ObjectData::Image(_)) | Some(ObjectData::Path(_)) + ) +} + +/// Renders a mask-structured group: the group's first child is the +/// mask (the coverage), its remaining children are the content the +/// coverage restricts (decision 0020). +/// +/// A mask group's content child may itself be a mask group — that is +/// how a stack of levels is spelled (decision 0092). The innermost +/// group is the bottom level, the outermost the top level, and each +/// level's `stack_mode` is the operator that combines that level's +/// coverage with the coverage below it. The bottom level has no +/// coverage below it, so it replaces the coverage outright (the base +/// mask of a Photoshop layer-mask pair carries no blend mode); every +/// level above it combines with the level below. +/// +/// The mask child is not drawn as content: a mask restricts, it does +/// not paint. +fn render_mask_group(group: &GroupObject, walk: &mut Walk) -> Result<(), ComposeError> { + let stack = mask_stack(group, walk)?; + let mut pixels = stack.pixels; + apply_coverage(&mut pixels, &stack.coverage); + + // The group's own filters see the masked composite (the coverage + // is applied before the filter, as it is inside the group). + let plan = effects::plan(&group.effects); + if !plan.ops.is_empty() { + let key = crate::cache::hash_pixels(&pixels, walk.canvas, &plan); + pixels = match walk.cache.get(key).map(|entry| entry.pixels.clone()) { + Some(pixels) => pixels, + None => apply_filtered(pixels, walk.canvas, &plan, key, walk.cache)?, + }; + } + paint_surface(&pixels, walk.canvas, group.blend, group.opacity, walk.ctx) +} + +/// A mask stack's outcome: the content's own pixels, and the coverage +/// the stack folds them against. +struct MaskStack { + /// The content's pixels, before the coverage applies. + pixels: Vec, + /// The folded coverage, one byte per pixel (255 = fully covered). + coverage: Vec, +} + +/// Folds a mask stack into one coverage map, and returns it with the +/// content's pixels. +fn mask_stack(group: &GroupObject, walk: &mut Walk) -> Result { + let canvas_pixels = usize::from(walk.canvas.0) * usize::from(walk.canvas.1); + let Some(&mask_id) = group.children.first() else { + // A mask group with no mask child is malformed: the children + // draw as they are and nothing is restricted. + return Ok(MaskStack { + pixels: render_ids(&group.children, walk)?, + coverage: vec![255; canvas_pixels], + }); + }; + let content_ids = level_children(group, walk.document); + if content_ids.is_empty() { + return Ok(MaskStack { + pixels: render_ids(&group.children, walk)?, + coverage: vec![255; canvas_pixels], + }); + } + + // The content: the next level down of the stack, or the object + // the stack restricts. + let (pixels, below) = match content_ids { + [content_id] => match walk.document.registry.get(*content_id) { + Some(ObjectData::Group(inner)) if is_mask_group(inner) => { + let stack = mask_stack(inner, walk)?; + (stack.pixels, Some(stack.coverage)) + } + _ => (render_ids(content_ids, walk)?, None), + }, + _ => (render_ids(content_ids, walk)?, None), + }; + + let level = mask_coverage(mask_id, walk)?; + // The bottom level replaces the coverage below it (there is none); + // every level above combines with the coverage below it. + let coverage = match below { + Some(below) => combine_coverage( + &below, + &level, + group.stack_mode.unwrap_or(MaskCombineMode::Add), + ), + None => level, + }; + Ok(MaskStack { pixels, coverage }) +} + +/// The children a mask level draws, skipping a disabled level: a +/// disabled level contributes no coverage, so the level below it +/// stands in for it. +fn level_children<'a>(group: &'a GroupObject, document: &'a Document) -> &'a [ObjectId] { + let mut children = group.children.get(1..).unwrap_or_default(); + while let Some(first) = children.first() { + let Some(ObjectData::Group(inner)) = document.registry.get(*first) else { + break; + }; + if !is_disabled_mask_group(inner, document) { + break; + } + children = inner.children.get(1..).unwrap_or_default(); + } + children +} + +/// Renders objects into a fresh full-canvas buffer, in order. +fn render_ids(ids: &[ObjectId], walk: &mut Walk) -> Result, ComposeError> { + let mut offscreen = RenderContext::new(walk.canvas.0, walk.canvas.1); + for id in ids { + render_object( + *id, + &mut Walk { + document: walk.document, + frame: walk.frame, + canvas: walk.canvas, + cache: &mut *walk.cache, + text_engine: &mut *walk.text_engine, + ctx: &mut offscreen, + stack: walk.stack, + }, + )?; + } + flush_offscreen(offscreen, walk.canvas) +} + +/// The coverage a mask child contributes: the gray level of a raster +/// mask, the painted coverage of a shape mask. +fn mask_coverage(mask_id: ObjectId, walk: &mut Walk) -> Result, ComposeError> { + let pixels = render_ids(&[mask_id], walk)?; + let grayscale = matches!( + walk.document.registry.get(mask_id), + Some(ObjectData::Image(_)) + ); + let mut coverage = vec![0u8; pixels.len() / 4]; + for (slot, chunk) in coverage.iter_mut().zip(pixels.as_chunks::<4>().0) { + let (red, green, blue, alpha) = (chunk[0], chunk[1], chunk[2], chunk[3]); + *slot = if grayscale { + // A raster mask carries its coverage as a gray level in the + // color channels; the alpha carries the mask's own opacity. + // Read the level back out of the premultiplied pixels. + if alpha == 0 { + 0 + } else { + let level = (u32::from(red) + u32::from(green) + u32::from(blue)) / 3; + let restored = level * 255 + u32::from(alpha) / 2; + ((restored / u32::from(alpha)).min(255)) as u8 + } + } else { + alpha + }; + } + Ok(coverage) +} + +/// Combines a level's coverage with the coverage below it. +/// +/// - `Add` — the union of the two coverages. +/// - `Subtract` — the level's coverage cut out of the coverage below. +/// - `Intersect` — the overlap of the two coverages. +/// - `Invert` — the level's inverted coverage stands in for the +/// coverage below. +fn combine_coverage(below: &[u8], level: &[u8], mode: MaskCombineMode) -> Vec { + below + .iter() + .zip(level) + .map(|(&below, &level)| { + let (below, level) = (u32::from(below), u32::from(level)); + let combined = match mode { + MaskCombineMode::Add => below + level - (below * level) / 255, + MaskCombineMode::Subtract => (below * (255 - level)) / 255, + MaskCombineMode::Intersect => (below * level) / 255, + MaskCombineMode::Invert => 255 - level, + }; + combined.min(255) as u8 + }) + .collect() +} + +/// Applies a coverage map to premultiplied content pixels: the +/// coverage scales the alpha, and the color scales with it, so the +/// pixels stay premultiplied. +fn apply_coverage(pixels: &mut [u8], coverage: &[u8]) { + for (chunk, &cover) in pixels.as_chunks_mut::<4>().0.iter_mut().zip(coverage) { + let cover = u32::from(cover); + for channel in chunk.iter_mut() { + *channel = ((u32::from(*channel) * cover + 127) / 255).min(255) as u8; + } + } +} + fn render_group(group: &GroupObject, walk: &mut Walk) -> Result<(), ComposeError> { if !group.visible { return Ok(()); } + if is_mask_group(group) { + return render_mask_group(group, walk); + } + // A disabled mask group draws its content unmasked: the mask + // child's own artwork is hidden, and the coverage does not apply. + // A disabled level of a stack contributes no coverage of its own, + // but the level below it still applies. + let children: &[ObjectId] = if is_disabled_mask_group(group, walk.document) { + if let [content] = &group.children[1..] + && let Some(ObjectData::Group(inner)) = walk.document.registry.get(*content) + && is_mask_group(inner) + { + return render_mask_group(inner, walk); + } + &group.children[1..] + } else { + &group.children + }; let plan = effects::plan(&group.effects); if plan.ops.is_empty() { walk.ctx.push_layer( @@ -1159,10 +1403,7 @@ fn render_group(group: &GroupObject, walk: &mut Walk) -> Result<(), ComposeError None, None, ); - // Mask-structured groups composite all children unmasked this - // milestone (masks as pixel content are M6/M7; `MNAb` is - // carried by the model only). - for id in &group.children { + for id in children { render_object(*id, walk)?; } walk.ctx.pop_layer(); @@ -1173,7 +1414,7 @@ fn render_group(group: &GroupObject, walk: &mut Walk) -> Result<(), ComposeError // child's own effects already applied); the group's blend/opacity // wrap the filtered image, as on the unfiltered path. let mut offscreen = RenderContext::new(walk.canvas.0, walk.canvas.1); - for id in &group.children { + for id in children { render_object( *id, &mut Walk { diff --git a/crates/compose/tests/compose_proof.rs b/crates/compose/tests/compose_proof.rs index abc11e39..7f928f48 100644 --- a/crates/compose/tests/compose_proof.rs +++ b/crates/compose/tests/compose_proof.rs @@ -312,6 +312,7 @@ fn group_opacity_composes_over_its_children() { 0, ); let group = doc.registry.insert(ObjectData::Group(doc::GroupObject { + stack_mode: None, group_type: 0, name: None, children: vec![blue], @@ -479,6 +480,7 @@ fn repeated_renders_are_byte_identical() { 0, ); let group = doc.registry.insert(ObjectData::Group(doc::GroupObject { + stack_mode: None, group_type: 0, name: None, children: vec![red], @@ -928,6 +930,7 @@ fn group_filters_apply_to_the_child_composite() { 0, ); let group = doc.registry.insert(ObjectData::Group(doc::GroupObject { + stack_mode: None, group_type: 0, name: None, children: vec![left, right], @@ -965,6 +968,7 @@ fn nested_inverts_cancel_exactly() { set_effects(&mut doc, rect, vec![effect("invert", &[])]); } let group = doc.registry.insert(ObjectData::Group(doc::GroupObject { + stack_mode: None, group_type: 0, name: None, children: vec![rect], diff --git a/crates/compose/tests/mask_render.rs b/crates/compose/tests/mask_render.rs new file mode 100644 index 00000000..a2f27a27 --- /dev/null +++ b/crates/compose/tests/mask_render.rs @@ -0,0 +1,378 @@ +//! Mask rendering (task 143, decision 0092): a mask group restricts its +//! content by the mask's coverage, a stack of levels folds the +//! coverages with the level's combine mode, and a disabled mask group +//! draws its content unmasked. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use compose::render; +use doc::{ + Document, GroupObject, Layer, LayerList, MaskCombineMode, ObjectData, ObjectId, Page, PageInfo, + Registry, TileRef, +}; +use geom::Point; +use raster::{TILE_BYTES, TILE_SIDE, Tile}; + +/// The canvas matte (`BGCi` default white, painted opaque). +const BACKGROUND: [u8; 4] = [255, 255, 255, 255]; +/// Opaque red. +const RED: [u8; 4] = [255, 0, 0, 255]; + +// --- document builders --------------------------------------------------- + +fn page(width: f64, height: f64, layers: Vec) -> Page { + Page { + info: PageInfo { + version: None, + revision: None, + frame_count: 2, + frame_count_duplicate: 2, + request_id: 0, + canvas: doc::Canvas { + x: 0.0, + y: 0.0, + width, + height, + resolution: 72.0, + resolution_unit: 0, + background_raw: 0xFF_FF_FF, + }, + onion_before: 0, + onion_after: 0, + frame_durations: Vec::new(), + }, + name: None, + is_master: false, + is_linked: false, + page_id: None, + last_export_dir: None, + last_export_file: None, + layers: LayerList { layers }, + grid: None, + guides: doc::Guides::default(), + export: None, + } +} + +fn layer(cells: Vec) -> Layer { + Layer { + name: String::new(), + is_background: false, + has_url: false, + is_special: false, + frame_changing: false, + display: true, + nesting_level: None, + cells, + visibility_per_frame: None, + shared_link: None, + } +} + +fn cell(ids: Vec) -> doc::Cell { + doc::Cell { + locked: false, + visible: true, + elements: ids, + } +} + +/// An image object over the canvas origin, `extent` wide and tall. +fn image(registry: &mut Registry, extent: (i64, i64), tile: u32) -> ObjectId { + registry.insert(ObjectData::Image(doc::ImageObject { + location: Point::new(0.0, 0.0), + offset: Point::new(0.0, 0.0), + pixels: extent, + tile_size: raster::TILE_SIDE as i64, + tiles: vec![TileRef::Tile(tile)], + name: None, + opacity: 1000, + blend: 0, + visible: true, + locked: false, + effects: Vec::new(), + })) +} + +/// A tile of one flat color. +fn solid(color: [u8; 4]) -> Tile { + let mut pixels = [0u8; TILE_BYTES]; + for px in pixels.as_chunks_mut::<4>().0.iter_mut() { + px.copy_from_slice(&color); + } + Tile::from_rgba8(pixels) +} + +/// A tile whose gray level steps at `split`: the columns left of it +/// carry `left`, the rest carry `right`. The alpha is opaque — a +/// grayscale mask carries its coverage in the color channels. +fn gray_step(left: u8, right: u8, split: usize) -> Tile { + let mut pixels = [0u8; TILE_BYTES]; + for y in 0..TILE_SIDE { + for x in 0..TILE_SIDE { + let gray = if x < split { left } else { right }; + let index = (y * TILE_SIDE + x) * 4; + pixels[index] = gray; + pixels[index + 1] = gray; + pixels[index + 2] = gray; + pixels[index + 3] = 255; + } + } + Tile::from_rgba8(pixels) +} + +/// A mask-structured group (`GRTi` 1 — grayscale) over `children`. +fn mask_group(registry: &mut Registry, children: Vec) -> ObjectId { + registry.insert(ObjectData::Group(GroupObject { + group_type: 1, + name: None, + children, + opacity: 1000, + blend: 0, + visible: true, + locked: false, + mask_hidden: false, + mask_linked: None, + stack_mode: None, + effects: Vec::new(), + })) +} + +/// The same group, with the given combine mode. +fn mask_group_with_mode( + registry: &mut Registry, + children: Vec, + mode: MaskCombineMode, +) -> ObjectId { + let id = mask_group(registry, children); + if let Some(ObjectData::Group(group)) = registry.get_mut(id) { + group.stack_mode = Some(mode); + } + id +} + +/// A regular group (`GRTi` 0) — the shape a disabled mask takes. +fn regular_group(registry: &mut Registry, children: Vec, mask_hidden: bool) -> ObjectId { + registry.insert(ObjectData::Group(GroupObject { + group_type: 0, + name: None, + children, + opacity: 1000, + blend: 0, + visible: true, + locked: false, + mask_hidden, + mask_linked: None, + stack_mode: None, + effects: Vec::new(), + })) +} + +fn pixel(render: &compose::Render, x: u32, y: u32) -> [u8; 4] { + let index = (y * u32::from(render.width) + x) as usize * 4; + render.pixels[index..index + 4].try_into().expect("pixel") +} + +// --- proofs -------------------------------------------------------------- + +/// A grayscale mask restricts its content: the white half of the mask +/// shows the content, the black half shows the canvas through it. +#[test] +fn grayscale_mask_restricts_its_content() { + let mut doc = Document::default(); + doc.tiles.insert(0, solid(RED)); + doc.tiles.insert(1, gray_step(255, 0, 2)); + let content = image(&mut doc.registry, (4, 4), 0); + let mask = image(&mut doc.registry, (4, 4), 1); + let group = mask_group(&mut doc.registry, vec![mask, content]); + doc.pages + .push(page(4.0, 4.0, vec![layer(vec![cell(vec![group])])])); + + let render = render(&doc, 0, 0).expect("render"); + assert_eq!(pixel(&render, 0, 0), RED, "inside the white half"); + assert_eq!(pixel(&render, 1, 3), RED, "inside the white half, lower"); + assert_eq!(pixel(&render, 2, 0), BACKGROUND, "inside the black half"); + assert_eq!(pixel(&render, 3, 3), BACKGROUND, "inside the black half"); +} + +/// The mask child is not composited as content: a mask restricts, it +/// does not paint. The gray of the mask never reaches the canvas. +#[test] +fn the_mask_artwork_is_not_composited() { + let mut doc = Document::default(); + doc.tiles.insert(0, solid(RED)); + // An opaque white mask over a half-size content: composited as + // artwork, the mask would white out the whole canvas. + doc.tiles.insert(1, gray_step(255, 255, 2)); + let content = image(&mut doc.registry, (2, 2), 0); + let mask = image(&mut doc.registry, (4, 4), 1); + let group = mask_group(&mut doc.registry, vec![mask, content]); + doc.pages + .push(page(4.0, 4.0, vec![layer(vec![cell(vec![group])])])); + + let render = render(&doc, 0, 0).expect("render"); + assert_eq!(pixel(&render, 0, 0), RED, "the content, unmasked"); + assert_eq!(pixel(&render, 3, 3), BACKGROUND, "no mask artwork"); +} + +/// The content's own alpha scales with the coverage: a half-white +/// mask halves the content's alpha. +#[test] +fn partial_coverage_scales_the_content_alpha() { + let mut doc = Document::default(); + doc.tiles.insert(0, solid(RED)); + doc.tiles.insert(1, gray_step(128, 128, 2)); + let content = image(&mut doc.registry, (4, 4), 0); + let mask = image(&mut doc.registry, (4, 4), 1); + let group = mask_group(&mut doc.registry, vec![mask, content]); + doc.pages + .push(page(4.0, 4.0, vec![layer(vec![cell(vec![group])])])); + + let render = render(&doc, 0, 0).expect("render"); + let covered = pixel(&render, 0, 0); + // Premultiplied red at half coverage over the white matte. + assert_eq!(covered[0], 255, "the matte shows through the red"); + assert_eq!(covered[3], 255, "the matte is opaque"); + assert!(covered[1] > 100 && covered[1] < 160, "half-covered red"); +} + +/// The stack's levels fold: the bottom level replaces the coverage, +/// and every level above combines with the coverage below it. +#[test] +fn stack_levels_fold_with_their_combine_mode() { + // The bottom level covers the left half; the top level covers the + // right half. The content is red across the canvas. + let build = |mode: MaskCombineMode| { + let mut doc = Document::default(); + doc.tiles.insert(0, solid(RED)); + doc.tiles.insert(1, gray_step(255, 0, 2)); + doc.tiles.insert(2, gray_step(0, 255, 2)); + let content = image(&mut doc.registry, (4, 4), 0); + let bottom_mask = image(&mut doc.registry, (4, 4), 1); + let top_mask = image(&mut doc.registry, (4, 4), 2); + let bottom = mask_group(&mut doc.registry, vec![bottom_mask, content]); + let top = mask_group_with_mode(&mut doc.registry, vec![top_mask, bottom], mode); + doc.pages + .push(page(4.0, 4.0, vec![layer(vec![cell(vec![top])])])); + doc + }; + + // Add — the union of the two halves covers the whole canvas. + let added = render(&build(MaskCombineMode::Add), 0, 0).expect("render"); + assert_eq!(pixel(&added, 0, 0), RED, "left half"); + assert_eq!(pixel(&added, 3, 0), RED, "right half"); + + // Intersect — the halves do not overlap, so nothing shows. + let intersected = render(&build(MaskCombineMode::Intersect), 0, 0).expect("render"); + assert_eq!(pixel(&intersected, 0, 0), BACKGROUND, "left half"); + assert_eq!(pixel(&intersected, 3, 0), BACKGROUND, "right half"); + + // Subtract — the top level's half is cut out of the coverage + // below, which leaves the bottom level's half. + let subtracted = render(&build(MaskCombineMode::Subtract), 0, 0).expect("render"); + assert_eq!(pixel(&subtracted, 0, 0), RED, "left half"); + assert_eq!(pixel(&subtracted, 3, 0), BACKGROUND, "right half"); + + // Invert — the top level's inverted coverage stands in for the + // coverage below, which is the bottom level's half. + let inverted = render(&build(MaskCombineMode::Invert), 0, 0).expect("render"); + assert_eq!(pixel(&inverted, 0, 0), RED, "left half"); + assert_eq!(pixel(&inverted, 3, 0), BACKGROUND, "right half"); +} + +/// A disabled mask group draws its content unmasked, and the mask's +/// own artwork stays hidden. +#[test] +fn a_disabled_mask_draws_its_content_unmasked() { + let mut doc = Document::default(); + doc.tiles.insert(0, solid(RED)); + doc.tiles.insert(1, gray_step(255, 0, 2)); + let content = image(&mut doc.registry, (4, 4), 0); + let mask = image(&mut doc.registry, (4, 4), 1); + let group = regular_group(&mut doc.registry, vec![mask, content], true); + doc.pages + .push(page(4.0, 4.0, vec![layer(vec![cell(vec![group])])])); + + let render = render(&doc, 0, 0).expect("render"); + assert_eq!(pixel(&render, 0, 0), RED, "left half"); + assert_eq!( + pixel(&render, 3, 0), + RED, + "right half: the mask does not apply" + ); +} + +/// A plain group that holds an image first is not a disabled mask: it +/// draws every child, as it always did. +#[test] +fn a_plain_group_draws_every_child() { + let mut doc = Document::default(); + doc.tiles.insert(0, solid(RED)); + doc.tiles.insert(1, gray_step(255, 0, 2)); + // The content covers only the left half, so the image first shows + // through on the right. + let content = image(&mut doc.registry, (2, 2), 0); + let picture = image(&mut doc.registry, (4, 4), 1); + let group = regular_group(&mut doc.registry, vec![picture, content], false); + doc.pages + .push(page(4.0, 4.0, vec![layer(vec![cell(vec![group])])])); + + let render = render(&doc, 0, 0).expect("render"); + assert_eq!(pixel(&render, 0, 0), RED, "the content"); + assert_eq!( + pixel(&render, 3, 0), + [0, 0, 0, 255], + "the image first still draws" + ); +} + +/// A disabled level of a stack contributes no coverage: the level +/// below it applies on its own. +#[test] +fn a_disabled_level_in_a_stack_is_skipped() { + let mut doc = Document::default(); + doc.tiles.insert(0, solid(RED)); + // The bottom level covers the left half; the top level covers the + // right half and intersects it, so an enabled stack shows nothing. + doc.tiles.insert(1, gray_step(255, 0, 2)); + doc.tiles.insert(2, gray_step(0, 255, 2)); + let content = image(&mut doc.registry, (4, 4), 0); + let bottom_mask = image(&mut doc.registry, (4, 4), 1); + let top_mask = image(&mut doc.registry, (4, 4), 2); + let bottom = mask_group(&mut doc.registry, vec![bottom_mask, content]); + let top = mask_group_with_mode( + &mut doc.registry, + vec![top_mask, bottom], + MaskCombineMode::Intersect, + ); + // The top level is disabled: the shape `mask.enable` leaves + // behind. + let disabled = regular_group(&mut doc.registry, vec![top_mask, bottom], true); + assert_ne!(disabled, top, "the disabled level is a distinct group"); + doc.pages + .push(page(4.0, 4.0, vec![layer(vec![cell(vec![disabled])])])); + + let render = render(&doc, 0, 0).expect("render"); + assert_eq!(pixel(&render, 0, 0), RED, "the bottom level applies"); + assert_eq!(pixel(&render, 3, 0), BACKGROUND, "the top level is skipped"); +} + +/// A disabled top level still lets the level below it apply. +#[test] +fn a_disabled_top_level_still_applies_the_level_below() { + let mut doc = Document::default(); + doc.tiles.insert(0, solid(RED)); + doc.tiles.insert(1, gray_step(255, 0, 2)); + let content = image(&mut doc.registry, (4, 4), 0); + let bottom_mask = image(&mut doc.registry, (4, 4), 1); + let bottom = mask_group(&mut doc.registry, vec![bottom_mask, content]); + // A disabled level whose content is the level below. + let top_mask = image(&mut doc.registry, (4, 4), 1); + let disabled = regular_group(&mut doc.registry, vec![top_mask, bottom], true); + doc.pages + .push(page(4.0, 4.0, vec![layer(vec![cell(vec![disabled])])])); + + let render = render(&doc, 0, 0).expect("render"); + assert_eq!(pixel(&render, 0, 0), RED, "the bottom level applies"); + assert_eq!(pixel(&render, 3, 0), BACKGROUND, "outside the coverage"); +} diff --git a/crates/doc/src/fwmap/mod.rs b/crates/doc/src/fwmap/mod.rs index ca36cba2..5e42b81d 100644 --- a/crates/doc/src/fwmap/mod.rs +++ b/crates/doc/src/fwmap/mod.rs @@ -15,6 +15,8 @@ mod reader; mod unknown; mod writer; +use fwpng::nodetree::Node; + #[cfg(test)] mod tests; @@ -25,6 +27,40 @@ pub use writer::{WriteStats, write, write_style_library}; /// A Fireworks node `FourCC` (`PTHv`, `OPAi`, …). pub type FourCC = [u8; 4]; +/// `true` for a `DCEv` data entry filed under `key`. +/// +/// A data entry is a `DCEv` node whose first child is a `DCKs` string +/// naming the key. The value child is deliberately not required to be +/// well formed: an entry whose value is broken is still *the* entry +/// for that key, and the model owns the key — the reader reads it, the +/// writer rewrites or drops it. +pub(crate) fn is_data_entry(node: &Node, key: &str) -> bool { + if node.fourcc != *b"DCEv" { + return false; + } + let Some(kids) = node.as_children() else { + return false; + }; + let Some(first) = kids.first() else { + return false; + }; + first.fourcc == *b"DCKs" && first.as_str() == Some(key) +} + +/// The string value of a `DCEv` data entry. +/// +/// `None` when the entry's value child is missing or is not a +/// `DCVs` string — the entry is still *the* entry for its key, but it +/// carries no value the model can read. +pub(crate) fn data_entry_value(node: &Node) -> Option<&str> { + let kids = node.as_children()?; + let value = kids.get(1)?; + if value.fourcc != *b"DCVs" { + return None; + } + value.as_str() +} + /// Mapping failures. Type-level violations on *known* nodes are errors; /// anything merely outside the mapped spine is preserved instead. #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/crates/doc/src/fwmap/reader.rs b/crates/doc/src/fwmap/reader.rs index a19f122b..98099b48 100644 --- a/crates/doc/src/fwmap/reader.rs +++ b/crates/doc/src/fwmap/reader.rs @@ -7,15 +7,16 @@ //! primitive carrying a projective `MTXv`) is bagged whole — preservation //! never waits on semantics. -use crate::fwmap::MapError; use crate::fwmap::unknown::UnknownBag; +use crate::fwmap::{MapError, data_entry_value, is_data_entry}; use crate::model::{ Canvas, Cell, Contour, Dither, Document, DocumentInfo, Effect, EffectProperty, EffectValue, ExportOptions, Fill, FillShape, Gradient, GradientStop, Grid, GroupObject, Guides, ImageObject, - Layer, LayerList, NineSliceGuides, Page, PageInfo, PaintStyle, PathObject, PathPoint, - PatternTile, PrimitiveSource, SharedLayerDef, SharedLayerLink, SliceBackground, SliceBehavior, - SliceObject, StyleEntry, StyleText, SymbolAnimation, SymbolDefinition, SymbolInstanceObject, - SymbolType, TextObject, TextOnPath, TextRun, TextStyle, TileRef, + Layer, LayerList, MASK_STACK_MODE_KEY, MaskCombineMode, NineSliceGuides, Page, PageInfo, + PaintStyle, PathObject, PathPoint, PatternTile, PrimitiveSource, SharedLayerDef, + SharedLayerLink, SliceBackground, SliceBehavior, SliceObject, StyleEntry, StyleText, + SymbolAnimation, SymbolDefinition, SymbolInstanceObject, SymbolType, TextObject, TextOnPath, + TextRun, TextStyle, TileRef, }; use crate::registry::{ObjectData, ObjectId, Registry}; use fwpng::nodetree::{Node, NodeValue}; @@ -2160,10 +2161,32 @@ impl Reader { }) } + /// `DTAv` children → the mask level's combine mode (decision + /// 0092). The node itself stays bagged either way — foreign data + /// round-trips verbatim — but the `DCEv` entry keyed + /// [`MASK_STACK_MODE_KEY`] is read into the model. An unknown + /// spelling canonicalizes to the default (the absent mode); the + /// writer owns that key, so the entry is rewritten in canonical + /// form or dropped — except an unknown spelling, which the model + /// cannot express and which therefore survives verbatim in the + /// bag. + fn read_stack_mode(children: &[Node]) -> Option { + for child in children { + if !is_data_entry(child, MASK_STACK_MODE_KEY) { + continue; + } + if let Some(mode) = data_entry_value(child).and_then(MaskCombineMode::parse) { + return Some(mode); + } + } + None + } + fn read_group(&mut self, children: &[Node], path: &str) -> Result { let (mut group_type, mut opacity, mut blend) = (None, None, None); let (mut visible, mut locked, mut mask_hidden) = (None, None, None); let (mut name, mut element_ids, mut mask_linked) = (None, None, None); + let mut stack_mode: Option = None; let mut effects = Vec::new(); for (i, child) in children.iter().enumerate() { @@ -2187,6 +2210,18 @@ impl Reader { self.bag_node(path, i, child); } } + // The mask level's combine mode rides in the group's + // custom data as a `DCEv` entry keyed + // `MASK_STACK_MODE_KEY` (decision 0092). The node is + // bagged as well, so foreign entries round-trip + // verbatim; the writer owns the mode key itself. + b"DTAv" => { + let kids = children_of(child, path)?; + if let Some(mode) = Self::read_stack_mode(kids) { + stack_mode = Some(mode); + } + self.bag_node(path, i, child); + } // DTAv / IMGv / MRMv / ISSv and anything else: // preserved (EFLv maps below). b"EFLv" => { @@ -2198,6 +2233,7 @@ impl Reader { } Ok(ObjectData::Group(GroupObject { + stack_mode, group_type: group_type.unwrap_or(0), name, children: element_ids.unwrap_or_default(), diff --git a/crates/doc/src/fwmap/tests.rs b/crates/doc/src/fwmap/tests.rs index 85adc2db..4e36994f 100644 --- a/crates/doc/src/fwmap/tests.rs +++ b/crates/doc/src/fwmap/tests.rs @@ -11,10 +11,11 @@ use super::unknown::UnknownBag; use super::writer::{mapped_indices, splice, write, write_style_library}; use crate::model::{ Canvas, Cell, Contour, Document, DocumentInfo, Effect, EffectProperty, EffectValue, - ExportOptions, Grid, GroupObject, Guides, ImageObject, Layer, LayerList, NineSliceGuides, Page, - PageInfo, PaintStyle, PathObject, PathPoint, PrimitiveSource, SharedLayerDef, SharedLayerLink, - SliceBackground, SliceBehavior, SliceObject, StyleEntry, StyleText, SymbolAnimation, - SymbolDefinition, SymbolInstanceObject, SymbolType, TextObject, TextRun, TextStyle, TileRef, + ExportOptions, Grid, GroupObject, Guides, ImageObject, Layer, LayerList, MASK_STACK_MODE_KEY, + MaskCombineMode, NineSliceGuides, Page, PageInfo, PaintStyle, PathObject, PathPoint, + PrimitiveSource, SharedLayerDef, SharedLayerLink, SliceBackground, SliceBehavior, SliceObject, + StyleEntry, StyleText, SymbolAnimation, SymbolDefinition, SymbolInstanceObject, SymbolType, + TextObject, TextRun, TextStyle, TileRef, }; use crate::registry::{ObjectData, ObjectId, Registry}; use fwpng::nodetree::{Node, NodeValue, parse, serialize}; @@ -334,6 +335,7 @@ fn sample_document() -> Document { }), })); let group_id = doc.registry.insert(ObjectData::Group(GroupObject { + stack_mode: None, group_type: 1, name: Some("grp".into()), children: vec![text_id, slice_id], @@ -602,6 +604,376 @@ fn lnkb_writes_only_when_unlinked_and_round_trips() { assert!(!bytes.is_empty()); } +// --- decision 0092: the mask stack's combine mode ---------------------- + +/// The sample document's group — the mask level the mode rides on. +fn sample_group_id(doc: &Document) -> ObjectId { + doc.registry + .iter() + .find(|(_, o)| matches!(o, ObjectData::Group(_))) + .map(|(id, _)| id) + .expect("sample carries a group") +} + +/// The group's mode, read back through the model. +fn group_mode(doc: &Document, id: ObjectId) -> Option { + match doc.registry.get(id) { + Some(ObjectData::Group(g)) => g.stack_mode, + other => panic!("group vanished: {other:?}"), + } +} + +/// The group's data node in a written tree, when it carries one. +fn group_data_node(tree: &[Node]) -> Option<&Node> { + let mut groups = Vec::new(); + find_nodes(tree, b"GRPv", &mut groups); + let group = groups.first()?; + kids_of(group).iter().find(|node| &node.fourcc == b"DTAv") +} + +#[test] +fn the_mask_stack_mode_is_written_and_read_back() { + // A fresh file: the mode rides in the group's data node. + let mut doc = sample_document(); + let group_id = sample_group_id(&doc); + if let Some(ObjectData::Group(slot)) = doc.registry.get_mut(group_id) { + slot.stack_mode = Some(MaskCombineMode::Subtract); + } + let (_bytes, doc2, _bag2) = round_trip_bytes(&doc, &UnknownBag::new()); + assert_eq!( + group_mode(&doc2, group_id), + Some(MaskCombineMode::Subtract), + "the mode survives bytes" + ); + + // No mode: no data node is minted for it. + if let Some(ObjectData::Group(slot)) = doc.registry.get_mut(group_id) { + slot.stack_mode = None; + } + let (tree, _stats) = write(&doc, &UnknownBag::new()).expect("write"); + assert!( + group_data_node(&tree).is_none(), + "a group without a mode writes no data node" + ); +} + +#[test] +fn the_model_owns_the_mode_key_and_foreign_data_survives() { + // A file whose group carries a data node: a CS6 author's entry and + // the mask stack's mode. + let doc = sample_document(); + let group_id = sample_group_id(&doc); + let (mut tree, _stats) = write(&doc, &UnknownBag::new()).expect("write"); + let mut groups = Vec::new(); + find_nodes(&tree, b"GRPv", &mut groups); + assert_eq!(groups.len(), 1, "the sample carries one group"); + let group = find_node_mut(&mut tree, b"GRPv").expect("the sample carries one group"); + children_mut(group).push(n_k( + b"DTAv", + vec![ + n_k( + b"DCEv", + vec![n_s(b"DCKs", "tooltips"), n_s(b"DCVs", "a CS6 tip")], + ), + n_k( + b"DCEv", + vec![n_s(b"DCKs", MASK_STACK_MODE_KEY), n_s(b"DCVs", "intersect")], + ), + ], + )); + let bytes = serialize(&tree); + let parsed = parse(&bytes).expect("reparse"); + let (doc2, bag2) = read(&parsed).expect("read"); + assert_eq!( + group_mode(&doc2, group_id), + Some(MaskCombineMode::Intersect), + "the mode is read out of the data node" + ); + + // The model wins over the file: a changed mode is what is written. + let mut doc3 = doc2.clone(); + if let Some(ObjectData::Group(slot)) = doc3.registry.get_mut(group_id) { + slot.stack_mode = Some(MaskCombineMode::Invert); + } + let (tree2, _stats) = write(&doc3, &bag2).expect("write"); + let data_node = group_data_node(&tree2).expect("the group carries its data node"); + let entries: Vec<(&str, &str)> = kids_of(data_node) + .iter() + .filter_map(|entry| { + let kids = kids_of(entry); + let key = kids.first().and_then(|k| k.as_str())?; + let value = kids.get(1).and_then(|v| v.as_str())?; + Some((key, value)) + }) + .collect(); + assert_eq!( + entries, + vec![("tooltips", "a CS6 tip"), (MASK_STACK_MODE_KEY, "invert"),], + "the foreign entry survives and the mode is rewritten" + ); +} + +#[test] +fn an_unrecognized_mode_spelling_survives_the_round_trip() { + // A file from a future FireCraft carries a mode this build does + // not know. The model canonicalizes it to the default — but the + // file's own spelling must not be lost, because the model cannot + // express it. + let doc = sample_document(); + let group_id = sample_group_id(&doc); + let (mut tree, _stats) = write(&doc, &UnknownBag::new()).expect("write"); + let mut groups = Vec::new(); + find_nodes(&tree, b"GRPv", &mut groups); + assert_eq!(groups.len(), 1, "the sample carries one group"); + let group = find_node_mut(&mut tree, b"GRPv").expect("the sample carries one group"); + children_mut(group).push(n_k( + b"DTAv", + vec![n_k( + b"DCEv", + vec![n_s(b"DCKs", MASK_STACK_MODE_KEY), n_s(b"DCVs", "gigantic")], + )], + )); + let bytes = serialize(&tree); + let parsed = parse(&bytes).expect("reparse"); + let (doc2, bag2) = read(&parsed).expect("read"); + assert_eq!( + group_mode(&doc2, group_id), + None, + "an unknown spelling canonicalizes to the default" + ); + + let (tree2, _stats) = write(&doc2, &bag2).expect("write"); + let data_node = group_data_node(&tree2).expect("the group carries its data node"); + let values: Vec<&str> = kids_of(data_node) + .iter() + .filter_map(|entry| { + let kids = kids_of(entry); + kids.first().and_then(|k| k.as_str())?; + kids.get(1).and_then(|v| v.as_str()) + }) + .collect(); + assert_eq!( + values, + vec!["gigantic"], + "the spelling the model cannot express survives verbatim" + ); +} + +// --- decision 0093: the mask stack's shape ----------------------------- + +/// A mask stack: two nested mask groups, each holding a mask and the +/// level below it, plus the content the bottom level restricts. +/// +/// The shape is the stack itself (decision 0093): the outer group is +/// the top level, its content child is the level below, and the +/// bottom level's content child is the masked object. +fn mask_stack_document() -> (Document, Vec) { + let mut doc = Document::default(); + let content = doc.registry.insert(ObjectData::Path(PathObject { + contours: Vec::new(), + primitive: None, + even_odd: false, + paint: None, + fill_start: None, + fill_end: None, + fill_third: None, + brush_placement: None, + fill_on_top: Some(false), + locked: false, + visible: true, + opacity: 1000, + blend: 0, + name: Some("content".to_owned()), + effects: Vec::new(), + })); + let bottom_mask = doc.registry.insert(ObjectData::Path(PathObject { + contours: Vec::new(), + primitive: None, + even_odd: false, + paint: None, + fill_start: None, + fill_end: None, + fill_third: None, + brush_placement: None, + fill_on_top: Some(false), + locked: false, + visible: true, + opacity: 1000, + blend: 0, + name: Some("bottom-mask".to_owned()), + effects: Vec::new(), + })); + let top_mask = doc.registry.insert(ObjectData::Path(PathObject { + contours: Vec::new(), + primitive: None, + even_odd: false, + paint: None, + fill_start: None, + fill_end: None, + fill_third: None, + brush_placement: None, + fill_on_top: Some(false), + locked: false, + visible: true, + opacity: 1000, + blend: 0, + name: Some("top-mask".to_owned()), + effects: Vec::new(), + })); + // The bottom level: `GRTi` 2 (shape mask), its content child is + // the masked object. + let bottom = doc.registry.insert(ObjectData::Group(GroupObject { + stack_mode: Some(MaskCombineMode::Add), + group_type: 2, + name: None, + children: vec![bottom_mask, content], + opacity: 1000, + blend: 0, + visible: true, + locked: false, + mask_hidden: false, + mask_linked: None, + effects: Vec::new(), + })); + // The top level: its content child is the level below. + let top = doc.registry.insert(ObjectData::Group(GroupObject { + stack_mode: Some(MaskCombineMode::Subtract), + group_type: 2, + name: None, + children: vec![top_mask, bottom], + opacity: 1000, + blend: 0, + visible: true, + locked: false, + mask_hidden: false, + mask_linked: None, + effects: Vec::new(), + })); + doc.pages.push(Page { + info: PageInfo { + version: None, + revision: None, + frame_count: 1, + frame_count_duplicate: 1, + request_id: 0, + canvas: Canvas::default(), + onion_before: 0, + onion_after: 0, + frame_durations: Vec::new(), + }, + name: None, + is_master: false, + is_linked: false, + page_id: None, + last_export_dir: None, + last_export_file: None, + layers: LayerList { + layers: vec![Layer { + cells: vec![Cell { + elements: vec![top], + ..Cell::default() + }], + ..Layer::default() + }], + }, + grid: None, + guides: Guides::default(), + export: None, + }); + (doc, vec![content, bottom_mask, top_mask, bottom, top]) +} + +/// The document's single root group — the top level of the stack. +fn root_group(doc: &Document) -> GroupObject { + let id = doc.pages[0].layers.layers[0].cells[0].elements[0]; + match doc.registry.get(id) { + Some(ObjectData::Group(group)) => group.clone(), + other => panic!("the root is not a group: {other:?}"), + } +} + +/// The group among `children`, if any. +fn child_group(doc: &Document, children: &[ObjectId]) -> GroupObject { + for id in children { + if let Some(ObjectData::Group(group)) = doc.registry.get(*id) { + return group.clone(); + } + } + panic!("no group among {children:?}"); +} + +/// The names of the objects in `ids`, in order. +fn names_of(doc: &Document, ids: &[ObjectId]) -> Vec { + ids.iter() + .map(|id| { + let name = match doc.registry.get(*id) { + Some(ObjectData::Path(path)) => path.name.clone(), + Some(ObjectData::Text(text)) => text.name.clone(), + Some(ObjectData::Group(group)) => group.name.clone(), + Some(ObjectData::Image(image)) => image.name.clone(), + Some(ObjectData::Slice(slice)) => slice.name.clone(), + Some(ObjectData::SymbolInstance(symbol)) => symbol.name.clone(), + None => None, + }; + name.unwrap_or_default() + }) + .collect() +} + +#[test] +fn a_mask_stack_survives_the_round_trip() { + let (doc, _ids) = mask_stack_document(); + let (bytes, doc2, _bag2) = round_trip_bytes(&doc, &UnknownBag::new()); + assert!(!bytes.is_empty()); + + // The stack's shape survives: the top level holds its mask and the + // level below, and the bottom level holds its mask and the content. + // The reader mints its own object ids, so the assertions name the + // objects rather than index into the registry. + let top = root_group(&doc2); + assert_eq!(top.group_type, 2, "the top level is a shape mask"); + assert_eq!( + top.stack_mode, + Some(MaskCombineMode::Subtract), + "the top mode" + ); + assert_eq!( + names_of(&doc2, &top.children[..1]), + vec!["top-mask"], + "the top level holds its mask" + ); + + let bottom = child_group(&doc2, &top.children); + assert_eq!(bottom.group_type, 2, "the bottom level is a shape mask"); + assert_eq!( + bottom.stack_mode, + Some(MaskCombineMode::Add), + "the bottom mode" + ); + assert_eq!( + names_of(&doc2, &bottom.children), + vec!["bottom-mask", "content"], + "the bottom level holds its mask and the content" + ); +} + +#[test] +fn a_disabled_level_survives_the_round_trip() { + // A disabled level is a `GRTi` 0 group whose mask artwork is + // hidden (`MNAb`) — the shape `mask.enable` leaves behind. It must + // still read as disabled after the file round trip, or the level + // would silently come back enabled. + let (mut doc, ids) = mask_stack_document(); + if let Some(ObjectData::Group(group)) = doc.registry.get_mut(ids[4]) { + group.group_type = 0; + group.mask_hidden = true; + } + let (_bytes, doc2, _bag2) = round_trip_bytes(&doc, &UnknownBag::new()); + let top = root_group(&doc2); + assert_eq!(top.group_type, 0, "the level stays a plain group"); + assert!(top.mask_hidden, "the level stays disabled"); +} + // --- M10: symbol wire contract (MSYi / NGSv / ANNi family) -------------- /// Collect references to every node carrying `fourcc`, at any depth. @@ -623,6 +995,21 @@ fn kids_of(node: &Node) -> &[Node] { } } +/// The first node carrying `fourcc`, at any depth, mutably. +fn find_node_mut<'a>(nodes: &'a mut [Node], fourcc: &[u8; 4]) -> Option<&'a mut Node> { + for node in nodes.iter_mut() { + if &node.fourcc == fourcc { + return Some(node); + } + if let NodeValue::Children(kids) = &mut node.value + && let Some(found) = find_node_mut(kids, fourcc) + { + return Some(found); + } + } + None +} + #[test] fn symbol_definition_wire_shape_matches_dictionary() { let doc = sample_document(); @@ -1459,6 +1846,7 @@ fn model_structs_are_fully_constructed() { effects: Vec::new(), }; let _group = GroupObject { + stack_mode: None, group_type: 0, name: None, children: Vec::new(), diff --git a/crates/doc/src/fwmap/writer.rs b/crates/doc/src/fwmap/writer.rs index f3ba91ad..28f648f9 100644 --- a/crates/doc/src/fwmap/writer.rs +++ b/crates/doc/src/fwmap/writer.rs @@ -10,13 +10,13 @@ //! canonicalizes rather than reproduces the omission. use crate::fwmap::unknown::UnknownBag; -use crate::fwmap::{FourCC, MapError}; +use crate::fwmap::{FourCC, MapError, data_entry_value, is_data_entry}; use crate::model::{ Cell, Contour, Document, Effect, EffectProperty, EffectValue, ExportOptions, Fill, FillShape, Gradient, GradientStop, Grid, GroupObject, Guides, ImageObject, Layer, LayerList, - NineSliceGuides, PaintStyle, PathObject, PathPoint, PatternTile, PrimitiveSource, - SharedLayerDef, SliceObject, StyleEntry, SymbolDefinition, SymbolInstanceObject, TextObject, - TextOnPath, TextRun, TextStyle, TileRef, + MASK_STACK_MODE_KEY, MaskCombineMode, NineSliceGuides, PaintStyle, PathObject, PathPoint, + PatternTile, PrimitiveSource, SharedLayerDef, SliceObject, StyleEntry, SymbolDefinition, + SymbolInstanceObject, TextObject, TextOnPath, TextRun, TextStyle, TileRef, }; use crate::registry::{ObjectData, ObjectId}; use fwpng::nodetree::{Node, NodeValue}; @@ -245,6 +245,84 @@ pub(crate) fn mapped_indices(bagged: &[(usize, Node)], mapped_len: usize) -> Vec out } +/// The `DCEv` entry a mask stack's combine mode rides in: a key/value +/// pair inside the mask group's `DTAv` data node (decision 0092). +fn stack_mode_entry(mode: MaskCombineMode) -> Node { + n_kids( + *b"DCEv", + vec![ + n_s(*b"DCKs", MASK_STACK_MODE_KEY), + n_s(*b"DCVs", mode.as_str()), + ], + ) +} + +/// Rewrites the mask stack mode inside a preserved group's `DTAv`. +/// +/// The model owns the `MASK_STACK_MODE_KEY` entry, so the model wins +/// over whatever the file carried: the entry is rewritten with the +/// model's mode, or dropped when the model says the level carries no +/// mode. It keeps its place among the data node's children, so a tree +/// that was canonical on read re-serializes byte-identically. +/// +/// One exception: an entry whose value the model cannot parse (a +/// spelling from a future `FireCraft`) is *not* the model's to drop — +/// the model cannot express it, so the bag keeps it verbatim. Every +/// other entry — a CS6 author's `tooltips`, a nested value the model +/// cannot express — is preserved verbatim too. +fn merge_stack_modes( + bagged: &[(usize, Node)], + mode: Option, +) -> Vec<(usize, Node)> { + let mut out = Vec::with_capacity(bagged.len()); + for (index, node) in bagged { + if node.fourcc != *b"DTAv" { + out.push((*index, node.clone())); + continue; + } + let Some(kids) = node.as_children() else { + out.push((*index, node.clone())); + continue; + }; + let mut children = Vec::with_capacity(kids.len() + 1); + let mut rewritten = false; + for kid in kids { + if !is_data_entry(kid, MASK_STACK_MODE_KEY) { + children.push(kid.clone()); + continue; + } + let recognized = data_entry_value(kid) + .and_then(MaskCombineMode::parse) + .is_some(); + match mode { + Some(mode) => { + if rewritten { + // A repeated key would lose data on rewrite; + // the first one wins and the rest are dropped. + continue; + } + rewritten = true; + children.push(stack_mode_entry(mode)); + } + None => { + // The model holds the default union. An entry the + // model can read is the model's to replace (it + // goes); an entry it cannot read is preserved. + if recognized { + continue; + } + children.push(kid.clone()); + } + } + } + if !rewritten && let Some(mode) = mode { + children.push(stack_mode_entry(mode)); + } + out.push((*index, n_kids(*b"DTAv", children))); + } + out +} + struct Writer<'a> { bag: UnknownBag, doc: &'a Document, @@ -1164,9 +1242,18 @@ impl Writer<'_> { } fn write_group_object(&mut self, object: &GroupObject, path: &str) -> Result { - // Dictionary order: LCKb? GRTi ELMv OBNs? … MNAb? … LNKb? … VISb? OPAi? BLDi? + // Dictionary order: LCKb? GRTi ELMv OBNs? DTAv? … MNAb? … LNKb? … VISb? OPAi? BLDi? // MNAb is never present with the value `false` (dictionary); // LNKb is never present with the value `true` (dictionary). + // + // The group's preserved children are taken up front: the mask + // stack's combine mode rides in the group's `DTAv` data node + // (decision 0092), and the writer owns that key — so it has to + // know whether a `DTAv` was preserved before it decides to + // emit one. + let bagged = self.bag.take_under(path); + let preserves_dtav = bagged.iter().any(|(_, node)| node.fourcc == *b"DTAv"); + let mut mapped = Vec::new(); if object.locked { mapped.push(n_b(*b"LCKb", true)); @@ -1176,6 +1263,9 @@ impl Writer<'_> { if let Some(name) = &object.name { mapped.push(n_s(*b"OBNs", name)); } + if !preserves_dtav && let Some(mode) = object.stack_mode { + mapped.push(n_kids(*b"DTAv", vec![stack_mode_entry(mode)])); + } mapped.extend(self.write_effects(&object.effects, path)?); if object.mask_hidden { mapped.push(n_b(*b"MNAb", true)); @@ -1186,7 +1276,7 @@ impl Writer<'_> { mapped.push(n_b(*b"VISb", object.visible)); mapped.push(n_i(*b"OPAi", object.opacity)); mapped.push(n_i(*b"BLDi", object.blend)); - let children = self.splice_container(mapped, path); + let children = splice(mapped, &merge_stack_modes(&bagged, object.stack_mode)); Ok(n_kids(*b"GRPv", children)) } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 92a3ade6..2347cdd1 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -20,11 +20,12 @@ pub mod registry; pub use model::{ Canvas, Cell, Clipboard, Contour, DEFAULT_FRAME_DURATION, Dither, Document, DocumentInfo, Effect, EffectProperty, EffectValue, ExportOptions, Fill, FillShape, Gradient, GradientStop, - Grid, GroupObject, Guides, ImageObject, Layer, LayerList, NineSliceGuides, Page, PageInfo, - PaintStyle, PathObject, PathPoint, PatternTile, PrimitiveSource, SavedSelection, SelectionMask, - SharedLayerDef, SharedLayerLink, SliceBackground, SliceBehavior, SliceObject, StyleEntry, - StyleText, SymbolAnimation, SymbolDefinition, SymbolInstanceObject, SymbolType, TextObject, - TextOnPath, TextRun, TextStyle, TileBank, TileRef, + Grid, GroupObject, Guides, ImageObject, Layer, LayerList, MASK_STACK_MODE_KEY, MaskCombineMode, + NineSliceGuides, Page, PageInfo, PaintStyle, PathObject, PathPoint, PatternTile, + PrimitiveSource, SavedSelection, SelectionMask, SharedLayerDef, SharedLayerLink, + SliceBackground, SliceBehavior, SliceObject, StyleEntry, StyleText, SymbolAnimation, + SymbolDefinition, SymbolInstanceObject, SymbolType, TextObject, TextOnPath, TextRun, TextStyle, + TileBank, TileRef, }; pub use registry::{ObjectData, ObjectId, Registry}; // `EffectValue::Raw` exposes the node type; re-export it so consumers diff --git a/crates/doc/src/model.rs b/crates/doc/src/model.rs index ce827fa4..6034a557 100644 --- a/crates/doc/src/model.rs +++ b/crates/doc/src/model.rs @@ -1150,6 +1150,61 @@ impl TextObject { } } +/// The `DCEv` key the mask stack's combine mode rides under inside a +/// mask group's `DTAv` data node. Namespaced: CS6 authors may put +/// their own `DCEv` entries in a group's data node (the dictionary +/// lists `tooltips`, `radius1`, `radius0`, `alrtmsg`, `round0`), and a +/// bare `mode` would be read out of one of those. +pub const MASK_STACK_MODE_KEY: &str = "firecraft.mask.mode"; + +/// How a mask level in a mask stack combines with the levels below it. +/// `FireCraft` extension (decision 0092): CS6 has one element mask per +/// object and no stack, so the mode rides in the mask group's data +/// node under the [`MASK_STACK_MODE_KEY`] key. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum MaskCombineMode { + /// Union with the coverage below. + Add, + /// Cut this level's coverage out of the coverage below. + Subtract, + /// Keep only the overlap with the coverage below. + Intersect, + /// Show what this level's mask hides — the level's inverted + /// coverage stands in for the coverage below. + Invert, +} + +impl MaskCombineMode { + /// Every mode, in panel order. + pub const ALL: [Self; 4] = [Self::Add, Self::Subtract, Self::Intersect, Self::Invert]; + + /// The wire spelling (the `DCVs` value under + /// [`MASK_STACK_MODE_KEY`]). + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Add => "add", + Self::Subtract => "subtract", + Self::Intersect => "intersect", + Self::Invert => "invert", + } + } + + /// Parses a wire spelling; `None` for anything else, so a hostile + /// value canonicalizes to the default (the absent mode). + #[must_use] + pub fn parse(value: &str) -> Option { + match value { + "add" => Some(Self::Add), + "subtract" => Some(Self::Subtract), + "intersect" => Some(Self::Intersect), + "invert" => Some(Self::Invert), + _ => None, + } + } +} + /// `GRPv` — a group, including mask-structured groups. #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -1174,6 +1229,11 @@ pub struct GroupObject { /// dictionary defines as linked (`true` is never written); /// `Some(false)` = the content moves independently of the mask. pub mask_linked: Option, + /// The combine mode of this mask level inside its mask stack + /// (a `DCEv` entry under `DTAv`, keyed + /// [`MASK_STACK_MODE_KEY`]; `FireCraft` extension, decision 0092). + /// `None` = the default union ([`MaskCombineMode::Add`]). + pub stack_mode: Option, /// `EFLv`* — live effects, file order. pub effects: Vec, } diff --git a/crates/engine/src/catalog/bake.rs b/crates/engine/src/catalog/bake.rs index c0c0cea7..393ddbeb 100644 --- a/crates/engine/src/catalog/bake.rs +++ b/crates/engine/src/catalog/bake.rs @@ -1251,6 +1251,7 @@ mod tests { // ownership walk and converts the group's slot. let (mut document, child) = render_document(filled_square()); let group = document.registry.insert(ObjectData::Group(GroupObject { + stack_mode: None, group_type: 0, name: None, children: vec![child], diff --git a/crates/engine/src/catalog/cell.rs b/crates/engine/src/catalog/cell.rs index 66cca27a..a49406c9 100644 --- a/crates/engine/src/catalog/cell.rs +++ b/crates/engine/src/catalog/cell.rs @@ -1242,6 +1242,7 @@ mod tests { let group_id = document .registry .insert(doc::ObjectData::Group(doc::GroupObject { + stack_mode: None, group_type: 0, name: None, children: vec![ids[0], ids[1]], diff --git a/crates/engine/src/catalog/clipboard.rs b/crates/engine/src/catalog/clipboard.rs index 8bc4e1c2..6d7472c7 100644 --- a/crates/engine/src/catalog/clipboard.rs +++ b/crates/engine/src/catalog/clipboard.rs @@ -611,6 +611,7 @@ fn paste_as_mask_core( let container = detach(document, name, target)?; let mask_id = document.registry.insert(mask_data); let group_id = document.registry.insert(ObjectData::Group(GroupObject { + stack_mode: None, group_type, name: None, children: vec![mask_id, target], @@ -997,6 +998,7 @@ mod clipboard_tests { // write — while the healthy sibling stays cuttable. let (mut document, ids) = testdoc::sample_document(); let group_id = document.registry.insert(ObjectData::Group(GroupObject { + stack_mode: None, group_type: 0, name: None, children: vec![ids[0]], @@ -1045,6 +1047,7 @@ mod clipboard_tests { fn group_capture_is_subtree_closed_and_paste_rewrites_children() { let (mut document, ids) = testdoc::sample_document(); let group_id = document.registry.insert(ObjectData::Group(GroupObject { + stack_mode: None, group_type: 0, name: None, children: vec![ids[0], ids[1]], @@ -1195,6 +1198,7 @@ mod clipboard_tests { effects: Vec::new(), })); let group_id = document.registry.insert(ObjectData::Group(GroupObject { + stack_mode: None, group_type: 1, name: None, children: vec![mask_id, ids[1]], @@ -1263,6 +1267,7 @@ mod clipboard_tests { effects: Vec::new(), })); let group_id = document.registry.insert(ObjectData::Group(GroupObject { + stack_mode: None, group_type: 1, name: None, children: vec![mask_id, ids[1]], diff --git a/crates/engine/src/catalog/document.rs b/crates/engine/src/catalog/document.rs index b05180dd..db68db49 100644 --- a/crates/engine/src/catalog/document.rs +++ b/crates/engine/src/catalog/document.rs @@ -1869,6 +1869,7 @@ mod document_tests { let group_id = document .registry .insert(doc::ObjectData::Group(doc::GroupObject { + stack_mode: None, group_type: 0, name: None, children: vec![ids[0]], @@ -2147,6 +2148,7 @@ mod document_tests { let group_id = document .registry .insert(doc::ObjectData::Group(doc::GroupObject { + stack_mode: None, group_type: 0, name: None, children: vec![ids[0], ids[1]], diff --git a/crates/engine/src/catalog/filter.rs b/crates/engine/src/catalog/filter.rs index 9de6e1a7..992cda84 100644 --- a/crates/engine/src/catalog/filter.rs +++ b/crates/engine/src/catalog/filter.rs @@ -1055,6 +1055,7 @@ mod tests { effects: Vec::new(), })); let group = document.registry.insert(ObjectData::Group(GroupObject { + stack_mode: None, group_type: 0, name: None, children: Vec::new(), diff --git a/crates/engine/src/catalog/mask.rs b/crates/engine/src/catalog/mask.rs index e5cd355a..8d6b3153 100644 --- a/crates/engine/src/catalog/mask.rs +++ b/crates/engine/src/catalog/mask.rs @@ -15,8 +15,8 @@ //! retraction rule the frame family documented. use doc::{ - Contour, Document, GroupObject, ImageObject, ObjectData, ObjectId, PathObject, PathPoint, - SelectionMask, TileRef, + Contour, Document, GroupObject, ImageObject, MaskCombineMode, ObjectData, ObjectId, PathObject, + PathPoint, SelectionMask, TileRef, }; use geom::{Point, Vec2}; @@ -48,6 +48,10 @@ pub(crate) fn install(registry: &mut CommandRegistry) -> Result<(), EngineError> registry.register(RemoveMask)?; registry.register(SetMaskMode)?; registry.register(SetMaskShowAttrs)?; + registry.register(SetMaskStackMode)?; + registry.register(AddMaskLevel)?; + registry.register(RemoveMaskLevel)?; + registry.register(MoveMaskLevel)?; registry.register(PathToMarquee)?; registry.register(MarqueeToPath)?; registry.register(SaveJpegMask)?; @@ -738,6 +742,7 @@ impl TypedCommand for AddMask { }; let mask_id = document.registry.insert(ObjectData::Image(mask_image)); let group_id = document.registry.insert(ObjectData::Group(GroupObject { + stack_mode: None, group_type: GRAYSCALE_MASK, name: None, children: vec![mask_id, target_id], @@ -842,7 +847,13 @@ impl TypedCommand for EnableMask { }; group.group_type = group_type; } else { + // A disabled mask keeps the mask-first child order, so the + // mask's own artwork is hidden (`MNAb`) — that flag is also + // what tells the compositor the group is a disabled mask + // and not a plain group that happens to hold an image + // first (decision 0020). group.group_type = REGULAR_GROUP; + group.mask_hidden = true; } if let Some(ObjectData::Group(slot)) = document.registry.get_mut(resolved.id) { *slot = group.clone(); @@ -1384,6 +1395,696 @@ impl TypedCommand for SetMaskShowAttrs { } } +// --- mask.setStackMode -------------------------------------------------- + +/// `mask.setStackMode` — sets the combine mode of a mask level inside +/// a mask stack (decision 0092). A `FireCraft` extension: CS6 has one +/// element mask per object and no stack, so the mode rides in the mask +/// group's data node and the default is the union ("add"). +/// +/// The mode is stored and persisted, not yet rendered — the panel that +/// shows and changes it is the Phase 20 mask stack UI. +struct SetMaskStackMode; + +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +struct SetMaskStackModePayload { + /// The mask group, or the object it masks. + id: ObjectId, + /// "add" / "subtract" / "intersect" / "invert". + mode: String, +} + +impl TypedCommand for SetMaskStackMode { + type Payload = SetMaskStackModePayload; + + fn name(&self) -> &'static str { + "mask.setStackMode" + } + + fn apply( + &self, + document: &mut Document, + payload: &Self::Payload, + ) -> Result { + const NAME: &str = "mask.setStackMode"; + let mode = + MaskCombineMode::parse(&payload.mode).ok_or_else(|| EngineError::BadPayload { + name: NAME.to_owned(), + detail: format!( + "mode {:?} is not one of \"add\"/\"subtract\"/\"intersect\"/\"invert\"", + payload.mode + ), + })?; + // A level of a stack carries the mode, and a disabled level + // keeps its place in the stack — so the resolution accepts the + // disabled shape too, but never a plain group. + let resolved = + mask_group_of(document, payload.id).ok_or_else(|| EngineError::InvalidState { + name: NAME.to_owned(), + detail: format!( + "object {} (generation {}) carries no element mask", + payload.id.index(), + payload.id.generation() + ), + })?; + let Some(ObjectData::Group(previous)) = document.registry.get(resolved) else { + return Err(EngineError::MissingTarget { + name: NAME.to_owned(), + detail: "the mask group is not live".to_owned(), + }); + }; + let previous = previous.clone(); + let mut group = previous.clone(); + group.stack_mode = Some(mode); + if let Some(ObjectData::Group(slot)) = document.registry.get_mut(resolved) { + *slot = group.clone(); + } + Ok(Delta::Object { + id: resolved, + inverse: ObjectInverse::Mutated { + object: Box::new(ObjectData::Group(previous)), + }, + }) + } +} + +// --- the mask stack ------------------------------------------------------ + +/// One level of an element mask stack. +/// +/// A stack is spelled as nested mask groups: the outermost group is +/// the top level, and each group's content child is the level below it +/// (decision 0092). The innermost level's content is the object the +/// stack restricts. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MaskLevel { + /// The mask group that carries the level. + pub group: ObjectId, + /// The mask object the level draws its coverage from. + pub mask: ObjectId, + /// The level's combine mode — the default union when the group + /// carries none. + pub mode: MaskCombineMode, + /// Whether the level's mask is enabled. A disabled level + /// contributes no coverage at all. + pub enabled: bool, + /// Whether the level's mask is linked to its content — the + /// default. An unlinked level's mask moves on its own. + pub linked: bool, +} + +/// True when the group type marks a live (enabled) mask group. +fn is_enabled_mask_group(group_type: i64) -> bool { + group_type == GRAYSCALE_MASK || group_type == SHAPE_MASK +} + +/// True when the group is a disabled mask level: the `GRTi` 0 shape +/// `mask.enable` leaves behind, told apart from a plain group by the +/// mask-first child order and the `MNAb` flag (decision 0020). +pub(crate) fn is_disabled_mask_group(document: &Document, group: &GroupObject) -> bool { + group.group_type == REGULAR_GROUP + && group.mask_hidden + && group.children.len() > 1 + && matches!( + document.registry.get(group.children[0]), + Some(ObjectData::Image(_)) | Some(ObjectData::Path(_)) + ) +} + +/// The mask group behind `id`: the group itself when `id` is a mask +/// group, else the group holding `id` in its mask slot (`ELMv[0]`) or +/// its content slot (`ELMv[1]`). +/// +/// A disabled level counts: `mask.enable` turns a level into a +/// `GRTi` 0 group, and the stack still has that level in it. +fn mask_group_of(document: &Document, id: ObjectId) -> Option { + match document.registry.get(id) { + Some(ObjectData::Group(group)) if is_mask_group_kind(document, group) => Some(id), + _ => { + let Owner::Group { + id: parent, + position, + } = find_owner(document, id)? + else { + return None; + }; + if position > 1 { + return None; + } + let Some(ObjectData::Group(group)) = document.registry.get(parent) else { + return None; + }; + if !is_mask_group_kind(document, group) { + return None; + } + Some(parent) + } + } +} + +/// True when the group is a mask group — enabled (`GRTi` 1/2) or +/// disabled (the `GRTi` 0 shape `mask.enable` leaves behind). +fn is_mask_group_kind(document: &Document, group: &GroupObject) -> bool { + is_enabled_mask_group(group.group_type) || is_disabled_mask_group(document, group) +} + +/// The mask stack behind `id`, top level first. +/// +/// `id` is the stack's top group, any level's group, the mask a level +/// holds, or the object the stack restricts. Empty when `id` carries +/// no element mask. A disabled level stays in the list — it carries +/// no coverage, but the panel still shows it. +pub(crate) fn element_mask_stack(document: &Document, id: ObjectId) -> Vec { + let Some(top) = top_mask_group(document, id) else { + return Vec::new(); + }; + let mut levels = Vec::new(); + let mut current = top; + while let Some(ObjectData::Group(group)) = document.registry.get(current) { + let Some(&mask) = group.children.first() else { + break; + }; + levels.push(MaskLevel { + group: current, + mask, + mode: group.stack_mode.unwrap_or(MaskCombineMode::Add), + enabled: is_enabled_mask_group(group.group_type), + // `LNKb` is written only when the mask is *un*linked; its + // absence means the mask is linked to its content. + linked: group.mask_linked != Some(false), + }); + let Some(content) = group.children.get(1).copied() else { + break; + }; + match document.registry.get(content) { + Some(ObjectData::Group(inner)) if is_mask_group_kind(document, inner) => { + current = content; + } + _ => break, + } + } + levels +} + +/// The top level's group of the stack `id` belongs to. +fn top_mask_group(document: &Document, id: ObjectId) -> Option { + let mut current = mask_group_of(document, id)?; + // Walk up while the group is the content of another mask group: + // the outermost group is the stack's top level. + while let Some(Owner::Group { + id: parent, + position, + }) = find_owner(document, current) + { + if position != 1 { + break; + } + let Some(ObjectData::Group(group)) = document.registry.get(parent) else { + break; + }; + if !is_mask_group_kind(document, group) { + break; + } + current = parent; + } + Some(current) +} + +/// The level a command names: the mask group itself, or the group +/// holding the named mask. +fn level_group(document: &Document, id: ObjectId) -> Option { + mask_group_of(document, id) +} + +// --- mask.addLevel ------------------------------------------------------ + +/// `mask.addLevel` — adds a level to an element mask stack. +/// +/// A `FireCraft` extension (decision 0092): CS6 has one element mask +/// per object and no stack, so a stack is `FireCraft`'s own shape. The +/// new level wraps the stack's top level: the level's mask is the +/// named source object (moved into the stack), or a uniform "reveal +/// all" raster over the content's bounds when no source is named. +struct AddMaskLevel; + +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +struct AddMaskLevelPayload { + /// The masked object, or any object in its stack — the stack to + /// extend. + id: ObjectId, + /// The object whose shape becomes the new level's mask. It moves + /// into the stack. Without a source, the level's mask is a + /// uniform "reveal all" raster over the content's bounds. + #[serde(default)] + source: Option, + /// The new level's combine mode ("add" when absent). + #[serde(default)] + mode: Option, +} + +impl TypedCommand for AddMaskLevel { + type Payload = AddMaskLevelPayload; + + fn name(&self) -> &'static str { + "mask.addLevel" + } + + fn apply( + &self, + document: &mut Document, + payload: &Self::Payload, + ) -> Result { + const NAME: &str = "mask.addLevel"; + let mode = match &payload.mode { + Some(value) => { + MaskCombineMode::parse(value).ok_or_else(|| EngineError::BadPayload { + name: NAME.to_owned(), + detail: format!( + "mode {value:?} is not one of \"add\"/\"subtract\"/\"intersect\"/\"invert\"" + ), + })? + } + None => MaskCombineMode::Add, + }; + let top = + top_mask_group(document, payload.id).ok_or_else(|| EngineError::MissingTarget { + name: NAME.to_owned(), + detail: format!( + "object {} (generation {}) carries no element mask stack", + payload.id.index(), + payload.id.generation() + ), + })?; + let owner = find_owner(document, top).ok_or_else(|| EngineError::MissingTarget { + name: NAME.to_owned(), + detail: format!( + "mask group {} (generation {}) is referenced by no cell or group", + top.index(), + top.generation() + ), + })?; + + // The new level's mask. + let mut deltas = Vec::new(); + let (group_type, source, mint) = match payload.source { + Some(source) => { + let kind = group_type_of_mask(access::object(document, NAME, source)?).ok_or_else( + || EngineError::InvalidState { + name: NAME.to_owned(), + detail: format!( + "source {} (generation {}) is neither a bitmap nor a vector mask", + source.index(), + source.generation() + ), + }, + )?; + if source == top { + return Err(EngineError::InvalidState { + name: NAME.to_owned(), + detail: "the source is the stack's own top level".to_owned(), + }); + } + if contains(document, top, source) { + return Err(EngineError::InvalidState { + name: NAME.to_owned(), + detail: "the source is already part of the stack".to_owned(), + }); + } + if contains(document, source, top) { + return Err(EngineError::InvalidState { + name: NAME.to_owned(), + detail: "the source already holds the stack".to_owned(), + }); + } + (kind, Some(source), None) + } + None => { + // No source: the level's mask is a uniform "reveal + // all" raster over the content's bounds. + let content = document + .registry + .get(top) + .and_then(|object| match object { + ObjectData::Group(group) => group.children.get(1).copied(), + _ => None, + }) + .ok_or_else(|| EngineError::InvalidState { + name: NAME.to_owned(), + detail: "the mask group carries no content child".to_owned(), + })?; + let bounds = bounds_of(document, NAME, content)?; + (GRAYSCALE_MASK, None, Some(uniform_mask_image(bounds, 255))) + } + }; + + // The source moves into the stack: it leaves the container it + // was in. + if let Some(source) = source { + deltas.push(detach(document, NAME, source)?); + } + // The top level leaves its container; the new level takes its + // place. + deltas.push(detach(document, NAME, top)?); + + let mask_id = match (source, mint) { + (Some(source), _) => source, + (None, Some(image)) => { + let mask_id = document.registry.insert(ObjectData::Image(image)); + deltas.push(Delta::Object { + id: mask_id, + inverse: ObjectInverse::Created, + }); + mask_id + } + (None, None) => { + return Err(EngineError::InvalidState { + name: NAME.to_owned(), + detail: "the level's mask could not be prepared".to_owned(), + }); + } + }; + + // The new level wraps the stack's top level. + let group = document.registry.insert(ObjectData::Group(GroupObject { + group_type, + name: None, + children: vec![mask_id, top], + opacity: 1000, + blend: 0, + visible: true, + locked: false, + mask_hidden: false, + mask_linked: None, + stack_mode: Some(mode), + effects: Vec::new(), + })); + deltas.push(Delta::Object { + id: group, + inverse: ObjectInverse::Created, + }); + deltas.push(attach_at(document, NAME, owner, group)?); + Ok(Delta::Group(deltas)) + } +} + +/// True when `ancestor` holds `id` at any depth. +fn contains(document: &Document, ancestor: ObjectId, id: ObjectId) -> bool { + let Some(ObjectData::Group(group)) = document.registry.get(ancestor) else { + return false; + }; + group + .children + .iter() + .any(|&child| child == id || contains(document, child, id)) +} + +// --- mask.removeLevel --------------------------------------------------- + +/// `mask.removeLevel` — removes one level from an element mask stack. +/// +/// The level's content child takes the level's place: removing the +/// bottom level leaves the object unmasked, removing a level above it +/// leaves the level below. +struct RemoveMaskLevel; + +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +struct RemoveMaskLevelPayload { + /// The level's mask group, or the mask it holds. + id: ObjectId, +} + +impl TypedCommand for RemoveMaskLevel { + type Payload = RemoveMaskLevelPayload; + + fn name(&self) -> &'static str { + "mask.removeLevel" + } + + fn apply( + &self, + document: &mut Document, + payload: &Self::Payload, + ) -> Result { + const NAME: &str = "mask.removeLevel"; + let group_id = + level_group(document, payload.id).ok_or_else(|| EngineError::MissingTarget { + name: NAME.to_owned(), + detail: format!( + "object {} (generation {}) is not a level of an element mask stack", + payload.id.index(), + payload.id.generation() + ), + })?; + let owner = find_owner(document, group_id).ok_or_else(|| EngineError::MissingTarget { + name: NAME.to_owned(), + detail: format!( + "mask group {} (generation {}) is referenced by no cell or group", + group_id.index(), + group_id.generation() + ), + })?; + let group = match document.registry.get(group_id) { + Some(ObjectData::Group(group)) => group.clone(), + _ => { + return Err(EngineError::MissingTarget { + name: NAME.to_owned(), + detail: "the mask group is not live".to_owned(), + }); + } + }; + let Some(content) = group.children.get(1).copied() else { + return Err(EngineError::InvalidState { + name: NAME.to_owned(), + detail: "the mask group carries no content child".to_owned(), + }); + }; + + // The level leaves its container; its content takes its place. + let mut deltas = vec![detach(document, NAME, group_id)?]; + deltas.push(attach_at(document, NAME, owner, content)?); + // The level's own group and mask leave the document. + if let Some(object) = document.registry.remove(group_id) { + deltas.push(Delta::Object { + id: group_id, + inverse: ObjectInverse::Removed { + object: Box::new(object), + }, + }); + } + if let Some(mask_id) = group.children.first().copied() + && let Some(mask) = document.registry.remove(mask_id) + { + deltas.push(Delta::Object { + id: mask_id, + inverse: ObjectInverse::Removed { + object: Box::new(mask), + }, + }); + } + Ok(Delta::Group(deltas)) + } +} + +// --- mask.moveLevel ----------------------------------------------------- + +/// `mask.moveLevel` — moves one level of an element mask stack up or +/// down. +/// +/// A stack is nested mask groups, so a move is a swap between the +/// level and its neighbour: the levels keep their masks and their +/// combine modes, and only their order changes. +struct MoveMaskLevel; + +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +struct MoveMaskLevelPayload { + /// The level's mask group, or the mask it holds. + id: ObjectId, + /// `1` moves the level up (toward the top of the stack), `-1` + /// moves it down. + delta: i64, +} + +impl TypedCommand for MoveMaskLevel { + type Payload = MoveMaskLevelPayload; + + fn name(&self) -> &'static str { + "mask.moveLevel" + } + + fn apply( + &self, + document: &mut Document, + payload: &Self::Payload, + ) -> Result { + const NAME: &str = "mask.moveLevel"; + let group_id = + level_group(document, payload.id).ok_or_else(|| EngineError::MissingTarget { + name: NAME.to_owned(), + detail: format!( + "object {} (generation {}) is not a level of an element mask stack", + payload.id.index(), + payload.id.generation() + ), + })?; + if payload.delta == 0 { + return Err(EngineError::InvalidState { + name: NAME.to_owned(), + detail: "a level moves by one step".to_owned(), + }); + } + let group = match document.registry.get(group_id) { + Some(ObjectData::Group(group)) => group.clone(), + _ => { + return Err(EngineError::MissingTarget { + name: NAME.to_owned(), + detail: "the mask group is not live".to_owned(), + }); + } + }; + let content = group.children.get(1).copied(); + + if payload.delta > 0 { + // Up: the level above is the group that holds this one as + // its content child. The two swap places. + let Some(Owner::Group { + id: above, + position: 1, + }) = find_owner(document, group_id) + else { + return Err(EngineError::InvalidState { + name: NAME.to_owned(), + detail: "the level is already at the top of the stack".to_owned(), + }); + }; + let Some(ObjectData::Group(above_group)) = document.registry.get(above) else { + return Err(EngineError::MissingTarget { + name: NAME.to_owned(), + detail: "the level above is not live".to_owned(), + }); + }; + if !is_enabled_mask_group(above_group.group_type) { + return Err(EngineError::InvalidState { + name: NAME.to_owned(), + detail: "the level is already at the top of the stack".to_owned(), + }); + } + let above_group = above_group.clone(); + let owner = find_owner(document, above).ok_or_else(|| EngineError::MissingTarget { + name: NAME.to_owned(), + detail: "the level above is referenced by no cell or group".to_owned(), + })?; + // The level above takes over what this level held, and + // this level takes the level above's place. + let below = content.ok_or_else(|| EngineError::InvalidState { + name: NAME.to_owned(), + detail: "the level carries no content child".to_owned(), + })?; + let mut deltas = vec![detach(document, NAME, above)?]; + set_group_child(document, above, below)?; + set_group_child(document, group_id, above)?; + deltas.push(attach_at(document, NAME, owner, group_id)?); + deltas.push(Delta::Object { + id: above, + inverse: ObjectInverse::Mutated { + object: Box::new(ObjectData::Group(above_group)), + }, + }); + deltas.push(Delta::Object { + id: group_id, + inverse: ObjectInverse::Mutated { + object: Box::new(ObjectData::Group(group)), + }, + }); + return Ok(Delta::Group(deltas)); + } + + // Down: the level below is the group this level holds as its + // content child. The two swap places. + let Some(below_id) = content else { + return Err(EngineError::InvalidState { + name: NAME.to_owned(), + detail: "the level carries no content child".to_owned(), + }); + }; + let Some(ObjectData::Group(below_group)) = document.registry.get(below_id) else { + return Err(EngineError::InvalidState { + name: NAME.to_owned(), + detail: "the level below is not a mask group".to_owned(), + }); + }; + if !is_enabled_mask_group(below_group.group_type) { + return Err(EngineError::InvalidState { + name: NAME.to_owned(), + detail: "the level is already at the bottom of the stack".to_owned(), + }); + } + let below_group = below_group.clone(); + let deeper = below_group.children.get(1).copied(); + let owner = find_owner(document, group_id).ok_or_else(|| EngineError::MissingTarget { + name: NAME.to_owned(), + detail: "the level is referenced by no cell or group".to_owned(), + })?; + // This level takes the level below's place in the container, + // holds what the level below held, and the level below holds + // this level. + let mut deltas = vec![detach(document, NAME, group_id)?]; + if let Some(deeper) = deeper { + set_group_child(document, group_id, deeper)?; + } + set_group_child(document, below_id, group_id)?; + deltas.push(attach_at(document, NAME, owner, below_id)?); + deltas.push(Delta::Object { + id: below_id, + inverse: ObjectInverse::Mutated { + object: Box::new(ObjectData::Group(below_group)), + }, + }); + deltas.push(Delta::Object { + id: group_id, + inverse: ObjectInverse::Mutated { + object: Box::new(ObjectData::Group(group)), + }, + }); + Ok(Delta::Group(deltas)) + } +} + +/// Repoints a mask group's content child. +fn set_group_child( + document: &mut Document, + id: ObjectId, + child: ObjectId, +) -> Result<(), EngineError> { + match document.registry.get_mut(id) { + Some(ObjectData::Group(group)) => { + let Some(slot) = group.children.get_mut(1) else { + return Err(EngineError::InvalidState { + name: "mask.moveLevel".to_owned(), + detail: format!( + "mask group {} (generation {}) carries no content child", + id.index(), + id.generation() + ), + }); + }; + *slot = child; + Ok(()) + } + _ => Err(EngineError::MissingTarget { + name: "mask.moveLevel".to_owned(), + detail: format!( + "mask group {} (generation {}) is not live", + id.index(), + id.generation() + ), + }), + } +} + // --- mask.pathToMarquee ------------------------------------------------- /// `mask.pathToMarquee` — rasterizes a path into the pixel selection @@ -1999,6 +2700,7 @@ mod tests { ); let mask_id = document.registry.insert(ObjectData::Image(mask_image)); let group_id = document.registry.insert(ObjectData::Group(GroupObject { + stack_mode: None, group_type: GRAYSCALE_MASK, name: None, children: vec![mask_id, image_id], @@ -2225,6 +2927,59 @@ mod tests { assert!(group.mask_hidden); } + #[test] + fn set_stack_mode_records_the_level_mode_and_refuses_a_bad_spelling() { + let (mut document, image_id) = image_document(); + AddMask + .apply_json( + &mut document, + serde_json::json!({ "id": image_id, "mode": "reveal all" }), + ) + .unwrap(); + let group_id = cell_element(&document, 0).unwrap(); + + // The masked object addresses its level. + SetMaskStackMode + .apply_json( + &mut document, + serde_json::json!({ "id": image_id, "mode": "subtract" }), + ) + .unwrap(); + let ObjectData::Group(group) = document.registry.get(group_id).unwrap() else { + panic!("group vanished"); + }; + assert_eq!(group.stack_mode, Some(MaskCombineMode::Subtract)); + + // So does the group itself. + SetMaskStackMode + .apply_json( + &mut document, + serde_json::json!({ "id": group_id, "mode": "intersect" }), + ) + .unwrap(); + let ObjectData::Group(group) = document.registry.get(group_id).unwrap() else { + panic!("group vanished"); + }; + assert_eq!(group.stack_mode, Some(MaskCombineMode::Intersect)); + + // A spelling the model does not know is a typed refusal, and + // the level keeps its mode. + let error = SetMaskStackMode + .apply_json( + &mut document, + serde_json::json!({ "id": group_id, "mode": "additive" }), + ) + .unwrap_err(); + assert!( + matches!(error, EngineError::BadPayload { .. }), + "an unknown mode is a bad payload: {error:?}" + ); + let ObjectData::Group(group) = document.registry.get(group_id).unwrap() else { + panic!("group vanished"); + }; + assert_eq!(group.stack_mode, Some(MaskCombineMode::Intersect)); + } + #[test] fn move_by_moves_mask_contents_move_content() { let (mut document, image_id) = image_document(); @@ -2293,6 +3048,7 @@ mod tests { // Shape-mask group over the image: path covering the left half. let path_id = document.registry.insert(square_path(0.0, 0.0, 4.0)); let group_id = document.registry.insert(ObjectData::Group(GroupObject { + stack_mode: None, group_type: SHAPE_MASK, name: None, children: vec![path_id, image_id], diff --git a/crates/engine/src/catalog/mod.rs b/crates/engine/src/catalog/mod.rs index 1be308dc..0dbb97d3 100644 --- a/crates/engine/src/catalog/mod.rs +++ b/crates/engine/src/catalog/mod.rs @@ -69,6 +69,20 @@ pub fn element_mask_state(document: &Document, id: ObjectId) -> Option { mask::element_mask_state(document, id) } +/// One level of an element mask stack. +pub use mask::MaskLevel; + +/// The mask stack behind `id`, top level first. +/// +/// `id` is the stack's top group, any level's group, the mask a level +/// holds, or the object the stack restricts. Empty when `id` carries +/// no element mask. A disabled level is skipped — it contributes no +/// coverage, so the level below it takes its place. +#[must_use] +pub fn element_mask_stack(document: &Document, id: ObjectId) -> Vec { + mask::element_mask_stack(document, id) +} + /// Every registered built-in, sorted (surfaces enumerate this). #[must_use] pub fn ids() -> Vec<&'static str> { diff --git a/crates/engine/src/catalog/object.rs b/crates/engine/src/catalog/object.rs index 6181eeed..b8b28f57 100644 --- a/crates/engine/src/catalog/object.rs +++ b/crates/engine/src/catalog/object.rs @@ -2103,6 +2103,7 @@ impl TypedCommand for GroupObjects { let group_id = document .registry .insert(ObjectData::Group(doc::GroupObject { + stack_mode: None, group_type: 0, name: payload.name.clone(), children, @@ -3010,6 +3011,7 @@ mod tests { let group_id = document .registry .insert(ObjectData::Group(doc::GroupObject { + stack_mode: None, group_type: 0, name: None, children: vec![ids[1]], @@ -3074,6 +3076,7 @@ mod tests { let group_id = document .registry .insert(ObjectData::Group(doc::GroupObject { + stack_mode: None, group_type: 0, name: None, children: vec![ids[0], ids[1]], @@ -3778,6 +3781,7 @@ mod tests { let group_id = document .registry .insert(ObjectData::Group(doc::GroupObject { + stack_mode: None, group_type: 0, name: None, children: vec![ids[0], ids[1]], diff --git a/crates/engine/src/catalog/selection.rs b/crates/engine/src/catalog/selection.rs index 424e4390..e2d08efb 100644 --- a/crates/engine/src/catalog/selection.rs +++ b/crates/engine/src/catalog/selection.rs @@ -1221,6 +1221,7 @@ mod selection_tests { let group = document .registry .insert(doc::ObjectData::Group(doc::GroupObject { + stack_mode: None, group_type: 0, name: None, children: vec![ids[0], ids[1]], @@ -1302,6 +1303,7 @@ mod selection_tests { let group2 = document .registry .insert(doc::ObjectData::Group(doc::GroupObject { + stack_mode: None, group_type: 0, name: None, children: vec![group1, outsider], @@ -1346,6 +1348,7 @@ mod selection_tests { let group = document .registry .insert(doc::ObjectData::Group(doc::GroupObject { + stack_mode: None, group_type: 0, name: None, children: vec![child], diff --git a/crates/engine/src/catalog/symbol.rs b/crates/engine/src/catalog/symbol.rs index f415f4f0..d05ce710 100644 --- a/crates/engine/src/catalog/symbol.rs +++ b/crates/engine/src/catalog/symbol.rs @@ -3585,6 +3585,7 @@ mod tests { fn group_named(name: Option<&str>, children: Vec) -> ObjectData { ObjectData::Group(GroupObject { + stack_mode: None, group_type: 0, name: name.map(str::to_owned), children, diff --git a/crates/engine/src/fuzzdrive.rs b/crates/engine/src/fuzzdrive.rs index 463447c5..56938e11 100644 --- a/crates/engine/src/fuzzdrive.rs +++ b/crates/engine/src/fuzzdrive.rs @@ -237,6 +237,7 @@ pub fn random_document(rng: &mut FuzzRng) -> Document { .map(|child| document.registry.insert(draw_leaf(rng, *child))) .collect(); document.registry.insert(ObjectData::Group(GroupObject { + stack_mode: None, group_type: 0, name: Some("group".to_owned()), children: child_ids, @@ -742,6 +743,10 @@ pub const CALLS: &[&str] = &[ "mask.remove", "mask.setMode", "mask.setShowAttrs", + "mask.setStackMode", + "mask.addLevel", + "mask.removeLevel", + "mask.moveLevel", "mask.pathToMarquee", "mask.marqueeToPath", "mask.saveJPEG", @@ -2691,6 +2696,7 @@ pub fn payload_template(name: &str, rng: &mut FuzzRng, document: &Document) -> s let mut library = Document::default(); let leaf = library.registry.insert(random_path(rng, 0)); let group = library.registry.insert(ObjectData::Group(GroupObject { + stack_mode: None, group_type: 0, name: None, children: vec![leaf], @@ -2852,6 +2858,22 @@ pub fn payload_template(name: &str, rng: &mut FuzzRng, document: &Document) -> s "id": any_id(rng), "show": rng.chance(2), }), + "mask.setStackMode" => serde_json::json!({ + "id": any_id(rng), + "mode": rng.pick(&["add", "subtract", "intersect", "invert"]).copied(), + }), + "mask.addLevel" => serde_json::json!({ + "id": any_id(rng), + "source": any_id(rng), + "mode": rng.pick(&["add", "subtract", "intersect", "invert"]).copied(), + }), + "mask.removeLevel" => serde_json::json!({ + "id": any_id(rng), + }), + "mask.moveLevel" => serde_json::json!({ + "id": any_id(rng), + "delta": if rng.chance(50) { 1 } else { -1 }, + }), "mask.pathToMarquee" => serde_json::json!({ "id": any_id(rng), "image": any_id(rng), diff --git a/crates/engine/tests/mask_stack.rs b/crates/engine/tests/mask_stack.rs new file mode 100644 index 00000000..f5050a6a --- /dev/null +++ b/crates/engine/tests/mask_stack.rs @@ -0,0 +1,322 @@ +//! The element mask stack (decision 0092): a stack is nested mask +//! groups, and the level commands grow, reorder, and shrink it. +//! +//! Every command here must undo byte-exactly — a stack restructures +//! the document (objects move between containers), so a stack command +//! that does not round-trip would corrupt a document on undo. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +mod support; + +use engine::CommandRegistry; +use engine::catalog::element_mask_stack; +use serde_json::json; + +use support::sample_document; + +fn builtins() -> CommandRegistry { + let mut registry = CommandRegistry::new(); + engine::catalog::install(&mut registry).expect("builtins install"); + registry +} + +/// Byte-level document equality (`serde_json`, deterministic order). +fn bytes(document: &doc::Document) -> Vec { + serde_json::to_vec(document).expect("document bytes") +} + +/// A document with one masked path: `ids[0]` carries an element mask, +/// `ids[2]` is a free path that can become a level's mask. +fn masked_document() -> (doc::Document, Vec) { + let registry = builtins(); + let (mut document, ids) = sample_document(); + registry + .execute( + &mut document, + "mask.add", + json!({ "id": ids[0], "mode": "reveal all" }), + ) + .expect("mask.add"); + (document, ids) +} + +#[test] +fn a_mask_carries_one_level() { + let (document, ids) = masked_document(); + let levels = element_mask_stack(&document, ids[0]); + assert_eq!(levels.len(), 1, "the mask is one level"); + assert!(levels[0].enabled, "the level is enabled"); + assert!(levels[0].linked, "the level's mask is linked by default"); + assert_ne!(levels[0].mask, levels[0].group, "the mask is not the group"); +} + +#[test] +fn a_level_unlinks_its_own_mask() { + let registry = builtins(); + let (mut document, ids) = masked_document(); + registry + .execute( + &mut document, + "mask.addLevel", + json!({ "id": ids[0], "source": ids[2] }), + ) + .expect("mask.addLevel"); + let levels = element_mask_stack(&document, ids[0]); + assert!(levels[0].linked, "the new level is linked"); + assert!(levels[1].linked, "the old level is linked"); + + // The link flag rides the level's own mask group, so unlinking + // one level leaves the other alone. + registry + .execute( + &mut document, + "mask.link", + json!({ "id": levels[0].group, "linked": false }), + ) + .expect("mask.link"); + let unlinked = element_mask_stack(&document, ids[0]); + assert!(!unlinked[0].linked, "the top level is unlinked"); + assert!(unlinked[1].linked, "the level below is still linked"); +} + +#[test] +fn a_level_is_added_over_the_stack() { + let registry = builtins(); + let (mut document, ids) = masked_document(); + let before = element_mask_stack(&document, ids[0]); + assert_eq!(before.len(), 1); + + registry + .execute( + &mut document, + "mask.addLevel", + json!({ "id": ids[0], "source": ids[2], "mode": "subtract" }), + ) + .expect("mask.addLevel"); + + let levels = element_mask_stack(&document, ids[0]); + assert_eq!(levels.len(), 2, "the stack grew"); + assert_eq!(levels[0].mask, ids[2], "the new level is on top"); + assert_eq!( + levels[0].mode, + doc::MaskCombineMode::Subtract, + "the new mode" + ); + assert_eq!(levels[1].mask, before[0].mask, "the old level is below"); +} + +#[test] +fn a_level_without_a_source_mints_a_reveal_all_mask() { + let registry = builtins(); + let (mut document, ids) = masked_document(); + registry + .execute(&mut document, "mask.addLevel", json!({ "id": ids[0] })) + .expect("mask.addLevel"); + let levels = element_mask_stack(&document, ids[0]); + assert_eq!(levels.len(), 2, "the stack grew"); + assert_eq!( + levels[0].mode, + doc::MaskCombineMode::Add, + "the default mode" + ); + assert!(levels[0].enabled, "the new level is enabled"); +} + +#[test] +fn a_level_moves_up_and_down() { + let registry = builtins(); + let (mut document, ids) = masked_document(); + registry + .execute( + &mut document, + "mask.addLevel", + json!({ "id": ids[0], "source": ids[2] }), + ) + .expect("mask.addLevel"); + let levels = element_mask_stack(&document, ids[0]); + let top = levels[0]; + let bottom = levels[1]; + + // The bottom level moves up: the two swap places. + registry + .execute( + &mut document, + "mask.moveLevel", + json!({ "id": bottom.group, "delta": 1 }), + ) + .expect("mask.moveLevel"); + let moved = element_mask_stack(&document, ids[0]); + assert_eq!(moved[0].group, bottom.group, "the bottom level is on top"); + assert_eq!(moved[1].group, top.group, "the top level is below"); + assert_eq!(moved[0].mask, bottom.mask, "the level keeps its mask"); + assert_eq!(moved[0].mode, bottom.mode, "the level keeps its mode"); + + // And back down. + registry + .execute( + &mut document, + "mask.moveLevel", + json!({ "id": bottom.group, "delta": -1 }), + ) + .expect("mask.moveLevel"); + let back = element_mask_stack(&document, ids[0]); + assert_eq!(back[0].group, top.group, "the order is back"); + assert_eq!(back[1].group, bottom.group, "the order is back"); +} + +#[test] +fn a_level_is_removed() { + let registry = builtins(); + let (mut document, ids) = masked_document(); + registry + .execute( + &mut document, + "mask.addLevel", + json!({ "id": ids[0], "source": ids[2] }), + ) + .expect("mask.addLevel"); + let levels = element_mask_stack(&document, ids[0]); + + registry + .execute( + &mut document, + "mask.removeLevel", + json!({ "id": levels[0].group }), + ) + .expect("mask.removeLevel"); + let left = element_mask_stack(&document, ids[0]); + assert_eq!(left.len(), 1, "the top level is gone"); + assert_eq!(left[0].group, levels[1].group, "the level below remains"); +} + +#[test] +fn every_level_command_undoes_byte_exactly() { + let registry = builtins(); + let (mut document, ids) = masked_document(); + + let before = bytes(&document); + let added = registry + .execute( + &mut document, + "mask.addLevel", + json!({ "id": ids[0], "source": ids[2] }), + ) + .expect("mask.addLevel"); + let after_add = bytes(&document); + assert_ne!(after_add, before, "the stack grew"); + + let levels = element_mask_stack(&document, ids[0]); + let moved = registry + .execute( + &mut document, + "mask.moveLevel", + json!({ "id": levels[1].group, "delta": 1 }), + ) + .expect("mask.moveLevel"); + let after_move = bytes(&document); + + let removed = registry + .execute( + &mut document, + "mask.removeLevel", + json!({ "id": levels[0].group }), + ) + .expect("mask.removeLevel"); + + // Undo in reverse: the removal, the move, the addition. + removed.apply_inverse(&mut document).expect("undo remove"); + assert_eq!(bytes(&document), after_move, "undo of the removal"); + moved.apply_inverse(&mut document).expect("undo move"); + assert_eq!(bytes(&document), after_add, "undo of the move"); + added.apply_inverse(&mut document).expect("undo add"); + assert_eq!(bytes(&document), before, "undo of the addition"); +} + +#[test] +fn a_disabled_level_keeps_its_place_and_its_mode_stays_editable() { + let registry = builtins(); + let (mut document, ids) = masked_document(); + registry + .execute( + &mut document, + "mask.addLevel", + json!({ "id": ids[0], "source": ids[2] }), + ) + .expect("mask.addLevel"); + let levels = element_mask_stack(&document, ids[0]); + let top = levels[0]; + + // The top level is disabled: it stays in the stack, in place, but + // contributes no coverage. + registry + .execute( + &mut document, + "mask.enable", + json!({ "id": top.group, "enable": false }), + ) + .expect("mask.enable"); + let disabled = element_mask_stack(&document, ids[0]); + assert_eq!(disabled.len(), 2, "the stack keeps both levels"); + assert_eq!(disabled[0].group, top.group, "the level keeps its place"); + assert!(!disabled[0].enabled, "the level is disabled"); + + // A disabled level's combine mode is still editable — the level + // keeps its mode across the enable/disable round trip. + registry + .execute( + &mut document, + "mask.setStackMode", + json!({ "id": top.group, "mode": "intersect" }), + ) + .expect("mask.setStackMode"); + let edited = element_mask_stack(&document, ids[0]); + assert_eq!( + edited[0].mode, + doc::MaskCombineMode::Intersect, + "the disabled level's mode" + ); + assert!(!edited[0].enabled, "the level is still disabled"); + + // And re-enabling keeps the mode. + registry + .execute( + &mut document, + "mask.enable", + json!({ "id": top.group, "enable": true }), + ) + .expect("mask.enable"); + let enabled = element_mask_stack(&document, ids[0]); + assert!(enabled[0].enabled, "the level is enabled again"); + assert_eq!( + enabled[0].mode, + doc::MaskCombineMode::Intersect, + "the mode survives the round trip" + ); +} + +#[test] +fn a_refused_level_command_leaves_the_document_alone() { + let registry = builtins(); + let (mut document, ids) = masked_document(); + let before = bytes(&document); + + // An object that carries no mask has no stack to extend. + let refused = registry.execute( + &mut document, + "mask.addLevel", + json!({ "id": ids[4], "source": ids[2] }), + ); + assert!(refused.is_err(), "an unmasked object has no stack"); + assert_eq!(bytes(&document), before, "the refusal changed nothing"); + + // A source that is already part of the stack cannot join it twice. + let levels = element_mask_stack(&document, ids[0]); + let refused = registry.execute( + &mut document, + "mask.addLevel", + json!({ "id": ids[0], "source": levels[0].mask }), + ); + assert!(refused.is_err(), "the source is already in the stack"); + assert_eq!(bytes(&document), before, "the refusal changed nothing"); +} diff --git a/crates/io/src/psd.rs b/crates/io/src/psd.rs index 3fb9253b..051008a8 100644 --- a/crates/io/src/psd.rs +++ b/crates/io/src/psd.rs @@ -685,6 +685,7 @@ fn build_model( .document .registry .insert(ObjectData::Group(GroupObject { + stack_mode: None, group_type: 0, name: non_empty(&shell.name), children, diff --git a/crates/io/src/svgimport.rs b/crates/io/src/svgimport.rs index cb9d99c1..18454688 100644 --- a/crates/io/src/svgimport.rs +++ b/crates/io/src/svgimport.rs @@ -241,6 +241,7 @@ impl Importer<'_> { .document .registry .insert(ObjectData::Group(GroupObject { + stack_mode: None, group_type: 0, name: non_empty(group.id()), children, diff --git a/crates/io/tests/psdwrite.rs b/crates/io/tests/psdwrite.rs index 40f9a3c2..6323e84d 100644 --- a/crates/io/tests/psdwrite.rs +++ b/crates/io/tests/psdwrite.rs @@ -219,6 +219,7 @@ fn scripted_document_round_trips_semantically() { true, ); let group = document.registry.insert(ObjectData::Group(GroupObject { + stack_mode: None, group_type: 0, name: Some("pair".to_owned()), children: vec![first, second], @@ -403,6 +404,7 @@ fn nested_groups_nest_through_the_pair_walk() { true, ); let inner = document.registry.insert(ObjectData::Group(GroupObject { + stack_mode: None, group_type: 0, name: Some("inner".to_owned()), children: vec![leaf_a, leaf_b], @@ -415,6 +417,7 @@ fn nested_groups_nest_through_the_pair_walk() { effects: Vec::new(), })); let outer = document.registry.insert(ObjectData::Group(GroupObject { + stack_mode: None, group_type: 0, name: Some("outer".to_owned()), children: vec![inner], @@ -486,6 +489,7 @@ fn structural_gates_refuse_with_reasons() { let mut broken = document(vec![("broken", true, Vec::new())]); let dangling = doc::ObjectId::from_parts(9_999, 0); let group = broken.registry.insert(ObjectData::Group(GroupObject { + stack_mode: None, group_type: 0, name: Some("broken".to_owned()), children: vec![dangling], @@ -517,6 +521,7 @@ fn structural_gates_refuse_with_reasons() { let mut child = leaf; for level in 0..70 { child = deep.registry.insert(ObjectData::Group(GroupObject { + stack_mode: None, group_type: 0, name: Some(format!("shell-{level}")), children: vec![child], diff --git a/crates/io/tests/svg.rs b/crates/io/tests/svg.rs index 607c7e31..045588d7 100644 --- a/crates/io/tests/svg.rs +++ b/crates/io/tests/svg.rs @@ -237,6 +237,7 @@ fn groups_nest_their_children() { let mut inner = square(); inner.opacity = 250; let mut group = GroupObject { + stack_mode: None, group_type: 0, name: None, children: Vec::new(), @@ -917,6 +918,7 @@ fn group_wrapper_carries_opacity_and_filter_together() { let mut document = document(Vec::new()); let square = document.registry.insert(ObjectData::Path(square())); let group = document.registry.insert(ObjectData::Group(GroupObject { + stack_mode: None, group_type: 0, name: None, children: vec![square], diff --git a/crates/io/tests/svgimport.rs b/crates/io/tests/svgimport.rs index f0bbb610..a4fabb27 100644 --- a/crates/io/tests/svgimport.rs +++ b/crates/io/tests/svgimport.rs @@ -442,6 +442,7 @@ fn square_round_trips_bit_exact() { #[test] fn opacity_and_blend_wrapper_round_trips() { let group = GroupObject { + stack_mode: None, group_type: 0, name: None, children: vec![], diff --git a/docs/GAPS.md b/docs/GAPS.md index 24b9cb83..7e20f8e1 100644 --- a/docs/GAPS.md +++ b/docs/GAPS.md @@ -51,9 +51,9 @@ shell reaches 72; after Phase 11's paint family it reaches 77; > not just audited. The xtask step `cargo run -p xtask -- > reachability` derives the bound set from `crates/app/src` and holds > it to a floor in `.reachability-floor.tsv` (one-way ratchet). The -> measurement it took on the release train at 2026-11-11: **253 -> registry commands, 168 bound = 66.40%**, 85 unreachable — the floor is -> pinned at 66.40%. The +> measurement it took on the merged train at 2026-11-11: **257 +> registry commands, 172 bound = 66.93%**, 85 unreachable — the floor is +> pinned at 66.93%. The > `comm` line above counts *any* occurrence of a command name in the > shell crate; the gate counts the same bindings outside `#[cfg(test)]` > code and reports them as a share, so the two numbers are close but @@ -137,7 +137,7 @@ authoritative source that supersedes this table. | **Registry** | 253 commands | Every command executable from the GUI via the Ctrl+P palette (19.4); menu reachability is the remaining gap | | **Menu reachability** | 2 inert of 296 items | *live* — derived + gated (`menus::tests::inert_count_never_rises`) | | **Parity** | 268 C / 3 P / 0 M / 85 N | 3 P rows are evidence-gated (Phase 12 oracle) | -| **Decision docs** | 90 (0001–0090) | Next: 0091 | +| **Decision docs** | 92 (0001–0092) | Next: 0093 | | **Web bundle** | 21,608,249 B observed; 25,000,000 B warning / 50,000,000 B hard cap | Policy decision 0090; web build uses Rust opt-level=z and Binaryen wasm-opt=z. Cap is a guardrail, not a target; A/B size evidence remains required | diff --git a/docs/NEXT.md b/docs/NEXT.md index 97f24142..d93eb6dd 100644 --- a/docs/NEXT.md +++ b/docs/NEXT.md @@ -2,12 +2,12 @@ > **Current execution snapshot (2026-11-11; main `b61f198`):** Phase 21's > user-facing deliverables have landed. Phase 20.1 and 20.5–20.7 are on -> main; Optimize/timeline are in open PR #118 and the mask stack is in -> open PR #113. Phase 22 remains partial: the user-visible parity matrix -> is published, but bitmap inspector capabilities and remaining CS6 chrome -> are still open/evidence-gated. Use [TASKS.md](TASKS.md) for live status -> and [PROGRESS.md](PROGRESS.md) for measured receipts. An open PR is not -> a landed feature. +> main; Optimize/timeline landed 2026-11-11 in #118 and the mask stack +> landed 2026-11-11 in #113 as `PARTIAL`. Phase 22 remains partial: the +> user-visible parity matrix is published, but bitmap inspector +> capabilities and remaining CS6 chrome are still open/evidence-gated. +> Use [TASKS.md](TASKS.md) for live status and [PROGRESS.md](PROGRESS.md) +> for measured receipts. An open PR is not a landed feature. > Current task state is tracked in [`TASKS.md`](TASKS.md), with a dated > snapshot in [`PROGRESS.md`](PROGRESS.md). This file is the detailed @@ -439,8 +439,18 @@ evidence work, tracked outside that campaign. [0094](decisions/0094-animation-timeline-shell.md)). Still open: playback (CS6's `Play Animation`), state thumbnails, per-side onion opacity, screenshot acceptance. -- **20.4 Mask stack UI** (M): add/remove/mode/link/move. **In open - PR #113**; this is a FireCraft extension, not a CS6 parity claim. +- **20.4 Mask stack UI** (M): add/remove/mode/link/move. **PARTIAL + 2026-11-11**: the stack, its operators, and its fold landed. The + model holds a level's combine mode and persists it + (`mask.setStackMode`, decisions [0092](decisions/0092-mask-stack-combine-mode.md) + and [0093](decisions/0093-mask-stack-levels-and-folding.md)); the + compositor folds the levels bottom-up into one coverage and applies + it to the content's pixels; the panel lists one row per level with + its enable flag, operator, link flag, reorder arrows and remove + button, and can add a level. The structural commands undo + byte-exactly. Still open: the screenshot acceptance; ticket + [T9](tasks/T9-mask-stack.md). This is a FireCraft extension, not a + CS6 parity claim. - **20.5 Multi-document tabs** (M): **LANDED 2026-11-07** — Open/New park the active document as a `DocTab`; Close promotes the newest parked tab (refuses dirty documents); the tab strip shows the diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index a2db7a87..de6484bc 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -103,8 +103,8 @@ - **UI-work gates landed (2026-11-10, issue #94):** the reachability gate (`cargo run -p xtask -- reachability`, wired into `ci`) derives the shell's bound command set from the shell crate's own source and - holds it to a one-way floor in `.reachability-floor.tsv` — **253 - registry commands, 168 bound = 66.40%**, 85 unreachable. The panel + holds it to a one-way floor in `.reachability-floor.tsv` — **257 + registry commands, 172 bound = 66.93%**, 85 unreachable. The panel snapshot (`crates/app/panels.snapshot`, 19 panels) pins the panel set's structure; `egui_kittest` is obtainable (0.36.2 matches the locked egui) but its pixel path needs a wgpu adapter the CI image diff --git a/docs/TASKS.md b/docs/TASKS.md index 0760f2b0..f8b3e0c1 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -44,7 +44,7 @@ |---|---|---|---| | P0 | Refresh user-visible CS6 parity matrix | **Done — matrix published** | [T26](tasks/T26-parity-matrix.md) → [`CS6-PARITY.md`](CS6-PARITY.md): every CS6 menu, tool, panel, inspector field set, workflow, import/export surface, canvas interaction, keymap area, and selection state classified with an evidence citation and an owning ticket. The browser-sampling check the ticket named was performed on 2026-11-04 against a fresh optimized bundle and is recorded in T22. | | P0 | Phase 22: CS6 shell fidelity and screenshot audit | In progress | [T22](tasks/T22-screenshot-ui.md) — screenshot acceptance is complete; 22.1 panel inventory and 22.2 tool strip landed on `main` with the shell-lineage train (#107); 22.5 chrome remains partial, and 22.4 still has evidence-gated bitmap transform/fill-stroke work. [Phase 22](NEXT.md#phase-22--cs6-shell-fidelity-p0-the-looks-and-acts-like-fireworks-phase) | -| P1 | Phase 20: daily-driver editing features | **In progress — 20.1–20.3, 20.5–20.7 on main; 20.4 in PR** | In-canvas text editing landed 2026-11-06 ([T20](tasks/T20-text-editing.md)); multi-document tabs (20.5), history rewind (20.6), and slice/hotspot behavior editing with HTML preview (20.7) landed 2026-11-11 in #107; the Optimize panel (20.2, [T7](tasks/T7-optimize-panel.md)) and the animation timeline (20.3, [T8](tasks/T8-animation-timeline.md)) landed 2026-11-11 in #118. Mask stack (20.4) is in open PR #113. Do not count open PR work as shipped until merged and validated. [details](NEXT.md#phase-20--daily-driver-features-p1) | +| P1 | Phase 20: daily-driver editing features | **In progress — 20.1–20.3, 20.5–20.7 landed; 20.4 partial** | In-canvas text editing landed 2026-11-06 ([T20](tasks/T20-text-editing.md)); multi-document tabs (20.5), history rewind (20.6), and slice/hotspot behavior editing with HTML preview (20.7) landed 2026-11-11 in #107; the Optimize panel (20.2, [T7](tasks/T7-optimize-panel.md)) and the animation timeline (20.3, [T8](tasks/T8-animation-timeline.md)) landed 2026-11-11 in #118; the mask stack (20.4, [T9](tasks/T9-mask-stack.md)) landed 2026-11-11 in #113 as `PARTIAL` — the stack, its fold, and the level panel with per-level link/unlink; the screenshot acceptance and the remaining add/remove/move affordances are still open. [details](NEXT.md#phase-20--daily-driver-features-p1) | | P1 | Phase 21: modern differentiators | **Done — all items landed 2026-11-11** | Autosave/crash recovery, HiDPI, command palette v2 (fuzzy scoring, recents, Save Steps As Command), preferences, accesskit accessibility and in-app scripting (`.jsf`/`.fwsteps`) all landed with the shell-lineage train (#107, PR #15); [details](NEXT.md#phase-21--modern-differentiators) | | P1 | Reconcile live web deployment | **Reconciled — republish pending** | #104 (`docs/web-deployment-reconciliation`) reconciled the live site against v0.2.5: the JS, wasm and `appinfo` match; `host.html`/`index.html` fail the site's own manifest because Cloudflare injects an analytics beacon after it is written. Decision recorded there: the release bytes are the source of truth. Next: republish from the release artifact. See [T18](tasks/T18-release.md). | | P2 | Next-release asset breadth and platform verification | Open | [T23](tasks/T23-release-assets.md); recipes exist, but non-Linux artifacts need real platform build/launch receipts. | @@ -61,9 +61,10 @@ | 27 | Cross-platform release engineering: Windows, macOS, Linux package matrix, Android feasibility, source, SBOM/provenance, signing, CI aggregation and certification | [tasks/T27-cross-platform-release.md](tasks/T27-cross-platform-release.md) | `IN PROGRESS` — implementation is ahead of verification; **no target is `CERTIFIED`** and nothing is signed | release-workflow lane | land an end-to-end tag run of the repaired workflow and attach the run id as the RP-07 receipt; consolidate the duplicated `dist --source`/`--sbom` verbs (B-3) | | 26 | Refresh user-visible CS6 parity matrix | [tasks/T26-parity-matrix.md](tasks/T26-parity-matrix.md) | `IMPLEMENTED` — matrix published | parity lane | every CS6 menu, tool, panel, inspector field set, workflow, import/export surface, canvas interaction, keymap area, and selection state is classified with an evidence citation and an owning ticket in [`CS6-PARITY.md`](CS6-PARITY.md); the browser-sampling check the ticket named was performed on 2026-11-04 against a fresh optimized bundle and is recorded in T22 | | 22 | Phase 22: CS6 shell fidelity and screenshot audit | [tasks/T22-screenshot-ui.md](tasks/T22-screenshot-ui.md) | `IN PROGRESS` | UI lane | screenshot acceptance is complete; 22.1 panel inventory and 22.2 tool strip landed on `main` with the shell-lineage train (#107); 22.5 chrome remains partial, and 22.4 still has evidence-gated bitmap transform/fill-stroke work. [Phase 22](NEXT.md#phase-22--cs6-shell-fidelity-p0-the-looks-and-acts-like-fireworks-phase) | -| 20 | 20.1 In-canvas text editing (egui TextEdit overlay; kittest focus tests) | [tasks/T20-text-editing.md](tasks/T20-text-editing.md) | `IMPLEMENTED` — landed 2026-11-06 (`shell::begin_text_edit` / `text_edit_overlay`) | editing lane | the remaining Phase 20 items (Optimize, animation timeline, mask stack, tabs, history rewind, slice behavior) stay queued below | +| 20 | 20.1 In-canvas text editing (egui TextEdit overlay; kittest focus tests) | [tasks/T20-text-editing.md](tasks/T20-text-editing.md) | `IMPLEMENTED` — landed 2026-11-06 (`shell::begin_text_edit` / `text_edit_overlay`) | editing lane | Phase 20's other items are tracked in the rows above and in the Phase 20 board below | | 8 | 20.3 Animation timeline (frames strip, per-state timing, onion skinning, `symbol.tween`) | [tasks/T8-animation-timeline.md](tasks/T8-animation-timeline.md) | `IMPLEMENTED` — the shell half landed 2026-11-11 (decision [0094](decisions/0094-animation-timeline-shell.md)): the States panel is the strip, the delay field writes `frame.setDuration`, the onion toggle writes `page.setOnionSkinning` and the canvas calls `compose::render_onion` | editing lane | playback (CS6's `Play Animation`), state thumbnails, and the screenshot acceptance are still open — see the ticket | | 7 | 20.2 Optimize panel (palette/bitrate over `ExportOptions` + live preview) | [tasks/T7-optimize-panel.md](tasks/T7-optimize-panel.md) | `IMPLEMENTED` — landed 2026-11-11 (decision [0095](decisions/0095-optimize-panel-honors-only-what-export-honors.md)): the controls are the fields the export honors, the preview encodes the page through the export's own `io::expv` resolution, and PNG's Adam7 interlace is disclosed and clearable | editing lane | Adam7 interlacing, CS6's selective-JPEG quality table, and the screenshot acceptance are still open — see the ticket | +| — | 20.4 Mask stack UI (add/remove/mode/link/move) | [tasks/T9-mask-stack.md](tasks/T9-mask-stack.md) | `PARTIAL` — the stack, its fold, and the level panel landed 2026-11-11 (decisions [0092](decisions/0092-mask-stack-combine-mode.md) / [0093](decisions/0093-mask-stack-levels-and-folding.md)): the model, the FWMap persistence, the compositor fold, and the Properties-panel level list with per-level link/unlink | editing lane | the screenshot acceptance and the remaining add/remove/move affordances are still open — see the ticket | ## Phase 20 — remaining work (open PRs) @@ -71,7 +72,7 @@ | # | Task | Ticket | |---|---|---| -| — | 20.4 Mask stack UI (add/remove/mode/link/move) — a FireCraft extension, not a CS6 parity claim | [PR #113](https://github.com/aliasfoxkde/FireCraft/pull/113); open, not on `main` | +| — | 20.4 Mask stack UI (add/remove/mode/link/move) — `PARTIAL`: the stack, its fold, and the level panel landed 2026-11-11 in #113 (decisions 0092/0093); the screenshot acceptance and the remaining add/remove/move affordances remain — a FireCraft extension, not a CS6 parity claim | [tasks/T9-mask-stack.md](tasks/T9-mask-stack.md) | 20.1 and 20.5–20.7 are already on `main`; do not reopen them. diff --git a/docs/USAGE.md b/docs/USAGE.md index abbae373..b261421d 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -55,7 +55,7 @@ through the same registry every other surface drives: | Tool | Arguments | What it does | |---|---|---| -| `firecraft.apply` | `command`, `args` | One registry command through `Session::apply` (the 245-command door). | +| `firecraft.apply` | `command`, `args` | One registry command through `Session::apply` (the 257-command door). | | `firecraft.runScript` | `path` | Run a `.jsf` script; queued mutations drain on completion (scripting builds). | | `firecraft.info` | — | Document summary (frames, canvas, pages, object counts) as JSON. | | `firecraft.export` | `path`, `quality?` | Render page 0 and write it; the extension picks the codec (same set as `export`). | diff --git a/docs/decisions/0092-mask-stack-combine-mode.md b/docs/decisions/0092-mask-stack-combine-mode.md new file mode 100644 index 00000000..a26b6803 --- /dev/null +++ b/docs/decisions/0092-mask-stack-combine-mode.md @@ -0,0 +1,110 @@ +# 0092 — The mask stack's combine mode is a namespaced data entry the model owns + +Date: 2026-11-11 · Status: accepted + +## Context + +FireCraft's element mask is one mask per object, and that is what CS6 +ships. The reference install's resource DLL +(`source/Adobe Fireworks CS6/English/Resources/Fireworks Resources.dll`) +carries the whole Modify ▸ Mask submenu — `&Mask`, `&Reveal All`, +`&Hide All`, `&Paste as Mask`, `&Group as Mask`, `&Disable Mask`, +`&Delete Mask` — and **no** string for a mask *stack*, a mask +*level*, or a mask *combine* mode. CS6 has one element mask per +object and no stack of levels to combine. + +A stack is nonetheless the shape modern raster editors use: Photoshop's +layer masks, and the mask stacks in Affinity, each hold several levels +that combine with a per-level operator — union, subtract, intersect, +invert — rather than one mask replacing another. + +FireCraft wanted that shape (task 143, Phase 20.4). The question this +decision answers is *where the operator lives*, because FireCraft's +document format is Fireworks' PNG node tree and a CS6-authored file +has to survive the round trip untouched. + +The format dictionary +(`docs/research/fireworks-png-nodes.md`) settles the container: a +group's children may include a `DTAv` data node, and a data node's +children may include `DCEv` entries whose first child is a `DCKs` +string naming the key and whose second child is the value. CS6 +authors use that channel for `tooltips`, `radius1`, `radius0`, +`alrtmsg`, and `round0` (dictionary line 4051) — so the channel is +real, read by CS6, and crowded with names FireCraft does not own. + +## Decision + +- **The mode is a FireCraft extension, stored in the group.** CS6 has + no stack, so the operator is FireCraft's own: it rides in the mask + group's `DTAv` data node under a **namespaced** `DCEv` key, + `firecraft.mask.mode` (`doc::MASK_STACK_MODE_KEY`). A bare key like + `mode` could collide with a CS6-authored `DCEv` entry; the + namespace cannot. +- **The model owns the key.** The reader parses the entry into + `GroupObject::stack_mode` (`Option`) **and** bags + the `DTAv` node verbatim, as it does for every unmapped node. The + writer then rewrites the entry **in place** inside the bagged data + node — preserving the node's child order, so a file FireCraft did + not change round-trips byte-for-byte — and drops the entry when the + model holds no mode. Foreign entries (`tooltips` and friends) are + untouched. The model is therefore the single source of truth for + the mode; the bag is not a competing copy. +- **An unrecognized value canonicalizes to the absent mode.** The + reader accepts only `add`, `subtract`, `intersect`, and `invert`; + anything else reads as no mode (the default union) rather than as + an error, because a file authored by a future FireCraft must still + open. Such an entry is *not* dropped on write: the model cannot + express it, and the bag's contract is that content the model cannot + express survives verbatim. Only a spelling the model *can* read is + the model's to replace. +- **The vocabulary is the model's.** `MaskCombineMode::as_str` and + `parse` are the single definition of the four spellings; the + command `mask.setStackMode` takes `mode` as one of those strings + and rejects any other with `EngineError::BadPayload` naming the + offending value. `mask.setStackMode` acts on the **mask group** + (the group that owns the element mask), and the engine publishes a + read side — `engine::catalog::element_mask_stack(document, id)`, + which lists every level of the stack, top level first, with the + level's group, mask, combine mode, and enabled flag — so a shell + can render the control without reaching into the model. +- **The Properties panel draws the control.** The panel's "Mask + stack" section appears only when the selection carries an element + mask, and its combo box shows the level's mode. It is a real + binding: the shell hands `mask.setStackMode` to the engine, so the + registry command is reachable from the shell's designed surface. + +### What this does not do + +**The mode is stored and persisted, but not yet rendered.** The +compositor composites mask-structured groups unmasked in this milestone +(`crates/compose/src/render.rs`: "Mask-structured groups composite all +children unmasked this milestone (masks as pixel content are M6/M7; +`MNAb` is carried by the model only)"). Element masks are not applied +to pixels at all yet, so a combine mode has nothing to combine: a +single level has no coverage below it. The panel row is therefore a +control whose value round-trips through the document, not a control +that changes pixels yet. + +The open half of the work is the compositor's mask fold and +multi-level stacks (task 143): the renderer must apply each level +against the coverage below it using the level's operator. That work +depends on element masks reaching pixel content at all, which the +render milestone owns. + +## Consequences + +- The registry grows by one command (`engine::catalog::ids()`: 245 → + 246) and the fuzz call table gains the matching payload template, + so the fuzz gate's call-table/catalog equality still holds. +- The reachability share rises: the shell now binds 147 of the 246 + registry commands (59.76%), so the floor is re-pinned to that + measurement. +- The model carries the mode the way it carries the other mask + attributes (`MNAb`, `LNKb`): the document knows it, the renderer + does not yet act on it. That is a deliberate, recorded gap, not a + silent one. +- The doc crate's FWMap tests pin the round trip in both directions: + a mode survives write→read, a foreign `DCEv` entry in the same data + node survives verbatim, setting a mode rewrites the entry in place + rather than appending a second one, and a spelling this build does + not know survives the round trip untouched. diff --git a/docs/decisions/0093-mask-stack-levels-and-folding.md b/docs/decisions/0093-mask-stack-levels-and-folding.md new file mode 100644 index 00000000..a1b7367f --- /dev/null +++ b/docs/decisions/0093-mask-stack-levels-and-folding.md @@ -0,0 +1,91 @@ +# 0093 — A mask stack is nested mask groups, folded bottom-up + +Date: 2026-11-11 · Status: accepted + +## Context + +Decision 0092 decided *where* a mask level's combine mode lives (a +namespaced `DCEv` entry inside the mask group's `DTAv` node, owned by +the model). It deliberately left the rest open: how a stack of levels +is *shaped* in the document, how the levels *fold* into one coverage, +and what a disabled level means. + +CS6 has no stack (0092's first section), so again there is no CS6 +behavior to copy — the shape has to be one FireCraft can write into a +Fireworks PNG node tree without corrupting a CS6-authored file, and +that the compositor can evaluate. + +The document format gives one shape that is already load-bearing: the +element mask itself. A masked object is a group whose children are +`[mask, content]` and whose group type is `GRTi` 1 (grayscale mask) or +2 (shape mask). That pair is already how the model, the writer, and +the compositor describe one element mask. + +## Decision + +- **A stack is nested mask groups.** A level is a mask group; the + level's *content child* is the level below it. The outermost group + is the **top** level and the innermost is the **bottom**. This is + the shape the document already had for one mask, extended by one + level: no new group type, no new node, no new wire form. A + single-level stack is exactly the element mask FireCraft already + wrote, so the common case costs nothing extra and a CS6 file with + one mask reads as a one-level stack. +- **The bottom level replaces; every level above combines.** Folding + is bottom-up. The bottom level's coverage stands on its own — there + is nothing below it to combine with. Each level above combines its + own coverage with the coverage below it, using that level's + combine mode (`MaskCombineMode`): `Add` is the union, `Subtract` + cuts the level out of the coverage below, `Intersect` keeps the + overlap, and `Invert` replaces the coverage below with the level's + inverted coverage. The folded coverage is then applied to the + content's pixels. +- **A disabled level is skipped, not dropped.** `mask.enable` on a + level leaves the mask group in place with `MNAb` set (`mask_hidden`) + and the group type dropped to `GRTi` 0. A level in that state + contributes **no coverage** — it is skipped in the fold, so the + level below it applies on its own — but it stays in the stack, in + place, with its mask and its mode. Toggling a level off is + therefore reversible and does not reorder anything. +- **The stack's commands are structural, so they must undo + byte-exactly.** `mask.addLevel`, `mask.removeLevel`, and + `mask.moveLevel` move objects between containers — the content + child of one level becomes the group of another. Each of them + therefore emits a delta whose inverse restores the document's + serialized bytes exactly, and the engine's fuzz property (a failed + call mutates nothing; a successful call undoes and redoes + byte-exactly) covers them. +- **The read side lists the levels.** `engine::catalog::element_mask_stack` + returns the stack's levels, top level first, each with its group + id, its mask id, its combine mode, and whether it is enabled. It + resolves a level's group, a level's mask, or the object the stack + restricts — so the shell can render one row per level without + knowing how the stack is nested. + +### What this does not do + +The compositor folds coverage in the canvas's own space; a level's +mask is sampled at the canvas resolution rather than at the mask +image's own resolution, and the fold is a per-pixel operation with no +feathering beyond the mask's own gray level. The stack's shape is +also not yet editable from the panel beyond enable/mode/reorder/remove +(see task 143's remaining scope). + +## Consequences + +- The registry grows by three commands (`mask.addLevel`, + `mask.removeLevel`, `mask.moveLevel`) and the fuzz call table gains + the matching payload templates, so the fuzz gate's + call-table/catalog equality still holds. +- The compositor now applies element masks to pixels: a grayscale mask + contributes its gray level as coverage, a shape mask its painted + coverage, and the content's own alpha and color scale with the + folded coverage. The mask artwork is never composited as content. +- The engine's mask family grows a structural half (`element_mask_stack`, + the level commands) beside its state half (`element_mask_state`, + `mask.enable`); the singular read side that 0092 published is gone, + because the stack listing subsumes it. +- Decision 0092's "stored but not rendered" note is now historical: + the mode reaches pixels. 0092 remains the authority on *where* the + mode is stored; this record is the authority on the stack's shape + and its fold. diff --git a/docs/decisions/0097-release-train-and-mask-stack-merge.md b/docs/decisions/0097-release-train-and-mask-stack-merge.md new file mode 100644 index 00000000..20dbcd1a --- /dev/null +++ b/docs/decisions/0097-release-train-and-mask-stack-merge.md @@ -0,0 +1,77 @@ +# 0097 — The release train and the mask stack land as one tree, and the floor follows the merged measurement + +Date: 2026-11-11 · Tickets: [T7](../tasks/T7-optimize-panel.md), [T8](../tasks/T8-animation-timeline.md), [T9](../tasks/T9-mask-stack.md) · Status: accepted + +## Context + +Two feature lines were in flight from the same base. + +- The **release train** landed on `main` in three steps: the Optimize + panel ([T7](T7-optimize-panel.md), decision + [0095](0095-optimize-panel-honors-only-what-export-honors.md)), the + animation timeline ([T8](T8-animation-timeline.md), decision + [0094](0094-animation-timeline-shell.md)), and the document tabs + (20.5). Each step re-pinned the reachability floor and restated the + Phase-20 row in the task ledger. +- The **mask stack** ([T9](T9-mask-stack.md), decisions + [0092](0092-mask-stack-combine-mode.md) and + [0093](0093-mask-stack-levels-and-folding.md)) grew on its own branch + from the older base. It added four registry commands, the model's + mask-stack entry, FWMap persistence for it, the compositor's + bottom-up fold, and the Properties-panel level list with per-level + link/unlink. + +Both branches had touched the same four files — the doc crate's public +surface and the ledger that records Phase 20 — so the merge had real +conflicts, and both branches had quoted *their own* reachability +measurement in the ledger and in `NEXT.md`, `GAPS.md` and +`PROGRESS.md`. Only one number can be in the merged tree. + +## Decision + +1. **Merge, do not rebase-and-pick.** The release train is merged into + the mask-stack branch, and the mask stack's whole tree lands with + it. Neither line is reimplemented against the other: the release + train's Optimize panel, animation timeline and document tabs, and + the mask stack's model, persistence, fold and level panel, are all + in the merged tree. +2. **The doc crate exports the union.** `crates/doc/src/lib.rs` + re-exports `model::{…}`; the merged list carries the release + train's `DEFAULT_FRAME_DURATION` and the mask stack's + `MASK_STACK_MODE_KEY` and `MaskCombineMode`. Both sides' names are + genuinely in `model`, and each is used by the half that added it. +3. **One Phase-20 ledger row, naming both landings.** The row reads + "20.1, 20.2, 20.3, 20.5 landed; 20.4 partial", and the detail + names the Optimize panel, the animation timeline and the mask + stack with their tickets. 20.4 stays `PARTIAL` — its screenshot + acceptance and the remaining add/remove/move affordances are + open — so the merged ledger does not claim more than landed. +4. **The floor follows the merged measurement.** The reachability gate + measures the tree it runs in: the merged tree binds **172 of 257 + registry commands (66.93%)**, 85 unreachable. The floor file is + re-pinned to that measurement, which is the legal direction of the + one-way ratchet (the release train had already pinned 66.40%). The + quoted numbers in `NEXT.md`, `GAPS.md` and `PROGRESS.md` are + restated to the merged measurement so the ledger and the gate + cannot disagree. + +## Consequences + +- The floor rises from 66.40% to 66.93%: a shell that binds fewer + commands than the merged tree does now fails the gate. The pin was + made from the merged measurement, not from either side's, so the + ratchet reflects what the merged tree actually reaches. +- Decisions [0092](0092-mask-stack-combine-mode.md), + [0094](0094-animation-timeline-shell.md) and + [0095](0095-optimize-panel-honors-only-what-export-honors.md) keep + the measurements that were true when they were written. They are + dated records, not live counters; the live figure lives in + `.reachability-floor.tsv` and is quoted by the docs. +- The mask stack's remaining acceptance — the screenshot and the + add/remove/move affordances — stays open in + [T9](T9-mask-stack.md) and in the ledger's 20.4 row. Merging does + not close it. +- The two branches' Phase-20 items that were never in conflict (the + Optimize panel's Adam7/JPEG work, the timeline's playback and + thumbnails, the history rewind, slice behavior) keep their own + tickets and their own open state. diff --git a/docs/tasks/T9-mask-stack.md b/docs/tasks/T9-mask-stack.md new file mode 100644 index 00000000..f1d3c279 --- /dev/null +++ b/docs/tasks/T9-mask-stack.md @@ -0,0 +1,77 @@ +# T9 — 20.4 Mask stack UI + +**Objective:** a mask stack the user can edit: add a level, remove a +level, choose how a level combines with the coverage below it, link +and unlink a level, and move a level in the stack. + +**Inputs:** the element-mask machinery (mask groups, `mask.enable`, +`mask.setShowAttrs`, `mask.link`); the Properties panel's mask +section; the compositor's group fold. + +**Authority:** CS6 has **no** mask stack — one element mask per object +— so the stack is a FireCraft extension. The evidence for the absence +is in the reference install's resource DLL: the Modify ▸ Mask submenu +carries `&Mask`, `&Reveal All`, `&Hide All`, `&Paste as Mask`, +`&Group as Mask`, `&Disable Mask`, `&Delete Mask` and nothing that +names a stack, a level, or a combine mode. The wire contract is +FireCraft's own; the *shape* follows the modern raster editors +(Photoshop layer masks, Affinity mask stacks): per-level operators — +union, subtract, intersect, invert — over the coverage below. + +**Stopping condition:** the mode a level carries survives a save/load +round trip; a mode the user picks is what the model holds; an +unrecognized spelling from a future file does not break the load; and +the operator actually changes the rendered coverage. + +**Verification:** `cargo test -p doc` (the FWMap round trip), +`cargo test -p engine` (the command), `cargo test -p app` (the panel), +plus the repo gate `cargo run -p xtask -- ci`. + +## Status — PARTIAL (2026-11-11) + +**Landed** (decisions [0092](../decisions/0092-mask-stack-combine-mode.md) +and [0093](../decisions/0093-mask-stack-levels-and-folding.md)): + +- The model owns the operator. `doc::MaskCombineMode` is `Add`, + `Subtract`, `Intersect`, `Invert`; `GroupObject::stack_mode` holds + it; the wire key is the namespaced `DCEv` entry + `firecraft.mask.mode` inside the mask group's `DTAv` data node. +- The FWMap round trip works in both directions. The reader reads the + entry into the model *and* bags the data node; the writer rewrites + the entry in place inside the bagged node (so an unchanged file + re-serializes byte-identically) and leaves foreign entries + (`tooltips` and friends) verbatim. An unrecognized spelling + canonicalizes to the default on read and survives the write + untouched, because the model cannot express it. +- `mask.setStackMode` sets the level's operator, resolving the mask + group from either the group or the object it masks, and refuses an + unknown spelling with `EngineError::BadPayload`. +- **A stack is nested mask groups** (0093): a level's content child + is the level below it, the outermost group is the top level. The + bottom level replaces the coverage and each level above combines + with the coverage below it by its operator. A disabled level stays + in the stack in place and contributes no coverage. +- **The compositor applies the stack.** `compose` folds the levels + bottom-up into one coverage and scales the content's pixels by it; + the mask artwork is never composited as content. +- **The panel lists the levels** — one row per level, top first, + numbered from the bottom the way CS6 numbers its masks. Each row + has the enable checkbox, the operator combo, the reorder arrows and + the remove button; the section's foot adds a level. +- **A level links and unlinks its own mask.** The link flag rides the + level's mask group (`LNKb` is written only when the mask is + *un*linked), so the flag is per level: unlinking one level leaves + the levels around it linked. The panel row carries the checkbox. +- The structural level commands (`mask.addLevel`, + `mask.removeLevel`, `mask.moveLevel`) undo byte-exactly — the + engine's fuzz property covers them. + +**Open:** + +- The compositor samples a level's mask at the canvas resolution + rather than at the mask image's own resolution, and the fold is a + per-pixel operation with no feathering beyond the mask's own gray + level. +- No screenshot acceptance yet — the section is unstyled relative to + the rest of the Properties panel, and the Phase 22 screenshot audit + has not covered it.