mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
M3.94: Window > Flattener Preview shows what flattening would rasterize, outline and change
flattener.preview {highlight, overprints?, preset?|options?, ids?} reports, without changing the
document, the transparent and affected objects, patterns, outlined strokes and type, rasterized
complex regions, all rasterized areas and flat-colour regions (FlattenReport, worked out by the
flattener's own plan without rendering images). The panel previews a snapshot taken on Refresh,
greyed with the chosen highlight in red, zoomable (click, Alt-click, drag, double-click); its
Overprints, preset and options (Show Options) set how it flattens, and its menu saves them as a
preset. ui.flattenerPreview drives it; the settings persist with the UI state.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
1dc158cafe
commit
953024cc35
11 files changed
+1017
-30
No files matched your search
@@ -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<CommandSpec> {
|
||||
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<Value> {
|
||||
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::<Vec<_>>(), "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<FlattenerPreset> {
|
||||
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<FlattenOptions, String> {
|
||||
@@ -341,12 +360,9 @@ fn presets_export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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::<Result<Vec<_>>>()?
|
||||
}
|
||||
@@ -425,6 +441,284 @@ fn target_roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
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<Self> {
|
||||
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> {
|
||||
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<NodeId>,
|
||||
/// Top-level objects flattening changes: the overlapping groups with transparency, and those
|
||||
/// the outline and overprint options change.
|
||||
pub affected: Vec<NodeId>,
|
||||
/// Objects with pattern paint in the flattened art.
|
||||
pub patterns: Vec<NodeId>,
|
||||
/// Paths whose strokes become filled outlines.
|
||||
pub strokes: Vec<NodeId>,
|
||||
/// Type that becomes outlines.
|
||||
pub text: Vec<NodeId>,
|
||||
/// Areas the raster/vector balance rasterizes whole.
|
||||
pub complex: Vec<PathData>,
|
||||
/// Every area that becomes an image.
|
||||
pub rasterized: Vec<PathData>,
|
||||
/// 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::<Vec<_>>(), "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<Document> {
|
||||
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<Node> = 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<u64> = 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<Value> {
|
||||
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<Flat>,
|
||||
/// Objects without transparency (they stay, but for the outline options).
|
||||
kept: Vec<NodeId>,
|
||||
/// The objects with transparency of their own.
|
||||
transparent: Vec<NodeId>,
|
||||
}
|
||||
|
||||
/// 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<NodeId>,
|
||||
regions: Vec<(PathData, RegionFill)>,
|
||||
regions: Regions,
|
||||
/// Where its image goes, and the image (once rendered).
|
||||
area: Option<Area>,
|
||||
raster: Option<Raster>,
|
||||
}
|
||||
|
||||
/// Where a group's image goes.
|
||||
struct Area {
|
||||
rect: Rect,
|
||||
ppi: f64,
|
||||
/// The complex regions it is clipped to (Clip Complex Regions).
|
||||
clip: Option<PathData>,
|
||||
/// 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<PathData>,
|
||||
}
|
||||
|
||||
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<Node> = art.iter().map(|n| sc.plain(n)).collect();
|
||||
let reaches: Vec<Option<Rect>> = plain.iter().map(reach).collect();
|
||||
let mut out = Plan { flat: vec![], kept: vec![] };
|
||||
let see_through: Vec<bool> = 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<NodeId> = 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<Node> = 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<Raster>)> {
|
||||
/// 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<Area>)> {
|
||||
let mut b = Builder::default();
|
||||
let elems: Vec<Elem> = 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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<u64> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Value, String> {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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<R
|
||||
crate::dialogs::flattener_presets::open(app, s("selected").as_deref());
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"ui.flattenerPreview" => crate::panels::flattener_preview::command(app, p),
|
||||
_ => return None,
|
||||
};
|
||||
Some(r)
|
||||
@@ -1359,6 +1366,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
||||
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"),
|
||||
|
||||
@@ -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<Document>,
|
||||
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<Point>,
|
||||
}
|
||||
|
||||
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<Shot>,
|
||||
view: View,
|
||||
generation: u64,
|
||||
renderer: Option<vectorcraft_render::Renderer>,
|
||||
key: Option<Key>,
|
||||
tex: Option<egui::TextureHandle>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static CACHE: RefCell<Cache> = 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<Value, String> {
|
||||
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<Value, String> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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<crate::panels::library_panel::OpenLibrary>,
|
||||
/// 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
+12
@@ -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"}}}
|
||||
```
|
||||
Reference in new issue
Block a user