diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index 22ebf6b4..18974b53 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -15,6 +15,9 @@ //! Edit → Transparency Flattener Presets: the built-in presets plus the user's, saved with the //! preferences ([`crate::Prefs::flattener_presets`]); a `preset` named anywhere options are taken //! finds both. +//! +//! Window → Flattener Preview: the same plan, worked out without rendering, reports what flattening +//! the document would touch ([`FlattenReport`], `flattener.preview`). use std::sync::Arc; @@ -89,6 +92,15 @@ pub fn specs() -> Vec { always, presets_export ), + cmd!( + query "flattener.preview", + "Flattener Preview", + [], + None, + "{highlight?: \"none\"|\"rasterizedRegions\" (areas the raster/vector balance rasterizes whole)|\"transparentObjects\"|\"allAffected\"|\"expandedPatterns\" (pattern art taking part)|\"outlinedStrokes\"|\"outlinedText\"|\"allRasterized\" (default none), overprints?: \"preserve\"|\"simulate\"|\"discard\" (simulate and discard flatten without preserveOverprints), preset?, …options (as object.flattenTransparency), ids?: (default: the whole document)} what flattening would do, without changing anything → {highlight, regions: [{bounds: {x, y, width, height}, id?}] (the highlighted objects, or areas), counts: {transparentObjects, allAffected, expandedPatterns, outlinedStrokes, outlinedText, rasterizedRegions, allRasterized, vectorRegions}, options}", + has_doc, + preview + ), ] } @@ -218,7 +230,7 @@ fn flatten(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Other("Flatten Transparency: select objects to flatten".into())); } let src = s.doc()?.doc.clone(); - let plan = plan(&src, &roots, &o); + let plan = plan(&src, &roots, &o, true); let options = serde_json::to_value(&o).unwrap_or_default(); if plan.flat.is_empty() && !plan.kept.iter().any(|id| src.node(*id).is_some_and(|n| kept_work(n, &o))) { return Ok(json!({ "ids": roots.iter().map(|i| i.0).collect::>(), "rasterized": 0, "vector": 0, "options": options })); @@ -265,6 +277,13 @@ impl Session { v } + /// The preset `name` names: a built-in one (by id or display name) or a saved one, any case. + pub fn flattener_preset(&self, name: &str) -> Option { + let key = name.trim(); + let key = FlattenOptions::preset_label(&key.to_ascii_lowercase()).unwrap_or(key); + self.flattener_presets().into_iter().find(|p| p.name.eq_ignore_ascii_case(key)) + } + /// The flattener options `p` asks for: a `preset` by name, built-in or saved, adjusted by the /// option keys ([`FlattenOptions::from_params_with`]). pub fn flatten_options(&self, p: &Value) -> std::result::Result { @@ -341,12 +360,9 @@ fn presets_export(s: &mut Session, p: &Value) -> Result { const C: &str = "flattener.presets.export"; let presets = match p.get("names").and_then(Value::as_array) { Some(names) => { - let all = s.flattener_presets(); let find = |n: &Value| { - let n = n.as_str().unwrap_or_default().trim(); - // Built-in ones also by id (`high`). - let key = FlattenOptions::preset_label(&n.to_ascii_lowercase()).unwrap_or(n); - all.iter().find(|q| q.name.eq_ignore_ascii_case(key)).cloned().ok_or_else(|| bad(C, format!("no preset named `{n}`"))) + let n = n.as_str().unwrap_or_default(); + s.flattener_preset(n).ok_or_else(|| bad(C, format!("no preset named `{n}`"))) }; names.iter().map(find).collect::>>()? } @@ -425,6 +441,284 @@ fn target_roots(s: &Session, p: &Value) -> Result> { Ok(roots.into_iter().filter(|id| d.is_visible(*id) && d.is_editable(*id) && !clipping(*id)).collect()) } +// ---------- preview ---------- + +/// What the Flattener Preview highlights. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Highlight { + /// Nothing: the art in colour. + #[default] + None, + /// Areas the raster/vector balance rasterizes whole. + RasterizedRegions, + /// Objects with transparency of their own. + TransparentObjects, + /// Every object flattening changes. + AllAffected, + /// Objects with pattern paint in the flattened art. + ExpandedPatterns, + /// Strokes that become filled outlines. + OutlinedStrokes, + /// Type that becomes outlines. + OutlinedText, + /// Every area that becomes an image. + AllRasterized, +} + +impl Highlight { + pub const ALL: [Self; 8] = [ + Self::None, + Self::RasterizedRegions, + Self::TransparentObjects, + Self::AllAffected, + Self::ExpandedPatterns, + Self::OutlinedStrokes, + Self::OutlinedText, + Self::AllRasterized, + ]; + + /// The id `flattener.preview` takes. + pub fn id(self) -> &'static str { + match self { + Self::None => "none", + Self::RasterizedRegions => "rasterizedRegions", + Self::TransparentObjects => "transparentObjects", + Self::AllAffected => "allAffected", + Self::ExpandedPatterns => "expandedPatterns", + Self::OutlinedStrokes => "outlinedStrokes", + Self::OutlinedText => "outlinedText", + Self::AllRasterized => "allRasterized", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::None => "None (Color Preview)", + Self::RasterizedRegions => "Rasterized Complex Regions", + Self::TransparentObjects => "Transparent Objects", + Self::AllAffected => "All Affected Objects", + Self::ExpandedPatterns => "Expanded Patterns", + Self::OutlinedStrokes => "Outlined Strokes", + Self::OutlinedText => "Outlined Text", + Self::AllRasterized => "All Rasterized Regions", + } + } + + /// A highlight by id or label, any case. + pub fn parse(s: &str) -> Option { + let s = s.trim(); + Self::ALL.into_iter().find(|h| h.id().eq_ignore_ascii_case(s) || h.label().eq_ignore_ascii_case(s)) + } +} + +/// How the Flattener Preview treats overprints: kept as they are, simulated in the preview, or +/// dropped; only Preserve flattens with Preserve Overprints and Spot Colors. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Overprints { + #[default] + Preserve, + Simulate, + Discard, +} + +impl Overprints { + pub const ALL: [Self; 3] = [Self::Preserve, Self::Simulate, Self::Discard]; + + pub fn id(self) -> &'static str { + match self { + Self::Preserve => "preserve", + Self::Simulate => "simulate", + Self::Discard => "discard", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Preserve => "Preserve", + Self::Simulate => "Simulate", + Self::Discard => "Discard", + } + } + + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|o| o.id().eq_ignore_ascii_case(s.trim()) || o.label().eq_ignore_ascii_case(s.trim())) + } +} + +/// What flattening would do (Window → Flattener Preview, `flattener.preview`). +#[derive(Clone, Debug, Default)] +pub struct FlattenReport { + /// Top-level objects with transparency of their own. + pub transparent: Vec, + /// Top-level objects flattening changes: the overlapping groups with transparency, and those + /// the outline and overprint options change. + pub affected: Vec, + /// Objects with pattern paint in the flattened art. + pub patterns: Vec, + /// Paths whose strokes become filled outlines. + pub strokes: Vec, + /// Type that becomes outlines. + pub text: Vec, + /// Areas the raster/vector balance rasterizes whole. + pub complex: Vec, + /// Every area that becomes an image. + pub rasterized: Vec, + /// Flat-colour regions made. + pub vector: usize, +} + +impl FlattenReport { + /// The objects `h` highlights. + pub fn objects(&self, h: Highlight) -> &[NodeId] { + match h { + Highlight::TransparentObjects => &self.transparent, + Highlight::AllAffected => &self.affected, + Highlight::ExpandedPatterns => &self.patterns, + Highlight::OutlinedStrokes => &self.strokes, + Highlight::OutlinedText => &self.text, + _ => &[], + } + } + + /// The areas `h` highlights. + pub fn areas(&self, h: Highlight) -> &[PathData] { + match h { + Highlight::RasterizedRegions => &self.complex, + Highlight::AllRasterized => &self.rasterized, + _ => &[], + } + } + + /// How many objects or areas each highlight shows. + pub fn counts(&self) -> Value { + json!({ + "transparentObjects": self.transparent.len(), + "allAffected": self.affected.len(), + "expandedPatterns": self.patterns.len(), + "outlinedStrokes": self.strokes.len(), + "outlinedText": self.text.len(), + "rasterizedRegions": self.complex.len(), + "allRasterized": self.rasterized.len(), + "vectorRegions": self.vector, + }) + } + + /// The report as `flattener.preview` answers: the highlighted objects (or areas) of `doc` and + /// the counts. + pub fn to_json(&self, doc: &Document, h: Highlight) -> Value { + let rect = |r: Rect| json!({"x": r.x0, "y": r.y0, "width": r.width(), "height": r.height()}); + let objects = self.objects(h).iter().filter_map(|id| Some(json!({"id": id.0, "bounds": rect(doc.node(*id)?.visual_bounds()?)}))); + let areas = self.areas(h).iter().filter_map(|p| Some(json!({ "bounds": rect(p.bounds()?) }))); + json!({"highlight": h.id(), "regions": objects.chain(areas).collect::>(), "counts": self.counts()}) + } + + /// The art `h` highlights, opaque, alone in a copy of `doc`: the objects and the areas (filled + /// black). Its coverage is the highlight. `None` when nothing is highlighted. + pub fn highlight_art(&self, doc: &Document, h: Highlight) -> Option { + let (ids, areas) = (self.objects(h), self.areas(h)); + if ids.is_empty() && areas.is_empty() { + return None; + } + let mut tmp = doc.clone(); + let mut art: Vec = ids.iter().filter_map(|id| doc.node(*id).cloned()).collect(); + art.iter_mut().for_each(opaque); + for path in areas { + let mut n = shape_node(&mut tmp, path.clone(), None); + n.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 0.0)), Paint::None, 0.0); + art.push(n); + } + Some(isolated_doc(&tmp, art)) + } +} + +/// What flattening `roots` of `doc` with `o` would do. +pub fn report(doc: &Document, roots: &[NodeId], o: &FlattenOptions) -> FlattenReport { + let plan = plan(doc, roots, o, false); + let mut r = FlattenReport { transparent: plan.transparent, ..Default::default() }; + // Every object under `id` (but a compound path's own pieces), visible ones only. + fn visit(n: &Node, f: &mut dyn FnMut(&Node)) { + if !n.visible { + return; + } + f(n); + if !matches!(n.kind, NodeKind::Compound { .. }) { + for c in n.children().into_iter().flatten() { + visit(c, f); + } + } + } + let pattern = |n: &Node| n.appearance.items.iter().any(|i| i.visible() && matches!(i.paint(), Paint::Pattern { .. })); + for f in &plan.flat { + r.affected.extend(&f.roots); + r.vector += f.regions.len(); + for root in f.roots.iter().filter_map(|id| doc.node(*id)) { + visit(root, &mut |n| { + // Flattened art is outlined throughout and its patterns become part of the image. + if matches!(n.kind, NodeKind::Text(_)) { + r.text.push(n.id); + } else if stroked(n) { + r.strokes.push(n.id); + } + if pattern(n) { + r.patterns.push(n.id); + } + }); + } + if let Some(a) = &f.area { + if a.whole { + r.complex.push(a.outline()); + } + r.rasterized.push(a.outline()); + } + } + for root in plan.kept.iter().filter_map(|id| doc.node(*id)).filter(|n| kept_work(n, o)) { + r.affected.push(root.id); + visit(root, &mut |n| { + if o.text_to_outlines && matches!(n.kind, NodeKind::Text(_)) { + r.text.push(n.id); + } else if o.strokes_to_outlines && stroked(n) { + r.strokes.push(n.id); + } + }); + } + r.affected.sort_by_key(|id| doc.index_path(*id)); + r +} + +impl Session { + /// What flattening would do with the options `p` asks for (as `flattener.preview`): the + /// options and the report. + pub fn flattener_report(&self, p: &Value) -> Result<(FlattenOptions, FlattenReport)> { + const C: &str = "flattener.preview"; + let mut o = self.flatten_options(p).map_err(|m| bad(C, m))?; + if let Some(v) = str_param(p, "overprints") { + let ov = Overprints::parse(v).ok_or_else(|| bad(C, format!("unknown overprints `{v}` (preserve, simulate or discard)")))?; + o.preserve_overprints = ov == Overprints::Preserve; + } + let roots = if p.get("ids").is_some() { + target_roots(self, p)? + } else { + let layers: Vec = self.doc()?.doc.layers.iter().map(|l| l.id.0).collect(); + target_roots(self, &json!({ "ids": layers }))? + }; + let r = report(&self.doc()?.doc, &roots, &o); + Ok((o, r)) + } +} + +fn preview(s: &mut Session, p: &Value) -> Result { + let h = match str_param(p, "highlight") { + Some(v) => Highlight::parse(v).ok_or_else(|| bad("flattener.preview", format!("unknown highlight `{v}`")))?, + None => Highlight::None, + }; + let (o, r) = s.flattener_report(p)?; + let mut out = r.to_json(&s.doc()?.doc, h); + out["options"] = serde_json::to_value(&o).unwrap_or_default(); + Ok(out) +} + // ---------- plan ---------- /// What the flattening does, worked out before the document changes. @@ -432,16 +726,40 @@ struct Plan { flat: Vec, /// Objects without transparency (they stay, but for the outline options). kept: Vec, + /// The objects with transparency of their own. + transparent: Vec, } /// One group of overlapping objects with transparency and what replaces it. struct Flat { /// Its objects in paint order; the result takes the place of the last. roots: Vec, - regions: Vec<(PathData, RegionFill)>, + regions: Regions, + /// Where its image goes, and the image (once rendered). + area: Option, raster: Option, } +/// Where a group's image goes. +struct Area { + rect: Rect, + ppi: f64, + /// The complex regions it is clipped to (Clip Complex Regions). + clip: Option, + /// The raster/vector balance rasterizes the whole group (it splits into too many regions). + whole: bool, +} + +impl Area { + /// The area's outline. + fn outline(&self) -> PathData { + self.clip.clone().unwrap_or_else(|| shapes::rectangle(self.rect)) + } +} + +/// Flat-colour regions: their outlines and paints. +type Regions = Vec<(PathData, RegionFill)>; + /// A flat-colour region's paint. struct RegionFill { paint: Paint, @@ -458,7 +776,9 @@ struct Raster { clip: Option, } -fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions) -> Plan { +/// The flattening of `roots` with `o`; `render` renders the images (the preview only needs where +/// they go). +fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: bool) -> Plan { let tmp = isolated_doc(src, roots.iter().filter_map(|id| src.node(*id).cloned()).collect()); // Geometry effects first: what is left on the art are raster effects. let baked = effects::bake_document(&tmp).unwrap_or(tmp); @@ -466,26 +786,28 @@ fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions) -> Plan { let mut sc = Scratch { brushes: vectorcraft_brush::library(&baked), doc: baked.clone() }; let plain: Vec = art.iter().map(|n| sc.plain(n)).collect(); let reaches: Vec> = plain.iter().map(reach).collect(); - let mut out = Plan { flat: vec![], kept: vec![] }; + let see_through: Vec = plain.iter().map(transparent).collect(); + let mut out = Plan { flat: vec![], kept: vec![], transparent: roots.iter().zip(&see_through).filter(|(_, t)| **t).map(|(id, _)| *id).collect() }; for g in overlapping(&reaches) { let ids: Vec = g.iter().map(|&i| roots[i]).collect(); - let flat = g.iter().any(|&i| transparent(&plain[i])).then(|| { + let flat = g.iter().any(|&i| see_through[i]).then(|| { let art: Vec = g.iter().map(|&i| art[i].clone()).collect(); let plain: Vec<&Node> = g.iter().map(|&i| &plain[i]).collect(); - flatten_group(&baked, &art, &plain, o) + let (regions, area) = flatten_group(&plain, o)?; + let raster = area.as_ref().filter(|_| render).map(|a| render_raster(&baked, &art, a.rect, a.ppi, a.clip.clone(), o)); + Some(Flat { roots: ids.clone(), regions, area, raster }) }); match flat.flatten() { - Some((regions, raster)) => out.flat.push(Flat { roots: ids, regions, raster }), + Some(f) => out.flat.push(f), None => out.kept.extend(ids), } } out } -/// The regions and image replacing one group of overlapping objects (`art` as rendered, `plain` -/// as composited); `None` when it paints nothing. -#[allow(clippy::type_complexity)] -fn flatten_group(doc: &Document, art: &[Node], plain: &[&Node], o: &FlattenOptions) -> Option<(Vec<(PathData, RegionFill)>, Option)> { +/// The regions and the image area replacing one group of overlapping objects (`plain`: the art as +/// composited); `None` when it paints nothing. +fn flatten_group(plain: &[&Node], o: &FlattenOptions) -> Option<(Regions, Option)> { let mut b = Builder::default(); let elems: Vec = plain.iter().filter_map(|n| b.elem(n)).collect(); if b.shapes.is_empty() { @@ -529,13 +851,13 @@ fn flatten_group(doc: &Document, art: &[Node], plain: &[&Node], o: &FlattenOptio } } let raster_rect = if all { plain.iter().filter_map(|n| reach(n)).reduce(|a, b| a.union(b)) } else { bounds(&complex) }; - let raster = raster_rect.map(|rect| { + let area = raster_rect.map(|rect| { let clip = o.clip_complex_regions.then(|| PathData::new(complex.iter().flat_map(|&k| regions[k].path.subpaths.iter().cloned()).collect())); let ppi = if gradients_only { o.gradient_ppi } else { o.line_art_ppi }; - render_raster(doc, art, rect, ppi, clip.filter(|c| !c.is_empty()), o) + Area { rect, ppi, clip: clip.filter(|c| !c.is_empty()), whole: all } }); let vector = vector.into_iter().map(|(k, f)| (regions[k].path.clone(), f)).collect(); - Some((vector, raster)) + Some((vector, area)) } /// The flat colour of a region covered by `sources`; `None` where nothing paints it. @@ -727,14 +1049,18 @@ fn kept_work(n: &Node, o: &FlattenOptions) -> bool { let mut any = false; n.walk(&mut |c| { any |= (o.text_to_outlines && matches!(c.kind, NodeKind::Text(_))) - || (o.strokes_to_outlines - && matches!(c.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. }) - && c.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() && s.width > 0.0))) + || (o.strokes_to_outlines && stroked(c)) || (!o.preserve_overprints && overprints(c)); }); any } +/// Whether `n` is a path with a visible stroke (outlining turns it into a fill). +fn stroked(n: &Node) -> bool { + matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. }) + && n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() && s.width > 0.0)) +} + fn overprints(n: &Node) -> bool { n.appearance.items.iter().any(AppearanceItem::overprint) || matches!(&n.kind, NodeKind::Text(t) if t.runs.iter().any(|r| r.style.overprint_fill || r.style.overprint_stroke)) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 95a6d43f..96fbd8c8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -801,6 +801,8 @@ mod tests_file; #[cfg(test)] mod tests_flatpresets; #[cfg(test)] +mod tests_flatpreview; +#[cfg(test)] mod tests_flatten; #[cfg(test)] mod tests_freeform; diff --git a/crates/engine/src/tests_flatpreview.rs b/crates/engine/src/tests_flatpreview.rs new file mode 100644 index 00000000..cd7033dd --- /dev/null +++ b/crates/engine/src/tests_flatpreview.rs @@ -0,0 +1,123 @@ +//! Flattener Preview (M3.94): `flattener.preview` reports what flattening the document would touch, +//! without changing it. + +use serde_json::{Value, json}; + +use super::*; +use crate::cmd::flatten::Highlight; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap() +} + +/// An unstroked rectangle filled with `color`. +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, color: &str) -> u64 { + let id = run(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].as_u64().unwrap(); + run(s, "paint.setFill", json!({"color": color, "ids": [id]})); + run(s, "paint.setStroke", json!({"none": true, "ids": [id]})); + id +} + +fn preview(s: &mut Session, p: Value) -> Value { + run(s, "flattener.preview", p) +} + +fn ids(v: &Value) -> Vec { + v["regions"].as_array().unwrap().iter().filter_map(|r| r["id"].as_u64()).collect() +} + +#[test] +fn type_under_a_transparent_rectangle_counts_as_affected() { + let mut s = session(); + let text = run(&mut s, "text.create", json!({"x": 20, "y": 80, "text": "Under", "size": 48}))["id"].as_u64().unwrap(); + let top = rect(&mut s, 10.0, 30.0, 200.0, 80.0, "#3366cc"); + run(&mut s, "transparency.set", json!({"ids": [top], "opacity": 50})); + // Apart from them: an opaque stroked square nothing overlaps. + let alone = rect(&mut s, 220.0, 220.0, 40.0, 40.0, "#20a040"); + run(&mut s, "paint.setStroke", json!({"color": "#000000", "ids": [alone]})); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let v = preview(&mut s, json!({"highlight": "allAffected"})); + assert_eq!(ids(&v), [text, top], "the type and the rectangle over it"); + assert_eq!(v["counts"]["transparentObjects"], 1); + assert_eq!(ids(&preview(&mut s, json!({"highlight": "transparentObjects"}))), [top]); + assert_eq!(ids(&preview(&mut s, json!({"highlight": "outlinedText"}))), [text]); + assert!(v["counts"]["vectorRegions"].as_u64().unwrap() > 1); + assert_eq!(v["counts"]["allRasterized"], 0, "flat colours only"); + // The options decide what else changes: outlining every stroke takes in the lone square. + let v = preview(&mut s, json!({"highlight": "outlinedStrokes", "strokesToOutlines": true})); + assert_eq!(ids(&v), [alone]); + assert_eq!(v["counts"]["allAffected"], 3); + assert_eq!(v["options"]["strokesToOutlines"], true); + // Nothing changed. + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); +} + +#[test] +fn an_opaque_document_reports_nothing() { + let mut s = session(); + rect(&mut s, 10.0, 10.0, 100.0, 100.0, "#ff0000"); + rect(&mut s, 50.0, 50.0, 100.0, 100.0, "#0000ff"); + run(&mut s, "text.create", json!({"x": 20, "y": 200, "text": "Solid", "size": 30})); + for h in Highlight::ALL { + let v = preview(&mut s, json!({ "highlight": h.id(), "preset": "low" })); + assert!(v["regions"].as_array().unwrap().is_empty(), "{}", h.id()); + assert!(v["counts"].as_object().unwrap().values().all(|c| c == 0), "{}: {}", h.id(), v["counts"]); + assert_eq!(v["highlight"], h.id()); + } +} + +#[test] +fn rasterized_areas_follow_the_balance_and_raster_effects() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 100.0, 100.0, "#ff0000"); + let b = rect(&mut s, 60.0, 60.0, 100.0, 100.0, "#0000ff"); + run(&mut s, "transparency.set", json!({"ids": [b], "opacity": 50})); + // Balance 0 rasterizes the group whole: a complex region, also among all rasterized ones. + let v = preview(&mut s, json!({"highlight": "rasterizedRegions", "balance": 0})); + assert_eq!((v["counts"]["rasterizedRegions"].as_u64(), v["counts"]["allRasterized"].as_u64()), (Some(1), Some(1))); + let r = &v["regions"][0]["bounds"]; + assert!(r["x"].as_f64().unwrap() <= 10.0 && r["width"].as_f64().unwrap() >= 150.0, "{r}"); + // A raster effect rasterizes where it reaches, whatever the balance. + let e = rect(&mut s, 200.0, 200.0, 50.0, 50.0, "#20a040"); + run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [e]})); + let v = preview(&mut s, json!({"highlight": "allRasterized", "preset": "high"})); + assert_eq!((v["counts"]["rasterizedRegions"].as_u64(), v["counts"]["allRasterized"].as_u64()), (Some(0), Some(1))); + assert_eq!(v["counts"]["transparentObjects"], 2); + // Only some objects (`ids`), and the options in force. + let v = preview(&mut s, json!({"ids": [a], "highlight": "allAffected"})); + assert_eq!(v["counts"]["allAffected"], 0, "alone, the red square has no transparency"); + assert!(s.execute("flattener.preview", &json!({"highlight": "sparkles"})).is_err()); + assert!(s.execute("flattener.preview", &json!({"overprints": "maybe"})).is_err()); +} + +#[test] +fn overprints_and_patterns_are_reported() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0, "#ff0000"); + run(&mut s, "select.set", json!({"ids": [r]})); + run(&mut s, "object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})); + run(&mut s, "object.pattern.done", json!({})); + let p = rect(&mut s, 100.0, 100.0, 100.0, 100.0, "#000000"); + run(&mut s, "paint.setFill", json!({"ids": [p], "swatch": "Dots"})); + let top = rect(&mut s, 150.0, 150.0, 100.0, 100.0, "#ffcc00"); + run(&mut s, "transparency.set", json!({"ids": [top], "opacity": 60})); + let v = preview(&mut s, json!({"highlight": "expandedPatterns"})); + assert_eq!(ids(&v), [p]); + // An overprinting object alone: affected once overprints are discarded. + let op = rect(&mut s, 10.0, 220.0, 40.0, 40.0, "#00ffff"); + run(&mut s, "object.setOverprint", json!({"ids": [op], "fill": true})); + assert_eq!(preview(&mut s, json!({}))["counts"]["allAffected"], 2); + for o in ["discard", "simulate"] { + let v = preview(&mut s, json!({ "overprints": o, "highlight": "allAffected" })); + assert!(ids(&v).contains(&op), "{o}"); + assert_eq!(v["options"]["preserveOverprints"], false); + } +} diff --git a/crates/ui-egui/src/dialogs/flatten.rs b/crates/ui-egui/src/dialogs/flatten.rs index a87d6042..923f13b7 100644 --- a/crates/ui-egui/src/dialogs/flatten.rs +++ b/crates/ui-egui/src/dialogs/flatten.rs @@ -85,16 +85,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { sync(d, saved); let current = options(d, saved); let presets = app.session.flattener_presets(); - let shown = match ¤t { - Ok(o) => presets.iter().find(|p| p.name == d.str("preset") && p.options == *o).map_or(CUSTOM, |p| p.name.as_str()), - Err(_) => CUSTOM, - }; ui.horizontal(|ui| { widgets::dim_label(ui, "Preset:"); - let names: Vec<&str> = presets.iter().map(|p| p.name.as_str()).collect(); - if let Some(i) = widgets::dropdown(ui, "flatten-preset", shown, &names, 220.0) { - put_options(&mut d.fields, &presets[i].options); - applied(d, &presets[i].name); + if let Some(p) = preset_dropdown(ui, "flatten-preset", &presets, &d.str("preset"), current.as_ref().ok(), 220.0) { + put_options(&mut d.fields, &p.options); + applied(d, &p.name); } if widgets::flat_button(ui, "Save Preset…", 100.0).clicked() { let open = !d.bool(SAVING); @@ -157,6 +152,21 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { form::commit_preview(app, CMD, p) } +/// The flattener preset dropdown: `presets` by name, showing `name` while `options` are that +/// preset's, else [Custom]. Returns the preset chosen. +pub(crate) fn preset_dropdown<'a>( + ui: &mut egui::Ui, + id: &str, + presets: &'a [FlattenerPreset], + name: &str, + options: Option<&FlattenOptions>, + width: f32, +) -> Option<&'a FlattenerPreset> { + let shown = presets.iter().find(|p| p.name == name && Some(&p.options) == options).map_or(CUSTOM, |p| p.name.as_str()); + let names: Vec<&str> = presets.iter().map(|p| p.name.as_str()).collect(); + widgets::dropdown(ui, id, shown, &names, width).map(|i| &presets[i]) +} + /// The width of the option value fields. const FIELD: f32 = 56.0; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index dacd8170..66c35a03 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -271,6 +271,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{selected?: preset name} open the presets manager (dialog `flattenerPresets`, fields `selected`, then the selected preset's `name` and option keys: setting them on a saved preset saves it, a new `name` renames it; built-in presets don't change). New, Delete, Import… and Export… run flattener.presets.save / delete / import / export", ), + ( + "ui.flattenerPreview", + "Flattener Preview", + "", + "{highlight?: none|rasterizedRegions|transparentObjects|allAffected|expandedPatterns|outlinedStrokes|outlinedText|allRasterized, overprints?: preserve|simulate|discard, preset?: name (loads its options), options?: {option keys, over the preset's}, showOptions?} set the Flattener Preview panel (ui.inspect: ui.flattener_preview), show it and refresh its snapshot → what flattener.preview answers for it", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -627,6 +633,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::flattener_preview::command(app, p), _ => return None, }; Some(r) @@ -1359,6 +1366,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { panel("Color", "color"), panel("Color Guide", "colorGuide"), panel("Document Info", "docInfo"), + panel("Flattener Preview", crate::panels::flattener_preview::ID), panel("Gradient", "gradient"), panel("Graphic Styles", "graphicStyles"), panel("History", "history"), diff --git a/crates/ui-egui/src/panels/flattener_preview.rs b/crates/ui-egui/src/panels/flattener_preview.rs new file mode 100644 index 00000000..b06941fb --- /dev/null +++ b/crates/ui-egui/src/panels/flattener_preview.rs @@ -0,0 +1,489 @@ +//! Window → Flattener Preview: what flattening the document would touch (`flattener.preview`), +//! highlighted in red over a grey preview of the art. Refresh takes a new snapshot of the +//! document; Highlight picks what shows; Overprints, the preset and the options (Show Options in +//! the panel menu) set how it flattens. Click the preview to zoom in, Alt-click to zoom out, drag +//! to pan and double-click to fit. +//! +//! The settings live in `UiState::flattener_preview` (`ui.inspect`); `ui.flattenerPreview` sets +//! them and refreshes. + +use std::cell::RefCell; +use std::sync::Arc; + +use egui::{Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use vectorcraft_doc::Document; +use vectorcraft_engine::cmd::FlattenOptions; +use vectorcraft_engine::cmd::flatten::{FlattenReport, Highlight, Overprints}; +use vectorcraft_geom::{Affine, Point, Rect as DRect}; +use vectorcraft_render::RenderOptions; + +use super::navigator::{doc_to_thumb, thumb_to_doc}; +use crate::dialogs::flatten::{options_editor, preset_dropdown}; +use crate::theme::Tokens; +use crate::{VectorcraftApp, widgets}; + +/// The panel id (Window → Flattener Preview). +pub const ID: &str = "flattenerPreview"; + +/// The panel's settings (kept with the UI state). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Settings { + pub highlight: Highlight, + pub overprints: Overprints, + /// The preset the options came from. + pub preset: String, + pub options: FlattenOptions, + /// The options show under the preset (panel menu → Show Options). + pub show_options: bool, +} + +impl Default for Settings { + fn default() -> Self { + let preset = FlattenOptions::preset_label(FlattenOptions::PRESETS[1]).unwrap_or_default().to_string(); + Self { highlight: Highlight::None, overprints: Overprints::Preserve, preset, options: FlattenOptions::default(), show_options: false } + } +} + +impl Settings { + /// `flattener.preview` parameters for these settings. + fn params(&self) -> Value { + json!({ "options": self.options, "overprints": self.overprints.id() }) + } +} + +/// The document as the last Refresh saw it, and what flattening it would do. +struct Shot { + doc: Arc, + doc_index: usize, + revision: u64, + /// The settings it was taken with. + options: FlattenOptions, + overprints: Overprints, + report: FlattenReport, + /// What the preview shows at its widest: the artboards and the art. + fit: DRect, + /// Tells snapshots apart for the texture cache. + generation: u64, +} + +/// The preview's zoom (1 = fit) and centre. +#[derive(Clone, Copy, Default)] +struct View { + zoom: f64, + center: Option, +} + +impl View { + /// The part of `fit` shown. + fn visible(&self, fit: DRect) -> DRect { + let z = self.zoom.max(1.0); + let c = self.center.unwrap_or(fit.center()); + DRect::from_center_size(c, (fit.width() / z, fit.height() / z)) + } + + /// Zoom by `factor` (1×–64×) keeping `at` under the pointer, panned within `fit`. + fn zoom_at(&mut self, fit: DRect, at: Point, factor: f64) { + let v = self.visible(fit); + let z = (self.zoom.max(1.0) * factor).clamp(1.0, 64.0); + let k = self.zoom.max(1.0) / z; + let c = v.center(); + self.zoom = z; + self.center = Some(Point::new(at.x + (c.x - at.x) * k, at.y + (c.y - at.y) * k)); + self.pan(fit, (0.0, 0.0)); + } + + /// Move the centre by `d` (document units), keeping the view inside `fit`. + fn pan(&mut self, fit: DRect, d: (f64, f64)) { + let v = self.visible(fit); + let (hw, hh) = (v.width() / 2.0, v.height() / 2.0); + let c = v.center(); + let x = (c.x + d.0).clamp(fit.x0 + hw, (fit.x1 - hw).max(fit.x0 + hw)); + let y = (c.y + d.1).clamp(fit.y0 + hh, (fit.y1 - hh).max(fit.y0 + hh)); + self.center = Some(Point::new(x, y)); + } +} + +/// What the preview texture shows: (generation, highlight, simulated overprints, the visible rect's +/// bits, its pixel size). +type Key = (u64, Highlight, bool, [u64; 4], [u32; 2]); + +#[derive(Default)] +struct Cache { + shot: Option, + view: View, + generation: u64, + renderer: Option, + key: Option, + tex: Option, +} + +thread_local! { + static CACHE: RefCell = RefCell::new(Cache::default()); +} + +/// Take a new snapshot of the active document and work out what flattening it would do → +/// `flattener.preview`'s answer for the panel's highlight. +pub fn refresh(app: &mut VectorcraftApp) -> Result { + let set = &app.ui.flattener_preview; + let (o, report) = app.session.flattener_report(&set.params()).map_err(|e| e.to_string())?; + let (options, overprints) = (set.options.clone(), set.overprints); + let (st, doc_index) = (app.session.active().ok_or("no document open")?, app.session.active_index().unwrap_or(0)); + let mut out = report.to_json(&st.doc, set.highlight); + out["options"] = serde_json::to_value(&o).unwrap_or_default(); + let mut fit = st.doc.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b)).unwrap_or(DRect::new(0.0, 0.0, 612.0, 792.0)); + if let Some(b) = st.doc.art_bounds() { + fit = fit.union(b); + } + let fit = fit.inflate(fit.width() * 0.03 + 4.0, fit.height() * 0.03 + 4.0); + let (doc, revision) = (st.doc.clone(), st.revision); + CACHE.with(|c| { + let mut c = c.borrow_mut(); + c.generation += 1; + c.shot = Some(Shot { doc, doc_index, revision, options, overprints, report, fit, generation: c.generation }); + c.view = View::default(); + }); + Ok(out) +} + +/// `ui.flattenerPreview {highlight?, overprints?, preset?, options?, showOptions?}`: set the panel's +/// settings (a preset loads its options, then `options` adjust them), show the panel and refresh. +pub(crate) fn command(app: &mut VectorcraftApp, p: &Value) -> Result { + let mut set = app.ui.flattener_preview.clone(); + if let Some(h) = p.get("highlight").and_then(Value::as_str) { + set.highlight = Highlight::parse(h).ok_or_else(|| format!("unknown highlight `{h}`"))?; + } + if let Some(o) = p.get("overprints").and_then(Value::as_str) { + set.overprints = Overprints::parse(o).ok_or_else(|| format!("unknown overprints `{o}` (preserve, simulate or discard)"))?; + } + if let Some(name) = p.get("preset").and_then(Value::as_str) { + let preset = app.session.flattener_preset(name).ok_or_else(|| format!("unknown preset `{name}` (see flattener.presets.list)"))?; + (set.preset, set.options) = (preset.name, preset.options); + } + if let Some(o) = p.get("options") { + set.options = app.session.flatten_options(&json!({ "options": merged(&set.options, o) }))?; + } + if let Some(b) = p.get("showOptions").and_then(Value::as_bool) { + set.show_options = b; + } + app.ui.flattener_preview = set; + app.ui.open_panel = Some(ID.into()); + app.ui.dock = true; + refresh(app) +} + +/// `base` with the keys of `over` set. +fn merged(base: &FlattenOptions, over: &Value) -> Value { + let mut v = serde_json::to_value(base).unwrap_or_default(); + if let (Some(m), Some(o)) = (v.as_object_mut(), over.as_object()) { + m.extend(o.iter().map(|(k, x)| (k.clone(), x.clone()))); + } + v +} + +pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.active() else { + super::empty_state(ui, "eye", "No document", "Open a document to preview its flattening."); + return; + }; + let (index, revision) = (app.session.active_index().unwrap_or(0), st.revision); + // A document the panel hasn't seen yet gets a snapshot right away. + let seen = CACHE.with(|c| c.borrow().shot.as_ref().map(|s| s.doc_index)); + if seen != Some(index) + && let Err(e) = refresh(app) + { + app.status(e); + } + let mut set = app.ui.flattener_preview.clone(); + let mut refresh_now = false; + ui.horizontal(|ui| { + if widgets::flat_button(ui, "Refresh", 64.0).on_hover_text("Preview the document as it is now").clicked() { + refresh_now = true; + } + let labels = Highlight::ALL.map(Highlight::label); + if let Some(i) = widgets::dropdown(ui, "fp-highlight", set.highlight.label(), &labels, ui.available_width().max(120.0)) { + set.highlight = Highlight::ALL[i]; + } + }); + ui.add_space(4.0); + egui::Grid::new("fp-settings").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + widgets::dim_label(ui, "Overprints:"); + let labels = Overprints::ALL.map(Overprints::label); + if let Some(i) = widgets::dropdown(ui, "fp-overprints", set.overprints.label(), &labels, 150.0) { + set.overprints = Overprints::ALL[i]; + } + ui.end_row(); + widgets::dim_label(ui, "Preset:"); + let presets = app.session.flattener_presets(); + if let Some(p) = preset_dropdown(ui, "fp-preset", &presets, &set.preset, Some(&set.options), 150.0) { + set.preset = p.name.clone(); + set.options = p.options.clone(); + } + ui.end_row(); + }); + if set.show_options { + widgets::divider(ui); + options_editor(ui, "flattener-preview", &mut set.options, true); + } + ui.add_space(6.0); + preview_area(ui, &t, set.highlight, set.overprints); + // What is highlighted, and whether the snapshot is behind the document or the settings. + let (count, stale) = CACHE.with(|c| { + let c = c.borrow(); + let s = c.shot.as_ref(); + let count = s.map_or(0, |s| s.report.objects(set.highlight).len() + s.report.areas(set.highlight).len()); + (count, s.is_some_and(|s| s.revision != revision || s.options != set.options || s.overprints != set.overprints)) + }); + if set.highlight != Highlight::None { + let what = if matches!(set.highlight, Highlight::RasterizedRegions | Highlight::AllRasterized) { "area" } else { "object" }; + widgets::dim_label(ui, &format!("{count} {what}{} highlighted", if count == 1 { "" } else { "s" })); + } + if stale { + ui.label(egui::RichText::new("Changed since the last Refresh.").color(t.text_dim).size(11.5)); + } + if set != app.ui.flattener_preview { + app.ui.flattener_preview = set; + } + if refresh_now && let Err(e) = refresh(app) { + app.status(e); + } +} + +/// The preview: the snapshot rendered over the visible part, grey under a highlight with the +/// highlighted art in `t.highlight`; click, Alt-click, drag and double-click zoom and pan. +fn preview_area(ui: &mut Ui, t: &Tokens, h: Highlight, overprints: Overprints) { + let w = ui.available_width(); + let (rect, resp) = ui.allocate_exact_size(vec2(w, (w * 0.8).clamp(140.0, 320.0)), Sense::click_and_drag()); + let resp = resp.on_hover_text("Click to zoom in, Alt-click to zoom out, drag to pan, double-click to fit"); + ui.painter().rect_filled(rect, 0.0, t.pasteboard); + let ppp = ui.ctx().pixels_per_point(); + CACHE.with(|c| { + let mut c = c.borrow_mut(); + let Cache { shot, view, renderer, key, tex, .. } = &mut *c; + let Some(shot) = shot.as_ref() else { return }; + let visible = view.visible(shot.fit); + if resp.double_clicked() { + *view = View::default(); + } else if resp.clicked() + && let Some(p) = resp.interact_pointer_pos() + { + let alt = ui.input(|i| i.modifiers.alt); + view.zoom_at(shot.fit, thumb_to_doc(visible, rect, p), if alt { 0.5 } else { 2.0 }); + } else if resp.dragged() { + let per_px = (visible.width() / rect.width() as f64).max(visible.height() / rect.height() as f64); + let d = resp.drag_delta(); + view.pan(shot.fit, (-d.x as f64 * per_px, -d.y as f64 * per_px)); + } + let visible = view.visible(shot.fit); + let px = [(rect.width() * ppp) as u32, (rect.height() * ppp) as u32]; + let k = (shot.generation, h, overprints == Overprints::Simulate, [visible.x0, visible.y0, visible.x1, visible.y1].map(f64::to_bits), px); + if *key != Some(k) || tex.is_none() { + let r = renderer.get_or_insert_with(vectorcraft_render::Renderer::new); + let img = render(r, shot, visible, px, h, overprints, t.highlight); + match tex { + Some(tx) => tx.set(img, egui::TextureOptions::LINEAR), + None => *tex = Some(ui.ctx().load_texture("flattener-preview", img, egui::TextureOptions::LINEAR)), + } + *key = Some(k); + } + if let Some(tx) = tex { + let r = + Rect::from_min_max(doc_to_thumb(visible, rect, visible.origin()), doc_to_thumb(visible, rect, Point::new(visible.x1, visible.y1))); + ui.painter().with_clip_rect(rect).image(tx.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), egui::Color32::WHITE); + } + }); + ui.painter().rect_stroke(rect, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); +} + +/// `visible` of the snapshot at most `px` pixels: in colour without a highlight, else in grey with +/// the highlighted art in `color`. +fn render( + r: &mut vectorcraft_render::Renderer, + shot: &Shot, + visible: DRect, + px: [u32; 2], + h: Highlight, + overprints: Overprints, + color: egui::Color32, +) -> egui::ColorImage { + let s = (px[0] as f64 / visible.width()).min(px[1] as f64 / visible.height()); + let (w, hh) = ((visible.width() * s).round().max(1.0) as u32, (visible.height() * s).round().max(1.0) as u32); + let view = Affine::scale(s) * Affine::translate((-visible.x0, -visible.y0)); + let opts = RenderOptions { + background: Some([255; 4]), + skip_templates: true, + overprint_preview: overprints == Overprints::Simulate, + ..Default::default() + }; + let mut img = r.render(&shot.doc, w, hh, view, &opts); + if h != Highlight::None { + let mask = shot + .report + .highlight_art(&shot.doc, h) + .map(|art| r.render(&art, w, hh, view, &RenderOptions { skip_templates: true, ..Default::default() })); + let red = [color.r(), color.g(), color.b()].map(f32::from); + for (i, p) in img.pixels.chunks_exact_mut(4).enumerate() { + // A light grey of the art, so the highlight stands out. + let luma = 0.299 * p[0] as f32 + 0.587 * p[1] as f32 + 0.114 * p[2] as f32; + let grey = 96.0 + luma * 0.62; + let a = mask.as_ref().map_or(0.0, |m| m.pixels[i * 4 + 3] as f32 / 255.0); + for c in 0..3 { + p[c] = (grey + (red[c] - grey) * a).round() as u8; + } + } + } + egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels) +} + +pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { + if widgets::menu_item(ui, "Refresh", app.session.active().is_some(), false) + && let Err(e) = refresh(app) + { + app.status(e); + } + let shown = app.ui.flattener_preview.show_options; + if widgets::menu_item(ui, "Show Options", true, shown) { + app.ui.flattener_preview.show_options = !shown; + } + if widgets::menu_item(ui, "Fit in Window", true, false) { + CACHE.with(|c| c.borrow_mut().view = View::default()); + } + if widgets::menu_item(ui, "Save Transparency Flattener Preset…", true, false) { + // Saved under a new name, then shown in the presets manager to name it. + match app.run("flattener.presets.save", json!({ "options": app.ui.flattener_preview.options })) { + Ok(r) => { + let name = r["name"].as_str().unwrap_or_default().to_string(); + app.ui.flattener_preview.preset = name.clone(); + let _ = app.run("ui.flattenerPresetsDialog", json!({ "selected": name })); + } + Err(e) => app.status(e), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + /// A half-transparent blue rectangle over type, and a green square apart. + fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app.run("text.create", json!({"x": 20, "y": 80, "text": "Under", "size": 48})).unwrap(); + let top = app.run("shape.rectangle", json!({"x": 10, "y": 30, "width": 200, "height": 80})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"color": "#3366cc", "ids": [top]})).unwrap(); + app.run("transparency.set", json!({"ids": [top], "opacity": 50})).unwrap(); + app.run("shape.rectangle", json!({"x": 240, "y": 140, "width": 40, "height": 40})).unwrap(); + app + } + + /// Two headless frames of the panel (the texture uploads in the first). + fn frame(app: &mut VectorcraftApp) -> egui::Context { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + for _ in 0..2 { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); + out.textures_delta.clear(); + } + ctx + } + + fn pixels() -> egui::ColorImage { + CACHE.with(|c| { + let mut c = c.borrow_mut(); + let Cache { shot, view, renderer, .. } = &mut *c; + let shot = shot.as_ref().unwrap(); + render( + renderer.get_or_insert_with(vectorcraft_render::Renderer::new), + shot, + view.visible(shot.fit), + [300, 200], + Highlight::None, + Overprints::Preserve, + egui::Color32::RED, + ) + }) + } + + #[test] + fn the_panel_takes_a_snapshot_and_highlights_in_red() { + let mut app = app(); + frame(&mut app); + assert!(CACHE.with(|c| c.borrow().shot.is_some()), "a first look refreshes"); + assert!(CACHE.with(|c| c.borrow().tex.is_some()), "the preview is drawn"); + let v = command(&mut app, &json!({"highlight": "allAffected"})).unwrap(); + assert_eq!(v["counts"]["allAffected"], 2); + assert_eq!(app.ui.open_panel.as_deref(), Some(ID)); + let text = crate::tests_labels::painted_text(&mut app, show); + assert!(text.contains("2 objects highlighted") && text.contains("All Affected Objects"), "{text}"); + // The highlight is red where the art is affected and grey elsewhere. + let (shot_fit, img) = CACHE.with(|c| { + let mut c = c.borrow_mut(); + let Cache { shot, renderer, .. } = &mut *c; + let shot = shot.as_ref().unwrap(); + let fit = DRect::new(0.0, 0.0, 300.0, 200.0); + ( + shot.fit, + render( + renderer.get_or_insert_with(vectorcraft_render::Renderer::new), + shot, + fit, + [300, 200], + Highlight::AllAffected, + Overprints::Preserve, + egui::Color32::RED, + ), + ) + }); + assert!(shot_fit.contains(Point::new(300.0, 200.0))); + let at = |x: usize, y: usize| img.pixels[y * img.width() + x]; + assert_eq!(at(100, 60), egui::Color32::RED, "inside the transparent rectangle"); + let apart = at(260, 160); + assert!(apart.r() == apart.g() && apart.g() == apart.b(), "the lone square is grey: {apart:?}"); + // Without a highlight it is in colour. + let plain = pixels(); + assert!(plain.pixels.iter().any(|p| i32::from(p.b()) > i32::from(p.r()) + 40), "the blue shows"); + } + + #[test] + fn edits_wait_for_refresh_and_settings_drive_the_report() { + let mut app = app(); + frame(&mut app); + let text = crate::tests_labels::painted_text(&mut app, show); + assert!(!text.contains("Changed since"), "{text}"); + app.run("select.all", json!({})).unwrap(); + app.run("transparency.set", json!({"opacity": 100})).unwrap(); + let text = crate::tests_labels::painted_text(&mut app, show); + assert!(text.contains("Changed since the last Refresh."), "{text}"); + let v = command(&mut app, &json!({"highlight": "transparentObjects"})).unwrap(); + assert_eq!(v["counts"]["transparentObjects"], 0, "an opaque document"); + // A preset and options: balance 0 rasterizes; the dropdown then reads [Custom]. + let v = command(&mut app, &json!({"preset": "low", "options": {"balance": 0}, "showOptions": true})).unwrap(); + assert_eq!((v["options"]["balance"].as_f64(), v["options"]["gradientPpi"].as_f64()), (Some(0.0), Some(150.0))); + assert!(app.ui.flattener_preview.show_options); + let text = crate::tests_labels::painted_text(&mut app, show); + assert!(text.contains("[Custom]") && text.contains("Raster/Vector Balance:"), "{text}"); + assert!(command(&mut app, &json!({"highlight": "nope"})).is_err()); + // The settings persist with the UI state. + let back: crate::UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap(); + assert_eq!(back.flattener_preview, app.ui.flattener_preview); + } + + #[test] + fn zoom_and_pan_stay_inside_the_document() { + let fit = DRect::new(0.0, 0.0, 200.0, 100.0); + let mut v = View::default(); + assert_eq!(v.visible(fit), fit); + v.zoom_at(fit, Point::new(50.0, 25.0), 2.0); + let r = v.visible(fit); + assert!((r.width() - 100.0).abs() < 1e-9 && r.x0 >= 0.0 && r.y0 >= 0.0, "{r:?}"); + v.pan(fit, (-1000.0, 0.0)); + assert_eq!(v.visible(fit).x0, 0.0); + v.zoom_at(fit, Point::new(0.0, 0.0), 0.25); + assert_eq!(v.visible(fit), fit, "never smaller than fit"); + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 3275eb3f..2e88821a 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -12,6 +12,7 @@ pub mod character; pub mod color; pub mod color_guide; pub mod doc_info; +pub mod flattener_preview; pub mod glyphs; pub mod gradient; pub mod graphic_styles; @@ -88,6 +89,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { "paraStyles" => text_styles::show(app, ui, text_styles::Kind::Para), "magicWand" => magic_wand::show(app, ui), "tabs" => tabs::show(app, ui), + flattener_preview::ID => flattener_preview::show(app, ui), _ => { dim_label(ui, "This panel is on the roadmap (see the parity plan)."); } @@ -126,6 +128,7 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { "paraStyles" => text_styles::menu(app, ui, text_styles::Kind::Para), "magicWand" => magic_wand::menu(app, ui), "tabs" => tabs::menu(app, ui), + flattener_preview::ID => flattener_preview::menu(app, ui), _ => { ui.add_enabled(false, egui::Button::new("No options").frame(false)); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 0e1c38e9..a16b4111 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -88,6 +88,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("separations", "Separations Preview", "printer"), ("imageTrace", "Image Trace", "image"), ("magicWand", "Magic Wand", "wand-sparkles"), + (crate::panels::flattener_preview::ID, "Flattener Preview", "eye"), ]; /// Groups of icon panels separated by dividers in the collapsed column. @@ -252,6 +253,9 @@ pub struct UiState { /// The library open in the library panel (Window → Swatch Libraries). #[serde(default)] pub library_panel: Option, + /// Flattener Preview panel: highlight, overprints, preset and options (`ui.flattenerPreview`). + #[serde(default)] + pub flattener_preview: crate::panels::flattener_preview::Settings, } impl UiState { @@ -305,6 +309,7 @@ impl Default for UiState { engine_prefs: Value::Null, color_guide: Default::default(), library_panel: None, + flattener_preview: Default::default(), } } } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 14d7b398..a57a36e5 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -81,6 +81,8 @@ pub struct Tokens { /// Window Close button (custom title bar) when hovered, and its glyph there. pub caption_close: Color32, pub caption_close_text: Color32, + /// What preview images mark (Flattener Preview highlights). + pub highlight: Color32, } impl Tokens { @@ -121,6 +123,7 @@ impl Tokens { font_size: 13.0, caption_close: hex(0xc42b1c), caption_close_text: Color32::WHITE, + highlight: hex(0xff3030), }; match b { Brightness::Dark => base, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4e3e2353..0b96f112 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -122,3 +122,9 @@ built-in presets don't change. New, Delete, Import… and Export… are `flatten `flattener.presets.delete`, `flattener.presets.import` and `flattener.presets.export`; `ui.dialog.confirm` closes. Opening a `.vcflattener` file with `app.open` imports its presets. In the Flatten Transparency dialog, Save Preset… keeps the dialog's options as a saved preset. + +Flattener Preview: `window.panel {panel: "flattenerPreview"}` shows the panel; `ui.flattenerPreview {highlight?, +overprints?, preset?, options?, showOptions?}` sets it (a preset loads its options, `options` adjust them), shows it +and refreshes its snapshot of the document, answering as `flattener.preview` does. `ui.inspect` reports the settings +as `ui.flattener_preview`. The panel keeps its snapshot until Refresh (it notes when the document or the settings +changed since); its preview greys the art and paints the highlighted objects or areas red. diff --git a/docs/mcp.md b/docs/mcp.md index 047f7601..718fa6a4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -473,3 +473,15 @@ the preferences and work as `preset` in `object.flattenTransparency` (and anywhe {"name":"run_command","arguments":{"command":"flattener.presets.save","params":{"name":"Press","preset":"high","balance":60}}} {"name":"run_command","arguments":{"command":"object.flattenTransparency","params":{"preset":"Press"}}} ``` + +## Flattener Preview + +`flattener.preview {highlight?, overprints?, preset?, …options, ids?}` reports what flattening the document (or +`ids`) would do without changing it: `counts` of transparent objects, all affected objects, patterns, outlined +strokes and type, rasterized complex regions (areas the raster/vector balance rasterizes whole), all rasterized areas +and flat-colour regions, plus `regions` (bounds, and `id` for objects) for the `highlight` asked for. `overprints: +"discard"` or `"simulate"` flatten without preserving overprints. + +```json +{"name":"run_command","arguments":{"command":"flattener.preview","params":{"highlight":"allRasterized","preset":"low"}}} +```