From 039c0cbef75611f86b142d95aafed9435020ae7b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 23:10:06 +0700 Subject: [PATCH] M3.48: Page Isolated Blending and Page Knockout Group New Document.page_isolate / page_knockout, set by transparency.togglePageIsolatedBlending / togglePageKnockoutGroup {value?} (one undo step each, saved with the document, reported by transparency.info) and the Transparency panel menu's check items. The page renders as an isolated group (top-level blend modes no longer blend with what lies under the page) and/or a knockout group whose elements are the layers (neutral layers pass their objects through). PDF and SVG wrap the page content in an isolated group and write page knockout as soft masks. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/lib.rs | 10 ++++++ crates/engine/src/cmd/opacitymask.rs | 40 ++++++++++++++++++++++- crates/engine/src/tests_knockout.rs | 35 ++++++++++++++++++-- crates/engine/src/tests_opacitymask.rs | 2 +- crates/pdf/src/export.rs | 13 ++++++-- crates/render/src/lib.rs | 21 ++++++++++-- crates/render/src/tests_knockout.rs | 25 ++++++++++++-- crates/svg/src/export.rs | 15 +++++++-- crates/ui-egui/src/panels/transparency.rs | 19 ++++++++--- docs/mcp.md | 4 ++- 10 files changed, 163 insertions(+), 21 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 957a1269..1cfce9c6 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -334,6 +334,14 @@ pub struct Document { /// Foreign data preserved on round-trip. #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] pub unknown: serde_json::Map, + /// Transparency panel → Page Isolated Blending: the page is an isolated transparency group, + /// so top-level blend modes don't blend with what lies under the page. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub page_isolate: bool, + /// Transparency panel → Page Knockout Group: the page's elements (its layers; neutral layers + /// pass their contents through) knock each other out. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub page_knockout: bool, } fn ppi72() -> f64 { @@ -377,6 +385,8 @@ impl Document { mask_edit: None, next_id: 1, unknown: Default::default(), + page_isolate: false, + page_knockout: false, }; let id = d.alloc_id(); d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0)))); diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index 77f90c74..6cec3b24 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -108,10 +108,28 @@ pub fn specs() -> Vec { "Transparency Info", [], None, - "{id?|ids?} → {ids, opacity: 0..100, blend, isolate, knockout: \"on\"|\"off\"|\"neutral\", knockoutShape, editingMask} the Transparency panel's values for `ids`, the selection, or the object whose mask is being edited. A value is null where those objects differ (or there are none); editingMask is the id of the object whose mask is being edited, or null", + "{id?|ids?} → {ids, opacity: 0..100, blend, isolate, knockout: \"on\"|\"off\"|\"neutral\", knockoutShape, editingMask, pageIsolatedBlending, pageKnockoutGroup} the Transparency panel's values for `ids`, the selection, or the object whose mask is being edited. A value is null where those objects differ (or there are none); editingMask is the id of the object whose mask is being edited, or null; the page values are the document's", has_doc, info ), + cmd!( + "transparency.togglePageIsolatedBlending", + "Page Isolated Blending", + ["Window", "Transparency"], + None, + "{value?} make the page an isolated group (default: toggle): blend modes of top-level objects don't blend with what lies under the page; saved with the document and written to PDF → {value}", + has_doc, + |s, p| toggle_page(s, p, false) + ), + cmd!( + "transparency.togglePageKnockoutGroup", + "Page Knockout Group", + ["Window", "Transparency"], + None, + "{value?} make the page a knockout group (default: toggle): its layers (the contents of neutral layers) hide what they cover instead of showing it through their transparency; saved with the document and written to PDF → {value}", + has_doc, + |s, p| toggle_page(s, p, true) + ), ] } @@ -248,6 +266,24 @@ fn toggle_new_clip(s: &mut Session, p: &Value) -> Result { Ok(json!({ "value": v })) } +/// Set (or toggle) the page's knockout group flag (`knockout`) or isolated blending flag, as one +/// undo step; setting the current value changes nothing. +fn toggle_page(s: &mut Session, p: &Value, knockout: bool) -> Result { + fn flag(d: &mut Document, knockout: bool) -> &mut bool { + if knockout { &mut d.page_knockout } else { &mut d.page_isolate } + } + let d = &s.doc()?.doc; + let cur = if knockout { d.page_knockout } else { d.page_isolate }; + let v = bool_or(p, "value", !cur); + if v != cur { + s.edit(if knockout { "Page Knockout Group" } else { "Page Isolated Blending" }, |d, _| { + *flag(d, knockout) = v; + Ok(()) + })?; + } + Ok(json!({ "value": v })) +} + fn toggle_new_invert(s: &mut Session, p: &Value) -> Result { let v = bool_or(p, "value", !s.menu.new_masks_inverted); s.menu.new_masks_inverted = v; @@ -329,6 +365,8 @@ fn info(s: &mut Session, p: &Value) -> Result { "knockout": i.knockout.map(Knockout::label), "knockoutShape": i.knockout_shape, "editingMask": st.doc.mask_edit.map(|me| me.object.0), + "pageIsolatedBlending": st.doc.page_isolate, + "pageKnockoutGroup": st.doc.page_knockout, })) } diff --git a/crates/engine/src/tests_knockout.rs b/crates/engine/src/tests_knockout.rs index f94eeefa..26e27cc0 100644 --- a/crates/engine/src/tests_knockout.rs +++ b/crates/engine/src/tests_knockout.rs @@ -1,4 +1,4 @@ -//! Knockout groups and the knockout shape (M3.46–M3.47): commands, undo, files +//! Knockout groups, the knockout shape and the page group (M3.46–M3.48): commands, undo, files //! (old bools load) and the PDF and SVG output. use serde_json::{Value, json}; @@ -80,6 +80,25 @@ fn knockout_shape_is_an_object_option() { assert!(!node(&s, child).knockout_shape); } +#[test] +fn page_group_toggles_are_undoable_document_settings() { + let mut s = session(); + let doc = |s: &Session| (s.doc().unwrap().doc.page_isolate, s.doc().unwrap().doc.page_knockout); + let before = steps(&s); + assert_eq!(run(&mut s, "transparency.togglePageIsolatedBlending", json!({})), json!({"value": true})); + assert_eq!(run(&mut s, "transparency.togglePageKnockoutGroup", json!({"value": true})), json!({"value": true})); + assert_eq!((doc(&s), steps(&s)), ((true, true), before + 2)); + // Setting the current value is not a change. + run(&mut s, "transparency.togglePageKnockoutGroup", json!({"value": true})); + assert_eq!(steps(&s), before + 2); + let info = run(&mut s, "transparency.info", json!({})); + assert_eq!((info["pageIsolatedBlending"].clone(), info["pageKnockoutGroup"].clone()), (json!(true), json!(true))); + run(&mut s, "edit.undo", json!({})); + assert_eq!(doc(&s), (true, false)); + run(&mut s, "transparency.togglePageIsolatedBlending", json!({})); + assert_eq!(doc(&s), (false, false)); +} + #[test] fn knockout_settings_round_trip_and_old_bools_load() { let mut s = session(); @@ -87,13 +106,15 @@ fn knockout_settings_round_trip_and_old_bools_load() { let child = node(&s, g).children().unwrap()[0].id.0; run(&mut s, "transparency.set", json!({"ids": [g], "knockout": "off"})); run(&mut s, "transparency.set", json!({"ids": [child], "knockout": "on", "knockoutShape": true})); + run(&mut s, "transparency.togglePageIsolatedBlending", json!({})); + run(&mut s, "transparency.togglePageKnockoutGroup", json!({})); let bytes = vectorcraft_format::save(&s.doc().unwrap().doc, false); let back = vectorcraft_format::load(&bytes).unwrap(); let (bg, bc) = (back.node(NodeId(g)).unwrap(), back.node(NodeId(child)).unwrap()); - assert_eq!((bg.knockout, bc.knockout, bc.knockout_shape), (Knockout::Off, Knockout::On, true)); + assert_eq!((bg.knockout, bc.knockout, bc.knockout_shape, back.page_isolate, back.page_knockout), (Knockout::Off, Knockout::On, true, true, true)); // Defaults are not written. let text = String::from_utf8(vectorcraft_format::save(&session().doc().unwrap().doc, false)).unwrap(); - assert!(!text.contains("knockout"), "{text}"); + assert!(!text.contains("knockout") && !text.contains("page_isolate"), "{text}"); // Files from before the three states stored a bool: true is On, false is Neutral. let mut v: Value = serde_json::from_slice(&bytes).unwrap(); let mut patched = 0; @@ -145,4 +166,12 @@ fn pdf_and_svg_write_knockout_groups_as_masks() { run(&mut s, "transparency.set", json!({"ids": [g], "knockout": "neutral"})); run(&mut s, "transparency.set", json!({"ids": [outer], "knockout": "on"})); assert_eq!(svg(&s.doc().unwrap().doc).matches("mask=\"url(#knockout-").count(), 1); + + // The page group: an isolated group around the page content. + let mut page = (*plain).clone(); + page.page_isolate = true; + assert!(svg(&page).contains("isolation")); + page.page_knockout = true; + assert_eq!(svg(&page).matches("mask=\"url(#knockout-").count(), 1, "the layer is neutral: its objects knock each other out"); + assert!(String::from_utf8_lossy(&pdf(&page).bytes).contains("/Luminosity")); } diff --git a/crates/engine/src/tests_opacitymask.rs b/crates/engine/src/tests_opacitymask.rs index ca25ca14..3cfba760 100644 --- a/crates/engine/src/tests_opacitymask.rs +++ b/crates/engine/src/tests_opacitymask.rs @@ -182,7 +182,7 @@ fn info_reports_mixed_values_as_null() { let info = s.execute("transparency.info", &json!({})).unwrap(); assert_eq!( info, - json!({"ids": [a.0, b.0], "opacity": null, "blend": "Screen", "isolate": null, "knockout": "neutral", "knockoutShape": false, "editingMask": null}) + json!({"ids": [a.0, b.0], "opacity": null, "blend": "Screen", "isolate": null, "knockout": "neutral", "knockoutShape": false, "editingMask": null, "pageIsolatedBlending": false, "pageKnockoutGroup": false}) ); let one = s.execute("transparency.info", &json!({"id": a.0})).unwrap(); assert_eq!((one["opacity"].clone(), one["isolate"].clone()), (json!(50.0), json!(true))); diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 69fd2129..ad0cff06 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -70,7 +70,7 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result Result>()); + } else { + for layer in layers { + self.draw_arc(&mut ctx, &frame, layer); + } + } + if page_group { + ctx.pop_layer(); + } + self.knockout = false; } if trim { ctx.pop_layer(); diff --git a/crates/render/src/tests_knockout.rs b/crates/render/src/tests_knockout.rs index b583b8e1..a5176608 100644 --- a/crates/render/src/tests_knockout.rs +++ b/crates/render/src/tests_knockout.rs @@ -1,7 +1,7 @@ -//! Knockout groups and the knockout shape (Transparency panel). +//! Knockout groups, the knockout shape and the page group (Transparency panel). use super::*; -use vectorcraft_color::{Color, Paint}; +use vectorcraft_color::{BlendMode as Blend, Color, Paint}; use vectorcraft_doc::{Appearance, Knockout, Node}; use vectorcraft_geom::shapes; @@ -105,3 +105,24 @@ fn neutral_groups_pass_knockout_through() { Arc::make_mut(&mut Arc::make_mut(&mut plain.layers[0]).children_mut().unwrap()[0]).knockout = Knockout::Neutral; assert!(close(render(&plain).pixel(50, 50), BLUE_OVER_RED)); } + +#[test] +fn page_knockout_and_isolated_blending() { + // Page Knockout Group: the neutral layer passes its objects through to the page. + let mut d = doc(Knockout::Neutral, |_, _| {}); + d.page_knockout = true; + assert!(close(render(&d).pixel(50, 50), BLUE_OVER_WHITE), "{:?}", render(&d).pixel(50, 50)); + // A layer set to Off keeps its objects from knocking each other out. + Arc::make_mut(&mut d.layers[0]).knockout = Knockout::Off; + assert!(close(render(&d).pixel(50, 50), BLUE_OVER_RED)); + + // Page Isolated Blending: a Difference object no longer blends with the white under the page. + let mut d = Document::new(100.0, 100.0); + let mut red = half(&mut d, 10.0, (1.0, 0.0, 0.0)); + red.opacity = 1.0; + red.blend = Blend::Difference; + d.insert(Some(d.layers[0].id), 0, red).unwrap(); + assert!(close(render(&d).pixel(30, 50), [0, 255, 255, 255]), "inverts the white: {:?}", render(&d).pixel(30, 50)); + d.page_isolate = true; + assert!(close(render(&d).pixel(30, 50), [255, 0, 0, 255]), "isolated: {:?}", render(&d).pixel(30, 50)); +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 3062de33..8c4e0753 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -44,13 +44,22 @@ pub(crate) fn export(doc: &Document, opts: &ExportOptions) -> String { depth: 1, patterns: HashMap::new(), pattern_nest: 0, - knockout: false, + knockout: doc.page_knockout, anonymous: false, knockout_filter: None, }; w.assign_name_ids(); - for l in &doc.layers { - w.node(l); + // Page Isolated Blending / Page Knockout Group: the page content is one isolated group. + let page_group = doc.page_isolate || doc.page_knockout; + if page_group { + let a = w.attrs(&vec![("isolation", "isolate".into())]); + w.line(&format!("")); + w.depth += 1; + } + w.children(&doc.layers); + if page_group { + w.depth -= 1; + w.line(""); } let nl = if opts.minify { "" } else { "\n" }; diff --git a/crates/ui-egui/src/panels/transparency.rs b/crates/ui-egui/src/panels/transparency.rs index 995bd02a..52649285 100644 --- a/crates/ui-egui/src/panels/transparency.rs +++ b/crates/ui-egui/src/panels/transparency.rs @@ -1,7 +1,7 @@ //! Transparency panel: blend mode, opacity (field + slider popup), object/mask thumbnails with the //! opacity-mask controls (make/release, link, clip, invert; Shift-click the mask to disable it), //! Isolate Blending, the three-state Knockout Group (on → neutral → off) and Opacity & Mask Define -//! Knockout Shape. +//! Knockout Shape; the menu's Page Isolated Blending and Page Knockout Group. use egui::{Sense, Stroke, StrokeKind, Ui, vec2}; use serde_json::json; @@ -200,8 +200,14 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("transparency.toggleNewMasksInverted", json!({})).ok(); } ui.separator(); - menu_item(ui, "Page Isolated Blending", false, false); - menu_item(ui, "Page Knockout Group", false, false); + let page = app.session.active().map(|d| (d.doc.page_isolate, d.doc.page_knockout)); + let (isolate, knockout) = page.unwrap_or_default(); + if menu_item(ui, "Page Isolated Blending", page.is_some(), isolate) { + app.run("transparency.togglePageIsolatedBlending", json!({})).ok(); + } + if menu_item(ui, "Page Knockout Group", page.is_some(), knockout) { + app.run("transparency.togglePageKnockoutGroup", json!({})).ok(); + } } /// The menu's opacity-mask rows (label, command, enabled) for the target's `mask`; Make needs a @@ -358,7 +364,7 @@ mod tests { } #[test] - fn knockout_states() { + fn knockout_states_and_page_group_items() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); run(&mut app, "file.new", json!({"width": 100, "height": 100})); run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})); @@ -367,5 +373,10 @@ mod tests { let texts = frame(&mut app); assert!(texts.iter().any(|t| t == "Knockout Group") && texts.iter().any(|t| t == "Opacity & Mask Define Knockout Shape")); } + assert!(frame(&mut app).iter().any(|t| t.ends_with(" Page Knockout Group") && !t.starts_with('✓'))); + run(&mut app, "transparency.togglePageKnockoutGroup", json!({})); + run(&mut app, "transparency.togglePageIsolatedBlending", json!({})); + let texts = frame(&mut app); + assert!(texts.iter().any(|t| t == "✓ Page Knockout Group") && texts.iter().any(|t| t == "✓ Page Isolated Blending"), "{texts:?}"); } } diff --git a/docs/mcp.md b/docs/mcp.md index 0ebe60b5..e34c9051 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -188,7 +188,9 @@ until `transparency.stopEditingOpacityMask`. Saves and exports never include the Knockout Group has three states: `transparency.set {knockout: "on"|"off"|"neutral"}` (`true` = on, `false` = neutral, the default). In a knockout group each child hides what it covers of the children below it; neutral groups pass the enclosing group's setting to their children, off groups never knock out. `knockoutShape: true` makes the -object's opacity and opacity mask scale how much it knocks out. Knockout groups render as isolated groups; +object's opacity and opacity mask scale how much it knocks out. `transparency.togglePageKnockoutGroup` and +`transparency.togglePageIsolatedBlending` (`{value?}`) treat the whole page as a knockout or isolated group; they are +saved with the document and undoable, and `transparency.info` reports them. Knockout groups render as isolated groups; PDF and SVG write them as soft-masked groups with the same look (the PDF export reports a warning). ## Clipping masks