mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 05:37:23 -06:00
Merge wp/F1
# Conflicts: # crates/doc/src/node.rs # crates/engine/src/cmd/appearance.rs # crates/engine/src/cmd/paint.rs # crates/ui-egui/src/chrome.rs # crates/ui-egui/src/panels/transparency.rs
This commit is contained in:
Generated
+1
@@ -4122,6 +4122,7 @@ dependencies = [
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-text",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+11
-2
@@ -76,13 +76,22 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Is `p` inside the region `clip` clips to ([`Node::clip_shapes`])? Text counts by its frame:
|
||||
/// glyph outlines need the font engine, above this crate.
|
||||
fn clip_contains(clip: &Node, p: Point) -> bool {
|
||||
let frame = |n: &Node| {
|
||||
let b = n.geometric_bounds()?;
|
||||
Some(Node::path(n.id, vectorcraft_geom::shapes::rectangle(b), Default::default()))
|
||||
};
|
||||
clip.clip_shapes(Some(&frame)).iter().any(|(bp, rule)| fill_contains(bp, *rule, p))
|
||||
}
|
||||
|
||||
fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId>) -> Option<Hit> {
|
||||
let children = parent.children()?;
|
||||
// A clip group only hits inside its clipping path.
|
||||
if let NodeKind::Group { clip: true, .. } = parent.kind
|
||||
&& let Some(clip) = children.first()
|
||||
&& let Some(path) = clip.path_data()
|
||||
&& !fill_contains(&path.to_bezpath(), vectorcraft_geom::FillRule::NonZero, p)
|
||||
&& !clip_contains(clip, p)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
+24
-2
@@ -325,8 +325,9 @@ pub struct Document {
|
||||
/// Pattern editing mode, while active.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pattern_edit: Option<PatternEdit>,
|
||||
/// Opacity-mask editing mode, while active.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
/// Opacity-mask editing mode, while active. Never saved (see [`Document::without_edit_modes`]);
|
||||
/// still read so files saved mid-edit by older versions load without the editing layer.
|
||||
#[serde(default, skip_serializing)]
|
||||
pub mask_edit: Option<MaskEdit>,
|
||||
next_id: u64,
|
||||
/// Foreign data preserved on round-trip.
|
||||
@@ -611,6 +612,27 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Leave opacity-mask editing: drop the temporary editing layer, a working copy of art the
|
||||
/// mask already holds (the engine syncs it after every edit).
|
||||
pub fn drop_edit_modes(&mut self) {
|
||||
if let Some(me) = self.mask_edit.take() {
|
||||
self.layers.retain(|l| l.id != me.layer);
|
||||
}
|
||||
}
|
||||
|
||||
/// The document as saved and exported: without the opacity-mask editing layer. Borrowed when
|
||||
/// no mask is being edited. (Pattern editing is saved: its tile layer holds unapplied edits.)
|
||||
pub fn without_edit_modes(&self) -> std::borrow::Cow<'_, Document> {
|
||||
if self.mask_edit.is_none() {
|
||||
return std::borrow::Cow::Borrowed(self);
|
||||
}
|
||||
let mut d = self.clone();
|
||||
d.drop_edit_modes();
|
||||
std::borrow::Cow::Owned(d)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_graphic_styles() -> Vec<GraphicStyle> {
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
vec![
|
||||
|
||||
+68
-2
@@ -4,10 +4,10 @@ use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::BlendMode;
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, shapes};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, shapes};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::live::{BlendSpec, EnvelopeKind, GradientMesh};
|
||||
use crate::live::{BlendSpec, EnvelopeKind, GradientMesh, Outliner};
|
||||
use crate::pattern::RepeatSpec;
|
||||
use crate::text::TextObject;
|
||||
|
||||
@@ -569,6 +569,72 @@ impl Node {
|
||||
}
|
||||
}
|
||||
|
||||
/// Unites filled regions (each under its own fill rule) into one path filled non-zero. Booleans
|
||||
/// live above this crate (`vectorcraft-pathops`), so callers supply it.
|
||||
pub type Uniter<'a> = &'a dyn Fn(&[(BezPath, FillRule)]) -> BezPath;
|
||||
|
||||
impl Node {
|
||||
/// The filled regions this object clips to as the clipping path of a clip group, in document
|
||||
/// space, each with its fill rule: a path, a compound path (with its holes), an image's frame,
|
||||
/// text outlined by `text` (glyph outlines need the font engine, above this crate), the visible
|
||||
/// members of a group (their union) and the clipping path of a nested clip group. Live objects
|
||||
/// are evaluated first. Guides and symbol instances add nothing.
|
||||
pub fn clip_shapes(&self, text: Outliner) -> Vec<(BezPath, FillRule)> {
|
||||
let mut out = vec![];
|
||||
self.push_clip_shapes(text, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn push_clip_shapes(&self, text: Outliner, out: &mut Vec<(BezPath, FillRule)>) {
|
||||
match &self.kind {
|
||||
NodeKind::Path { guide: true, .. } | NodeKind::SymbolInstance { .. } => {}
|
||||
NodeKind::Path { path, rule, .. } => out.push((path.to_bezpath(), *rule)),
|
||||
NodeKind::Compound { children, rule } => {
|
||||
let mut bp = BezPath::new();
|
||||
for p in children.iter().filter_map(|c| c.path_data()) {
|
||||
bp.extend(p.to_bezpath());
|
||||
}
|
||||
out.push((bp, *rule));
|
||||
}
|
||||
NodeKind::Group { children, clip: true } => {
|
||||
if let Some(c) = children.first() {
|
||||
c.push_clip_shapes(text, out);
|
||||
}
|
||||
}
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
|
||||
for c in children.iter().filter(|c| c.visible) {
|
||||
c.push_clip_shapes(text, out);
|
||||
}
|
||||
}
|
||||
NodeKind::Text(_) => {
|
||||
if let Some(o) = text.and_then(|f| f(self)) {
|
||||
o.push_clip_shapes(None, out);
|
||||
}
|
||||
}
|
||||
NodeKind::Image(im) => {
|
||||
let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf);
|
||||
out.push((frame.to_bezpath(), FillRule::NonZero));
|
||||
}
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
|
||||
crate::live::expand_deep(self, text).push_clip_shapes(text, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The region this object clips to as one path and fill rule ([`Self::clip_shapes`]), shared
|
||||
/// by the renderer and the SVG and PDF writers so every output clips alike. One shape keeps its
|
||||
/// own rule; several are united by `unite` (filled non-zero). `None` when there is nothing to
|
||||
/// clip by: the clipped art is then hidden.
|
||||
pub fn clip_outline(&self, text: Outliner, unite: Uniter) -> Option<(BezPath, FillRule)> {
|
||||
let mut shapes = self.clip_shapes(text);
|
||||
match shapes.len() {
|
||||
0 => None,
|
||||
1 => shapes.pop(),
|
||||
_ => Some((unite(&shapes), FillRule::NonZero)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)?
|
||||
fn keeps_gradient_fit(a: Affine) -> bool {
|
||||
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
|
||||
|
||||
@@ -12,6 +12,7 @@ workspace = true
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
vectorcraft-pathops = { workspace = true }
|
||||
vectorcraft-text = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
//! The region a clip group clips to ([`Node::clip_outline`]) with text outlined by the font engine
|
||||
//! and several shapes united, so the renderer and the SVG and PDF writers clip alike.
|
||||
|
||||
use vectorcraft_doc::{Appearance, Node, NodeKind};
|
||||
use vectorcraft_geom::{BezPath, FillRule, PathData};
|
||||
|
||||
/// The clipping path `clip` of a clip group as one path and fill rule: compound paths keep their
|
||||
/// holes, even-odd paths their rule, text clips by its glyph outlines and a group by the union of
|
||||
/// its members. `None` when there is nothing to clip by (the clipped art is then hidden).
|
||||
pub fn clip_outline(clip: &Node) -> Option<(BezPath, FillRule)> {
|
||||
clip.clip_outline(Some(&outline_text), &unite)
|
||||
}
|
||||
|
||||
/// Text as one path of its glyph outlines, in document space.
|
||||
fn outline_text(n: &Node) -> Option<Node> {
|
||||
let NodeKind::Text(t) = &n.kind else { return None };
|
||||
let mut bp = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).to_bezpath();
|
||||
bp.apply_affine(t.xf);
|
||||
Some(Node::path(n.id, PathData::from_bezpath(&bp), Appearance::default()))
|
||||
}
|
||||
|
||||
fn unite(shapes: &[(BezPath, FillRule)]) -> BezPath {
|
||||
let paths: Vec<(PathData, FillRule)> = shapes.iter().map(|(bp, r)| (PathData::from_bezpath(bp), *r)).collect();
|
||||
let refs: Vec<(&PathData, FillRule)> = paths.iter().map(|(p, r)| (p, *r)).collect();
|
||||
vectorcraft_pathops::unite_all(&refs).to_bezpath()
|
||||
}
|
||||
@@ -12,12 +12,15 @@
|
||||
//! renderer, the exporters and Outline Stroke.
|
||||
//! - [`effect_catalog`] lists every effect with its menu path, parameter documentation and the
|
||||
//! defaults of Illustrator's dialogs. Missing parameters always fall back to those defaults.
|
||||
//! - [`clip_outline`] is the region a clip group clips to, shared by the renderer and the SVG and
|
||||
//! PDF writers.
|
||||
//!
|
||||
//! Everything is deterministic: "random" effects (Roughen, Tweak, Scribble) use a seeded hash
|
||||
//! noise (`seed` parameter, default 0).
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod bake;
|
||||
mod clip;
|
||||
mod distort;
|
||||
mod group;
|
||||
mod raster;
|
||||
@@ -34,6 +37,7 @@ use vectorcraft_doc::Effect;
|
||||
use vectorcraft_geom::{BezPath, PathData, Rect};
|
||||
|
||||
pub use bake::{bake_document, needs_bake};
|
||||
pub use clip::clip_outline;
|
||||
pub use group::{OutlineHook, PATHFINDER_EFFECTS, has_pathfinder, is_pathfinder, pathfinder_children};
|
||||
pub use raster::{RasterFx, outset, raster_effects};
|
||||
pub use warp::{WarpStyle, warp_point};
|
||||
|
||||
@@ -14,6 +14,7 @@ use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, NodeKind, StrokeLayer};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::opacitymask::percent;
|
||||
use super::paint::paint_from;
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
@@ -61,8 +62,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Appearance Item",
|
||||
[],
|
||||
None,
|
||||
"{index: paint-order item index, opacity?: 0..1 (values above 1 are percent), blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), ids?} edit one fill/stroke of each selected object's own appearance stack",
|
||||
has_selection,
|
||||
"{index: paint-order item index, ids? (default: the selection), opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill)} edit one fill/stroke of each target object's own appearance stack",
|
||||
has_doc,
|
||||
set_item
|
||||
),
|
||||
cmd!("appearance.removeItem", "Remove Item", [], None, "{index, ids?}", has_selection, remove_item),
|
||||
@@ -344,6 +345,9 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let idx = index_param(p, "index", "appearance.setItem")?;
|
||||
let paint = paint_from(s, p)?;
|
||||
let ids = appearance_targets(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad("appearance.setItem", "select objects or give ids"));
|
||||
}
|
||||
let blend = str_param(p, "blend").and_then(BlendMode::parse);
|
||||
s.edit("Appearance", |d, _| {
|
||||
for id in &ids {
|
||||
@@ -364,7 +368,7 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
*pp = super::gradient::place_paint(pa, p, bounds);
|
||||
}
|
||||
if let Some(o) = p.get("opacity").and_then(Value::as_f64) {
|
||||
*op = if o > 1.0 { o / 100.0 } else { o }.clamp(0.0, 1.0) as f32;
|
||||
*op = percent(o);
|
||||
}
|
||||
if let Some(b) = blend {
|
||||
*bl = b;
|
||||
|
||||
@@ -119,6 +119,7 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.doc_mut()?;
|
||||
let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
|
||||
let label = e.label.clone();
|
||||
let editing = st.doc.mask_edit.map(|m| m.layer);
|
||||
st.history.redo.push(crate::HistoryEntry {
|
||||
label: e.label,
|
||||
doc: std::mem::replace(&mut st.doc, e.doc),
|
||||
@@ -126,6 +127,7 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
});
|
||||
st.revision += 1;
|
||||
st.selection.prune(&st.doc);
|
||||
super::maskedit::follow_history(st, editing);
|
||||
Ok(json!({ "undone": label }))
|
||||
}
|
||||
|
||||
@@ -133,6 +135,7 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.doc_mut()?;
|
||||
let e = st.history.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?;
|
||||
let label = e.label.clone();
|
||||
let editing = st.doc.mask_edit.map(|m| m.layer);
|
||||
st.history.undo.push(crate::HistoryEntry {
|
||||
label: e.label,
|
||||
doc: std::mem::replace(&mut st.doc, e.doc),
|
||||
@@ -140,6 +143,7 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
});
|
||||
st.revision += 1;
|
||||
st.selection.prune(&st.doc);
|
||||
super::maskedit::follow_history(st, editing);
|
||||
Ok(json!({ "redone": label }))
|
||||
}
|
||||
|
||||
|
||||
@@ -90,15 +90,20 @@ const WEBP_SIDE: f64 = 16383.0;
|
||||
/// renderer's own size limits are [`vectorcraft_render::raster_size`]'s.
|
||||
fn check_format_size(f: &Format, w: f64, h: f64) -> Result<()> {
|
||||
if f.id == "webp" && (w.round() > WEBP_SIDE || h.round() > WEBP_SIDE) {
|
||||
return Err(bad(C, format!("{}×{} px is too large for {} (at most {WEBP_SIDE} px a side): lower the scale", w.round(), h.round(), f.label)));
|
||||
return Err(bad(
|
||||
C,
|
||||
format!("{} × {} pixels is too large for {} (at most {WEBP_SIDE} pixels a side): lower the scale", w.round(), h.round(), f.label),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Encode `doc` as `format` (an id or extension from [`super::FORMATS`]) with that format's
|
||||
/// options from `p` (see `document.formats`). Raster formats leave template layers out.
|
||||
/// options from `p` (see `document.formats`). Raster formats leave template layers out, and no
|
||||
/// format writes the opacity-mask editing layer.
|
||||
pub fn encode(doc: &Document, format: &str, p: &Value) -> Result<Vec<u8>> {
|
||||
let f = super::writable(C, Some(format), None)?;
|
||||
let doc = &*doc.without_edit_modes();
|
||||
let n = doc.artboards.len();
|
||||
Ok(match f.id {
|
||||
"vectorcraft" => vectorcraft_format::save_file(doc),
|
||||
|
||||
@@ -3,18 +3,23 @@
|
||||
//! The mask art moves onto a temporary isolated layer where every tool and command can edit it.
|
||||
//! After each edit [`sync`] writes the layer's art back into the object's mask, so the masked
|
||||
//! object updates live; the renderer doesn't paint the editing layer (the mask is seen through its
|
||||
//! effect, as in Illustrator). Leaving the mode removes the layer.
|
||||
//! effect, as in the reference app). Leaving the mode removes the layer, and saving or exporting
|
||||
//! never writes it ([`Document::without_edit_modes`]). Meanwhile the Transparency panel's commands
|
||||
//! act on the masked object (see `opacitymask::transparency_targets`).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, MaskEdit, Node, NodeId};
|
||||
use vectorcraft_doc::{Document, MaskEdit, Node, NodeId, NodeKind, Selection};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Name of the temporary mask-editing layer.
|
||||
pub const MASK_EDIT_LAYER: &str = "Opacity Mask Editing";
|
||||
|
||||
/// Name of the group that holds several mask objects (or none) as one mask art node.
|
||||
const MASK_GROUP: &str = "Opacity Mask";
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
@@ -22,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Opacity Mask",
|
||||
["Window", "Transparency"],
|
||||
None,
|
||||
"{id?} edit the selected object's mask art in place (the mask thumbnail in the Transparency panel) → {layer}",
|
||||
"{id?} edit the mask art of `id` (default: the first selected object with a mask) in place, as clicking the mask thumbnail in the Transparency panel does → {layer}",
|
||||
has_doc,
|
||||
enter
|
||||
),
|
||||
@@ -38,57 +43,116 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Mask art made of `children`: a plain group, so the mask keeps one art node. Editing spreads it
|
||||
/// back out onto the editing layer.
|
||||
pub(crate) fn mask_group(children: Vec<Arc<Node>>) -> Node {
|
||||
let mut g = Node::group(NodeId(u64::MAX - 1), children);
|
||||
g.name = Some(MASK_GROUP.into());
|
||||
g
|
||||
}
|
||||
|
||||
/// A group [`mask_group`] made (named so, with nothing of its own that its members would lose).
|
||||
fn is_mask_group(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Group { clip: false, .. })
|
||||
&& n.name.as_deref() == Some(MASK_GROUP)
|
||||
&& n.opacity >= 1.0
|
||||
&& n.blend == vectorcraft_doc::color::BlendMode::Normal
|
||||
&& !n.isolate
|
||||
&& !n.knockout
|
||||
&& n.mask.is_none()
|
||||
&& n.appearance.items.is_empty()
|
||||
&& n.appearance.effects.is_empty()
|
||||
}
|
||||
|
||||
/// The objects mask art is made of: the members of a [`mask_group`], else the art itself.
|
||||
pub(crate) fn mask_parts(art: &Arc<Node>) -> Vec<Arc<Node>> {
|
||||
match art.children() {
|
||||
Some(ch) if is_mask_group(art) => ch.clone(),
|
||||
_ => vec![art.clone()],
|
||||
}
|
||||
}
|
||||
|
||||
fn enter(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "transparency.editOpacityMask";
|
||||
if s.doc()?.doc.mask_edit.is_some() {
|
||||
let st = s.doc()?;
|
||||
if st.doc.mask_edit.is_some() {
|
||||
return Err(bad(C, "already editing an opacity mask"));
|
||||
}
|
||||
let st = s.doc()?;
|
||||
let id = id_param(p, "id")
|
||||
.or_else(|| st.selection.objects.iter().copied().find(|i| st.doc.node(*i).is_some_and(|n| n.mask.is_some())))
|
||||
.ok_or_else(|| bad(C, "select an object with an opacity mask"))?;
|
||||
let layer = s.edit("Edit Opacity Mask", |d, sel| {
|
||||
let art = d
|
||||
.node(id)
|
||||
.and_then(|n| n.mask.as_ref())
|
||||
.map(|m| m.art.clone())
|
||||
.ok_or_else(|| EngineError::Other("the object has no opacity mask".into()))?;
|
||||
let layer = d.add_layer(Some(MASK_EDIT_LAYER));
|
||||
// Fresh ids: copies of a masked object share the ids inside their mask art.
|
||||
let copy = d.reid(&art);
|
||||
let cid = copy.id;
|
||||
d.insert(Some(layer), 0, copy)?;
|
||||
d.mask_edit = Some(MaskEdit { object: id, layer });
|
||||
sel.set([cid]);
|
||||
Ok(layer)
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
st.isolation = Some(layer);
|
||||
st.active_layer = Some(layer);
|
||||
st.revision += 1;
|
||||
let layer = s.edit("Edit Opacity Mask", |d, sel| begin(d, sel, id))?;
|
||||
show_editing(s, Some(layer))?;
|
||||
Ok(json!({ "layer": layer.0 }))
|
||||
}
|
||||
|
||||
/// Enter mask editing for `id` inside an edit: copy its mask art onto a new editing layer and
|
||||
/// select it. Returns the layer.
|
||||
pub(crate) fn begin(d: &mut Document, sel: &mut Selection, id: NodeId) -> Result<NodeId> {
|
||||
let art =
|
||||
d.node(id).and_then(|n| n.mask.as_ref()).map(|m| m.art.clone()).ok_or_else(|| EngineError::Other("the object has no opacity mask".into()))?;
|
||||
let layer = d.add_layer(Some(MASK_EDIT_LAYER));
|
||||
let parts = mask_parts(&art);
|
||||
let mut ids = Vec::with_capacity(parts.len());
|
||||
for part in &parts {
|
||||
// Fresh ids: copies of a masked object share the ids inside their mask art.
|
||||
let copy = d.reid(part);
|
||||
ids.push(d.insert(Some(layer), usize::MAX, copy)?);
|
||||
}
|
||||
d.mask_edit = Some(MaskEdit { object: id, layer });
|
||||
sel.set(ids);
|
||||
Ok(layer)
|
||||
}
|
||||
|
||||
fn leave(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let Some(me) = s.doc()?.doc.mask_edit else { return Err(bad("transparency.stopEditingOpacityMask", "not editing an opacity mask")) };
|
||||
s.edit("Stop Editing Opacity Mask", |d, sel| {
|
||||
sync(d);
|
||||
d.mask_edit = None;
|
||||
let _ = d.remove(me.layer);
|
||||
if d.node(me.object).is_some() {
|
||||
sel.set([me.object]);
|
||||
} else {
|
||||
sel.clear();
|
||||
}
|
||||
finish(d, sel);
|
||||
Ok(())
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
st.isolation = None;
|
||||
st.active_layer = st.doc.default_layer();
|
||||
st.revision += 1;
|
||||
show_editing(s, None)?;
|
||||
Ok(json!({ "id": me.object.0 }))
|
||||
}
|
||||
|
||||
/// Leave mask editing inside an edit (no-op when not editing): write the art back into the mask,
|
||||
/// drop the editing layer and select the masked object.
|
||||
pub(crate) fn finish(d: &mut Document, sel: &mut Selection) {
|
||||
let Some(me) = d.mask_edit else { return };
|
||||
sync(d);
|
||||
d.drop_edit_modes();
|
||||
if d.node(me.object).is_some() {
|
||||
sel.set([me.object]);
|
||||
} else {
|
||||
sel.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Isolate the editing layer and draw into it (`Some`), or return to the document (`None`).
|
||||
pub(crate) fn show_editing(s: &mut Session, layer: Option<NodeId>) -> Result<()> {
|
||||
let st = s.doc_mut()?;
|
||||
st.isolation = layer;
|
||||
st.active_layer = layer.or_else(|| st.doc.default_layer());
|
||||
st.revision += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// After undo or redo, isolation follows the restored document into or out of mask editing
|
||||
/// (`was` is the editing layer before, if any), so it never names a layer that is gone (whose id
|
||||
/// a later object could reuse).
|
||||
pub(crate) fn follow_history(st: &mut crate::DocState, was: Option<NodeId>) {
|
||||
match st.doc.mask_edit {
|
||||
Some(me) => {
|
||||
st.isolation = Some(me.layer);
|
||||
st.active_layer = Some(me.layer);
|
||||
}
|
||||
None if was.is_some() && st.isolation == was => {
|
||||
st.isolation = None;
|
||||
st.active_layer = st.doc.default_layer();
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the editing layer's art into the object's mask (called after every edit). Leaves the
|
||||
/// mode when the object or the layer is gone (e.g. deleted).
|
||||
pub(crate) fn sync(d: &mut Document) {
|
||||
@@ -101,12 +165,8 @@ pub(crate) fn sync(d: &mut Document) {
|
||||
};
|
||||
let new_art = match art.as_slice() {
|
||||
[one] => one.clone(),
|
||||
many => {
|
||||
// Several objects (or none): a group, so the mask keeps one art node.
|
||||
let mut g = Node::group(NodeId(u64::MAX - 1), many.to_vec());
|
||||
g.name = Some("Opacity Mask".into());
|
||||
Arc::new(g)
|
||||
}
|
||||
// Several objects (or none): a group, so the mask keeps one art node.
|
||||
many => Arc::new(mask_group(many.to_vec())),
|
||||
};
|
||||
if let Some(m) = d.node(me.object).and_then(|n| n.mask.as_ref())
|
||||
&& Arc::ptr_eq(&m.art, &new_art)
|
||||
@@ -154,6 +214,11 @@ mod tests {
|
||||
assert!(st.doc.mask_edit.is_none() && st.isolation.is_none());
|
||||
assert!(!st.doc.layers.iter().any(|l| l.name.as_deref() == Some(super::MASK_EDIT_LAYER)));
|
||||
assert_eq!(st.selection.objects, vec![NodeId(obj)]);
|
||||
// Two mask objects went back as one group; editing again spreads them out on the layer.
|
||||
let layer = s.execute("transparency.editOpacityMask", &json!({"id": obj})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.node(NodeId(layer)).unwrap().children().unwrap().len(), 2);
|
||||
assert_eq!(s.doc().unwrap().selection.len(), 2);
|
||||
s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap();
|
||||
// Undo returns to editing mode's last state, then to before.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.mask_edit.is_some());
|
||||
|
||||
@@ -342,3 +342,6 @@ pub(crate) fn parse_range(s: &str, count: usize) -> std::result::Result<Vec<usiz
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The Transparency panel's state ([`Session::transparency_info`]).
|
||||
pub use opacitymask::TransparencyInfo;
|
||||
|
||||
@@ -9,6 +9,7 @@ use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{Affine, FillRule, Point, Rect, Vec2};
|
||||
|
||||
use super::edit::{duplicate_in, selected_roots};
|
||||
use super::opacitymask::percent;
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -87,8 +88,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("object.showAll", "Show All", ["Object"], Some("Cmd+Alt+3"), "{}", has_doc, show_all),
|
||||
cmd!("object.compoundPath.make", "Make", ["Object", "Compound Path"], Some("Cmd+8"), "{}", has_selection, compound_make),
|
||||
cmd!("object.compoundPath.release", "Release", ["Object", "Compound Path"], Some("Cmd+Alt+Shift+8"), "{}", has_selection, compound_release),
|
||||
cmd!("object.clippingMask.make", "Make", ["Object", "Clipping Mask"], Some("Cmd+7"), "{}", has_multi, clip_make),
|
||||
cmd!("object.clippingMask.release", "Release", ["Object", "Clipping Mask"], Some("Cmd+Alt+7"), "{}", has_selection, clip_release),
|
||||
cmd!(
|
||||
"object.clippingMask.make",
|
||||
"Make",
|
||||
["Object", "Clipping Mask"],
|
||||
Some("Cmd+7"),
|
||||
"{} the topmost selected object (a path, compound path or text, which loses its paint) clips the others: compound holes, even-odd fills and glyph outlines clip as drawn → {id} of the clip group",
|
||||
has_multi,
|
||||
clip_make
|
||||
),
|
||||
cmd!(
|
||||
"object.clippingMask.release",
|
||||
"Release",
|
||||
["Object", "Clipping Mask"],
|
||||
Some("Cmd+Alt+7"),
|
||||
"{} the selected clip groups become plain groups; their clipping path (path, compound path or text) stays, unpainted",
|
||||
has_selection,
|
||||
clip_release
|
||||
),
|
||||
cmd!(
|
||||
"object.clippingMask.editContents",
|
||||
"Edit Contents",
|
||||
@@ -114,7 +131,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Object Properties",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, name?, visible?, locked?, opacity?: 0..1 or %, blend?: \"Multiply\"…, isolate?, knockout?}",
|
||||
"{ids?|id?, name?, visible?, locked?, opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?}",
|
||||
has_doc,
|
||||
set_props
|
||||
),
|
||||
@@ -519,8 +536,8 @@ fn clip_make(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let top = *ids.last().unwrap();
|
||||
let gid = s.edit("Make Clipping Mask", |d, sel| {
|
||||
if !matches!(d.node(top).map(|n| &n.kind), Some(NodeKind::Path { .. })) {
|
||||
return Err(EngineError::Other("the top object must be a path to use as a clipping mask".into()));
|
||||
if !matches!(d.node(top).map(|n| &n.kind), Some(NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } | NodeKind::Text(_))) {
|
||||
return Err(EngineError::Other("the top object must be a path, compound path or text object to use as a clipping mask".into()));
|
||||
}
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let gid = d.alloc_id();
|
||||
@@ -530,9 +547,16 @@ fn clip_make(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
d.move_node(*id, Some(gid), usize::MAX)?;
|
||||
}
|
||||
if let Some(c) = d.node_mut(top) {
|
||||
// The clipping path loses its paint (it stays unpainted after Release).
|
||||
c.appearance = Appearance::basic(Paint::None, Paint::None, 0.0);
|
||||
if let NodeKind::Path { clipping, .. } = &mut c.kind {
|
||||
*clipping = true;
|
||||
match &mut c.kind {
|
||||
NodeKind::Path { clipping, .. } => *clipping = true,
|
||||
NodeKind::Text(t) => {
|
||||
for r in &mut t.runs {
|
||||
(r.style.fill, r.style.stroke) = (Paint::None, Paint::None);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
sel.set([gid]);
|
||||
@@ -615,7 +639,7 @@ fn exit_isolation(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let opacity = p.get("opacity").and_then(Value::as_f64).map(|o| if o > 1.0 { o / 100.0 } else { o }.clamp(0.0, 1.0) as f32);
|
||||
let opacity = p.get("opacity").and_then(Value::as_f64).map(percent);
|
||||
let blend = match str_param(p, "blend") {
|
||||
Some(b) => Some(BlendMode::parse(b).ok_or_else(|| bad("object.setProps", format!("unknown blend mode `{b}`")))?),
|
||||
None => None,
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
//! Opacity masks (Transparency panel): make, release, enable/disable, link/unlink, clip, invert.
|
||||
//! Opacity masks (Transparency panel): make, release, enable/disable, link/unlink, clip, invert,
|
||||
//! and the panel's state (`transparency.info`).
|
||||
//!
|
||||
//! The mask art is stored on the masked object ([`vectorcraft_doc::OpacityMask`]), outside the
|
||||
//! layer tree, so it is never hit-tested or selected. Its luminance sets the object's opacity.
|
||||
//! Every command here (and `transparency.set`) acts on [`transparency_targets`]: explicit ids, else
|
||||
//! the object whose mask is being edited, else the selection.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Node, NodeId, OpacityMask};
|
||||
use vectorcraft_color::BlendMode;
|
||||
use vectorcraft_doc::{Document, Node, NodeId, OpacityMask, Selection};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::maskedit::{begin, finish, mask_group, mask_parts, show_editing};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -17,8 +24,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make Opacity Mask",
|
||||
["Window", "Transparency"],
|
||||
None,
|
||||
"{clip?, invert?} the topmost selected object becomes the mask of the others (grouped if several) → {id}",
|
||||
has_multi,
|
||||
"{ids?, clip?, invert?} the topmost of the selected objects (or of `ids`) becomes the mask of the others (grouped if several). One object gets an empty mask and enters mask editing: draw the mask, then transparency.stopEditingOpacityMask → {id, editing}",
|
||||
has_doc,
|
||||
make
|
||||
),
|
||||
cmd!(
|
||||
@@ -26,17 +33,22 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Release Opacity Mask",
|
||||
["Window", "Transparency"],
|
||||
None,
|
||||
"{} put the mask art back above each masked object",
|
||||
has_mask,
|
||||
"{id?|ids?} put the mask art back above each masked object (default: the selection, or the object whose mask is being edited, which leaves mask editing)",
|
||||
has_doc,
|
||||
release
|
||||
),
|
||||
cmd!("transparency.disableOpacityMask", "Disable Opacity Mask", ["Window", "Transparency"], None, "{}", has_mask, |s, _| set_flags(
|
||||
s,
|
||||
cmd!(
|
||||
"transparency.disableOpacityMask",
|
||||
"Disable Opacity Mask",
|
||||
&json!({ "disabled": true })
|
||||
)),
|
||||
cmd!("transparency.enableOpacityMask", "Enable Opacity Mask", ["Window", "Transparency"], None, "{}", has_mask, |s, _| set_flags(
|
||||
["Window", "Transparency"],
|
||||
None,
|
||||
"{id?|ids?} keep the mask but stop applying it",
|
||||
has_doc,
|
||||
|s, p| set_flags(s, p, "Disable Opacity Mask", &json!({ "disabled": true }))
|
||||
),
|
||||
cmd!("transparency.enableOpacityMask", "Enable Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?}", has_doc, |s, p| set_flags(
|
||||
s,
|
||||
p,
|
||||
"Enable Opacity Mask",
|
||||
&json!({ "disabled": false })
|
||||
)),
|
||||
@@ -45,12 +57,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Unlink Opacity Mask",
|
||||
["Window", "Transparency"],
|
||||
None,
|
||||
"{} the object moves without its mask",
|
||||
has_mask,
|
||||
|s, _| set_flags(s, "Unlink Opacity Mask", &json!({ "linked": false }))
|
||||
"{id?|ids?} the object moves without its mask",
|
||||
has_doc,
|
||||
|s, p| set_flags(s, p, "Unlink Opacity Mask", &json!({ "linked": false }))
|
||||
),
|
||||
cmd!("transparency.linkOpacityMask", "Link Opacity Mask", ["Window", "Transparency"], None, "{}", has_mask, |s, _| set_flags(
|
||||
cmd!("transparency.linkOpacityMask", "Link Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?}", has_doc, |s, p| set_flags(
|
||||
s,
|
||||
p,
|
||||
"Link Opacity Mask",
|
||||
&json!({ "linked": true })
|
||||
)),
|
||||
@@ -59,9 +72,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Opacity Mask Options",
|
||||
[],
|
||||
None,
|
||||
"{clip?, invert?, disabled?, linked?} change the selected objects' opacity masks",
|
||||
has_mask,
|
||||
|s, p| set_flags(s, "Opacity Mask Options", p)
|
||||
"{id?|ids?, clip?, invert?, disabled?, linked?} change the opacity masks of `ids`, the selection, or the object whose mask is being edited",
|
||||
has_doc,
|
||||
|s, p| set_flags(s, p, "Opacity Mask Options", p)
|
||||
),
|
||||
cmd!(
|
||||
"transparency.toggleNewMasksClipping",
|
||||
@@ -81,35 +94,76 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
toggle_new_invert
|
||||
),
|
||||
cmd!(query "transparency.opacityMaskInfo", "Opacity Mask Info", [], None, "{} → [{id, clip, invert, disabled, linked}] masks of the selected objects", has_doc, info),
|
||||
cmd!(
|
||||
query "transparency.opacityMaskInfo",
|
||||
"Opacity Mask Info",
|
||||
[],
|
||||
None,
|
||||
"{id?|ids?} → [{id, clip, invert, disabled, linked, art}] the opacity masks of `ids`, the selection, or the object whose mask is being edited",
|
||||
has_doc,
|
||||
mask_info
|
||||
),
|
||||
cmd!(
|
||||
query "transparency.info",
|
||||
"Transparency Info",
|
||||
[],
|
||||
None,
|
||||
"{id?|ids?} → {ids, opacity: 0..100, blend, isolate, knockout, editingMask} the Transparency panel's values for `ids`, the selection, or the object whose mask is being edited. A value is null where those objects differ (or there are none); editingMask is the id of the object whose mask is being edited, or null",
|
||||
has_doc,
|
||||
info
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn has_mask(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
let st = s.active().unwrap();
|
||||
if st.selection.in_paint_order(&st.doc).into_iter().any(|id| st.doc.node(id).is_some_and(|n| n.mask.is_some())) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("no selected object has an opacity mask".into())
|
||||
/// The objects the Transparency panel's commands act on: `ids` / `id`, else the object whose
|
||||
/// opacity mask is being edited (the selection then is its mask art), else the selection.
|
||||
pub(crate) fn transparency_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
match s.doc()?.doc.mask_edit {
|
||||
Some(me) if p.get("ids").is_none() && p.get("id").is_none() => Ok(vec![me.object]),
|
||||
_ => targets(s, p),
|
||||
}
|
||||
}
|
||||
|
||||
fn masked_ids(s: &Session) -> Result<Vec<NodeId>> {
|
||||
let st = s.doc()?;
|
||||
Ok(selected_roots(s)?.into_iter().filter(|id| st.doc.node(*id).is_some_and(|n| n.mask.is_some())).collect())
|
||||
/// An opacity parameter, in percent (0..100) everywhere, as the model's 0..1.
|
||||
pub(crate) fn percent(o: f64) -> f32 {
|
||||
(o / 100.0).clamp(0.0, 1.0) as f32
|
||||
}
|
||||
|
||||
/// The [`transparency_targets`] that have an opacity mask.
|
||||
fn masked_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
let d = &s.doc()?.doc;
|
||||
let ids: Vec<NodeId> = transparency_targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some_and(|n| n.mask.is_some())).collect();
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other("no target object has an opacity mask: select one or give `id`".into()));
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
if ids.len() < 2 {
|
||||
return Err(bad("transparency.makeOpacityMask", "select the art and, on top, the mask object"));
|
||||
const C: &str = "transparency.makeOpacityMask";
|
||||
let d = &s.doc()?.doc;
|
||||
if d.mask_edit.is_some() {
|
||||
return Err(bad(C, "stop editing the opacity mask first"));
|
||||
}
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(ids) => {
|
||||
if let Some(id) = ids.iter().find(|id| d.node(**id).is_none()) {
|
||||
return Err(EngineError::NoNode(*id));
|
||||
}
|
||||
Selection { objects: ids, ..Default::default() }.in_paint_order(d)
|
||||
}
|
||||
None => selected_roots(s)?,
|
||||
};
|
||||
let Some((&top, below)) = ids.split_last() else { return Err(bad(C, "select the art and, on top of it, the mask object")) };
|
||||
let clip = bool_or(p, "clip", !s.menu.new_masks_unclipped);
|
||||
let invert = bool_or(p, "invert", s.menu.new_masks_inverted);
|
||||
let (mask_id, art) = (ids[ids.len() - 1], &ids[..ids.len() - 1]);
|
||||
let id = s.edit("Make Opacity Mask", |d, sel| {
|
||||
let mask_art = (*d.remove(mask_id)?).clone();
|
||||
// Several objects: the top one is the mask. One: an empty mask, drawn in mask editing.
|
||||
let (art, mask_id) = if below.is_empty() { (&ids[..], None) } else { (below, Some(top)) };
|
||||
let (id, layer) = s.edit("Make Opacity Mask", |d, sel| {
|
||||
let mask_art = match mask_id {
|
||||
Some(m) => (*d.remove(m)?).clone(),
|
||||
None => mask_group(vec![]),
|
||||
};
|
||||
// Several objects are grouped so they share one mask.
|
||||
let target = if let [one] = art {
|
||||
*one
|
||||
@@ -131,36 +185,49 @@ fn make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
m.invert = invert;
|
||||
n.mask = Some(Box::new(m));
|
||||
sel.set([target]);
|
||||
Ok(target)
|
||||
let layer = if mask_id.is_none() { Some(begin(d, sel, target)?) } else { None };
|
||||
Ok((target, layer))
|
||||
})?;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
if layer.is_some() {
|
||||
show_editing(s, layer)?;
|
||||
}
|
||||
Ok(json!({ "id": id.0, "editing": layer.is_some() }))
|
||||
}
|
||||
|
||||
fn release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = masked_ids(s)?;
|
||||
fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = masked_targets(s, p)?;
|
||||
let editing = s.doc()?.doc.mask_edit.is_some();
|
||||
s.edit("Release Opacity Mask", |d, sel| {
|
||||
// Leave mask editing first: its art goes back into the mask, which is released below.
|
||||
finish(d, sel);
|
||||
let mut out = vec![];
|
||||
for id in &ids {
|
||||
let Some(m) = d.node_mut(*id).and_then(|n| n.mask.take()) else { continue };
|
||||
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
// Fresh ids: copies of a masked object share the ids inside their mask art.
|
||||
let art = d.reid(&m.art);
|
||||
out.push(*id);
|
||||
out.push(d.insert(par, idx + 1, art)?);
|
||||
for (k, part) in mask_parts(&m.art).iter().enumerate() {
|
||||
// Fresh ids: copies of a masked object share the ids inside their mask art.
|
||||
let art = d.reid(part);
|
||||
out.push(d.insert(par, idx + 1 + k, art)?);
|
||||
}
|
||||
}
|
||||
sel.set(out);
|
||||
Ok(())
|
||||
})?;
|
||||
if editing {
|
||||
show_editing(s, None)?;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
fn set_flags(s: &mut Session, label: &str, p: &Value) -> Result<Value> {
|
||||
let ids = masked_ids(s)?;
|
||||
/// Set the mask flags given in `p` on the masked objects among `target`'s targets.
|
||||
fn set_flags(s: &mut Session, target: &Value, label: &str, p: &Value) -> Result<Value> {
|
||||
let get = |k: &str| p.get(k).and_then(Value::as_bool);
|
||||
let (clip, invert, disabled, linked) = (get("clip"), get("invert"), get("disabled"), get("linked"));
|
||||
if clip.is_none() && invert.is_none() && disabled.is_none() && linked.is_none() {
|
||||
return Err(bad("transparency.setOpacityMask", "give at least one of clip, invert, disabled, linked"));
|
||||
}
|
||||
let ids = masked_targets(s, target)?;
|
||||
s.edit(label, |d, _| {
|
||||
for id in &ids {
|
||||
if let Some(m) = d.node_mut(*id).and_then(|n| n.mask.as_mut()) {
|
||||
@@ -187,20 +254,85 @@ fn toggle_new_invert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "value": v }))
|
||||
}
|
||||
|
||||
/// The Transparency panel's values for its targets ([`Session::transparency_info`]). A value is
|
||||
/// `None` where the targets differ, or when there are none.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct TransparencyInfo {
|
||||
/// The targets (the selection, in selection order, by default).
|
||||
pub ids: Vec<NodeId>,
|
||||
/// Opacity in percent (0..100), to two decimals.
|
||||
pub opacity: Option<f64>,
|
||||
pub blend: Option<BlendMode>,
|
||||
pub isolate: Option<bool>,
|
||||
pub knockout: Option<bool>,
|
||||
}
|
||||
|
||||
impl TransparencyInfo {
|
||||
fn of(d: &Document, ids: Vec<NodeId>) -> Self {
|
||||
let nodes = nodes_of(d, &ids);
|
||||
fn same<T: PartialEq>(nodes: &[&Node], f: impl Fn(&Node) -> T) -> Option<T> {
|
||||
let first = f(nodes.first()?);
|
||||
nodes[1..].iter().all(|n| f(n) == first).then_some(first)
|
||||
}
|
||||
Self {
|
||||
// Compared in hundredths of a percent, as shown.
|
||||
opacity: same(&nodes, |n| (n.opacity as f64 * 10_000.0).round() as i64).map(|o| o as f64 / 100.0),
|
||||
blend: same(&nodes, |n| n.blend),
|
||||
isolate: same(&nodes, |n| n.isolate),
|
||||
knockout: same(&nodes, |n| n.knockout),
|
||||
ids,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The nodes of `ids` that exist: looked up one by one when few, else in one walk of the tree.
|
||||
fn nodes_of<'a>(d: &'a Document, ids: &[NodeId]) -> Vec<&'a Node> {
|
||||
if ids.len() <= 8 {
|
||||
return ids.iter().filter_map(|id| d.node(*id)).collect();
|
||||
}
|
||||
let set: HashSet<NodeId> = ids.iter().copied().collect();
|
||||
let mut out = Vec::with_capacity(ids.len());
|
||||
d.walk(|n| {
|
||||
if set.contains(&n.id) {
|
||||
out.push(n);
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Defaults for new opacity masks (clip, invert) from the Transparency panel menu.
|
||||
pub fn new_mask_defaults(&self) -> (bool, bool) {
|
||||
(!self.menu.new_masks_unclipped, self.menu.new_masks_inverted)
|
||||
}
|
||||
|
||||
/// The Transparency panel's values for the selection, or for the object whose mask is being
|
||||
/// edited (`transparency.info` without ids).
|
||||
pub fn transparency_info(&self) -> TransparencyInfo {
|
||||
match (self.active(), transparency_targets(self, &Value::Null)) {
|
||||
(Some(st), Ok(ids)) => TransparencyInfo::of(&st.doc, ids),
|
||||
_ => TransparencyInfo::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn info(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let v: Vec<Value> = st
|
||||
.selection
|
||||
.in_paint_order(&st.doc)
|
||||
let i = TransparencyInfo::of(&st.doc, transparency_targets(s, p)?);
|
||||
Ok(json!({
|
||||
"ids": i.ids.iter().map(|id| id.0).collect::<Vec<_>>(),
|
||||
"opacity": i.opacity,
|
||||
"blend": i.blend.map(BlendMode::label),
|
||||
"isolate": i.isolate,
|
||||
"knockout": i.knockout,
|
||||
"editingMask": st.doc.mask_edit.map(|me| me.object.0),
|
||||
}))
|
||||
}
|
||||
|
||||
fn mask_info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let v: Vec<Value> = nodes_of(d, &transparency_targets(s, p)?)
|
||||
.into_iter()
|
||||
.filter_map(|id| st.doc.node(id))
|
||||
.filter_map(|n| {
|
||||
let m = n.mask.as_deref()?;
|
||||
Some(json!({ "id": n.id.0, "clip": m.clip, "invert": m.invert, "disabled": m.disabled, "linked": m.linked, "art": m.art.id.0 }))
|
||||
@@ -232,6 +364,7 @@ mod tests {
|
||||
select(&mut s, &[a, b]);
|
||||
let r = s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
|
||||
assert_eq!(r["id"].as_u64(), Some(a));
|
||||
assert_eq!(r["editing"], false);
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!(d.node(NodeId(b)).is_none(), "mask art left the layer tree");
|
||||
let m = d.node(NodeId(a)).unwrap().mask.as_deref().unwrap();
|
||||
|
||||
@@ -38,8 +38,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Transparency",
|
||||
["Window", "Transparency"],
|
||||
None,
|
||||
"{opacity?: 0..100, blend?: name, isolate?, knockout?, item?: appearance item index|null, ids?} opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects)",
|
||||
has_selection,
|
||||
"{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?, item?: appearance item index|null} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects)",
|
||||
has_doc,
|
||||
transparency
|
||||
),
|
||||
]
|
||||
@@ -155,39 +155,40 @@ fn default_paint(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn transparency(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "transparency.set";
|
||||
let item = item_target(s, p, C)?;
|
||||
let ids = super::opacitymask::transparency_targets(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "select objects or give ids"));
|
||||
}
|
||||
// While an opacity mask is edited the selection is its art, so the Appearance panel's active
|
||||
// item (a row of that art) doesn't stand for the masked object.
|
||||
let editing_mask = p.get("ids").is_none() && p.get("id").is_none() && s.doc()?.doc.mask_edit.is_some();
|
||||
let item = if editing_mask && p.get("item").is_none() { ItemTarget::Top } else { item_target(s, p, C)? };
|
||||
let mut q = p.as_object().cloned().unwrap_or_default();
|
||||
q.remove("item");
|
||||
let opacity = q.remove("opacity").and_then(|v| v.as_f64()).map(|o| (o / 100.0).clamp(0.0, 1.0));
|
||||
q.remove("id");
|
||||
q.insert("ids".into(), json!(ids.iter().map(|id| id.0).collect::<Vec<_>>()));
|
||||
let ItemTarget::Item { index, explicit } = item else {
|
||||
if let Some(o) = opacity {
|
||||
q.insert("opacity".into(), json!(o));
|
||||
}
|
||||
return s.execute("object.setProps", &Value::Object(q));
|
||||
};
|
||||
// Opacity and blend belong to the targeted fill/stroke; isolate/knockout stay object-level.
|
||||
let item_ids = if editing_mask { ids } else { appearance_targets(s, p)? };
|
||||
if explicit {
|
||||
let d = &s.doc()?.doc;
|
||||
if !appearance_targets(s, p)?.iter().any(|id| d.node(*id).is_some_and(|n| index < n.appearance.items.len())) {
|
||||
if !item_ids.iter().any(|id| d.node(*id).is_some_and(|n| index < n.appearance.items.len())) {
|
||||
return Err(bad(C, format!("no appearance item {index}")));
|
||||
}
|
||||
}
|
||||
let blend = q.remove("blend");
|
||||
let (opacity, blend) = (q.remove("opacity"), q.remove("blend"));
|
||||
if let Some(b) = &blend
|
||||
&& b.as_str().and_then(BlendMode::parse).is_none()
|
||||
{
|
||||
return Err(bad(C, format!("unknown blend mode {b}")));
|
||||
}
|
||||
if opacity.is_some() || blend.is_some() {
|
||||
let mut set = json!({ "index": index, "opacity": opacity, "blend": blend });
|
||||
for key in ["ids", "id"] {
|
||||
if let Some(v) = p.get(key) {
|
||||
set[key] = v.clone();
|
||||
}
|
||||
}
|
||||
s.execute("appearance.setItem", &set)?;
|
||||
let ids: Vec<u64> = item_ids.iter().map(|id| id.0).collect();
|
||||
s.execute("appearance.setItem", &json!({ "index": index, "ids": ids, "opacity": opacity, "blend": blend }))?;
|
||||
}
|
||||
if q.keys().all(|k| k == "ids" || k == "id") {
|
||||
if q.keys().all(|k| k == "ids") {
|
||||
return ok();
|
||||
}
|
||||
s.execute("object.setProps", &Value::Object(q))
|
||||
|
||||
@@ -723,6 +723,8 @@ mod tests_brushsym;
|
||||
#[cfg(test)]
|
||||
mod tests_build;
|
||||
#[cfg(test)]
|
||||
mod tests_clip;
|
||||
#[cfg(test)]
|
||||
mod tests_cmdsplit;
|
||||
#[cfg(test)]
|
||||
mod tests_colormgmt;
|
||||
@@ -739,6 +741,8 @@ mod tests_live;
|
||||
#[cfg(test)]
|
||||
mod tests_menucmds;
|
||||
#[cfg(test)]
|
||||
mod tests_opacitymask;
|
||||
#[cfg(test)]
|
||||
mod tests_panelcmds;
|
||||
#[cfg(test)]
|
||||
mod tests_pathops;
|
||||
|
||||
@@ -259,7 +259,7 @@ fn text_fill_items_composite_with_their_opacity() {
|
||||
let opaque = render(&s);
|
||||
// A pixel the red fill covers fully (over the black character fill).
|
||||
let (x, y) = (0..400u32).flat_map(|y| (0..400u32).map(move |x| (x, y))).find(|&(x, y)| opaque.pixel(x, y) == [255, 0, 0, 255]).expect("red text");
|
||||
run(&mut s, "appearance.setItem", json!({"index": 0, "opacity": 0.5, "blend": "multiply"}));
|
||||
run(&mut s, "appearance.setItem", json!({"index": 0, "opacity": 50, "blend": "multiply"}));
|
||||
let px = render(&s).pixel(x, y);
|
||||
// Half of red multiplied over black: black.
|
||||
assert!(px[0] < 20 && px[3] == 255, "{px:?}");
|
||||
@@ -294,7 +294,7 @@ fn set_item_and_remove_item() {
|
||||
let st = stroke(&n, 1);
|
||||
assert_eq!((st.opacity, st.blend, st.width), (0.5, BlendMode::Screen, 3.0));
|
||||
assert_eq!(st.paint.color().unwrap().to_hex(), "#0000ff");
|
||||
run(&mut s, "appearance.setItem", json!({"index": 0, "visible": false, "opacity": 0.25}));
|
||||
run(&mut s, "appearance.setItem", json!({"index": 0, "visible": false, "opacity": 25}));
|
||||
let n = node(&s, id);
|
||||
assert!(!n.appearance.items[0].visible() && n.appearance.items[0].opacity() == 0.25);
|
||||
assert!(s.execute("appearance.setItem", &json!({})).is_err());
|
||||
@@ -310,7 +310,7 @@ fn clear_and_reduce_to_basic() {
|
||||
let id = rect(&mut s, 100.0);
|
||||
run(&mut s, "appearance.addStroke", json!({}));
|
||||
run(&mut s, "appearance.setItem", json!({"index": 2, "visible": false, "color": "#00ff00"}));
|
||||
run(&mut s, "appearance.setItem", json!({"index": 1, "opacity": 0.5}));
|
||||
run(&mut s, "appearance.setItem", json!({"index": 1, "opacity": 50}));
|
||||
run(&mut s, "effect.apply", json!({"effect": "distort.roughen", "item": 1}));
|
||||
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "item": null}));
|
||||
run(&mut s, "transparency.set", json!({"opacity": 40, "blend": "multiply"}));
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Clipping sets with any clip shape (M3.26): Make Clipping Mask takes a compound path or text on
|
||||
//! top and Release gives it back; compound holes, even-odd fills, text outlines and imported
|
||||
//! multi-shape clip paths clip on screen, and the SVG and PDF output clip the same way.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_render::Rendered;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn id_of(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn ellipse(s: &mut Session, x: f64, w: f64) -> NodeId {
|
||||
id_of(&s.execute("shape.ellipse", &json!({"x": x, "y": x, "width": w, "height": w})).unwrap())
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, ids: &[NodeId]) {
|
||||
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
}
|
||||
|
||||
/// The active document's 100×100 artboard, rendered transparent.
|
||||
fn render(s: &Session) -> Rendered {
|
||||
vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 100, 100, Affine::IDENTITY, &Default::default())
|
||||
}
|
||||
|
||||
fn opaque(r: &Rendered, x: u32, y: u32) -> bool {
|
||||
r.pixel(x, y)[3] > 128
|
||||
}
|
||||
|
||||
/// The document exported as `format` and opened again, rendered.
|
||||
fn reopened(s: &mut Session, format: &str) -> Rendered {
|
||||
let data = s.execute("document.export", &json!({"format": format})).unwrap()["dataBase64"].clone();
|
||||
let mut o = Session::new();
|
||||
o.execute("document.open", &json!({"name": format!("clip.{format}"), "dataBase64": data})).unwrap();
|
||||
render(&o)
|
||||
}
|
||||
|
||||
/// The same probes are opaque on screen and in the SVG and PDF output.
|
||||
fn assert_outputs_agree(s: &mut Session, probes: &[(u32, u32)]) {
|
||||
let screen = render(s);
|
||||
for format in ["svg", "pdf"] {
|
||||
let out = reopened(s, format);
|
||||
for &(x, y) in probes {
|
||||
assert_eq!(opaque(&out, x, y), opaque(&screen, x, y), "{format} at ({x}, {y})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The leaf a click at (`x`, `y`) hits.
|
||||
fn hit(s: &Session, x: f64, y: f64) -> Option<NodeId> {
|
||||
vectorcraft_doc::hit::hit_test(&s.doc().unwrap().doc, vectorcraft_geom::Point::new(x, y), Default::default()).map(|h| h.leaf)
|
||||
}
|
||||
|
||||
/// Art under the whole artboard, then a donut (compound path with a real hole) on top.
|
||||
fn art_and_donut(s: &mut Session) -> (NodeId, NodeId) {
|
||||
let art = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
|
||||
let (outer, inner) = (ellipse(s, 10.0, 80.0), ellipse(s, 35.0, 30.0));
|
||||
select(s, &[inner]);
|
||||
s.execute("path.reverse", &json!({})).unwrap();
|
||||
select(s, &[outer, inner]);
|
||||
s.execute("object.compoundPath.make", &json!({})).unwrap();
|
||||
let donut = s.doc().unwrap().selection.objects[0];
|
||||
(art, donut)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_clip_keeps_its_hole_and_release_gives_the_compound_back() {
|
||||
let mut s = session();
|
||||
let (art, donut) = art_and_donut(&mut s);
|
||||
select(&mut s, &[art, donut]);
|
||||
let g = id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap());
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let NodeKind::Group { children, clip: true } = &d.node(g).unwrap().kind else { panic!("a clip group") };
|
||||
assert_eq!(children[0].id, donut);
|
||||
assert!(matches!(children[0].kind, NodeKind::Compound { .. }) && children[0].appearance.fill_paint().is_none());
|
||||
let r = render(&s);
|
||||
assert!(opaque(&r, 20, 50), "the ring shows the art");
|
||||
assert!(!opaque(&r, 50, 50), "the hole stays transparent");
|
||||
assert!(!opaque(&r, 3, 3), "outside the ring");
|
||||
assert_outputs_agree(&mut s, &[(20, 50), (50, 50), (3, 3), (80, 50)]);
|
||||
// Clicks hit the art only where it shows.
|
||||
assert_eq!(hit(&s, 20.0, 50.0), Some(art));
|
||||
assert_eq!(hit(&s, 50.0, 50.0), None, "the hole");
|
||||
// Release: a plain group, the compound path back on top of the art, unpainted.
|
||||
select(&mut s, &[g]);
|
||||
s.execute("object.clippingMask.release", &json!({})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let NodeKind::Group { children, clip: false } = &d.node(g).unwrap().kind else { panic!("a plain group") };
|
||||
assert!(matches!(children[0].kind, NodeKind::Compound { .. }) && children[0].id == donut);
|
||||
assert!(opaque(&render(&s), 50, 50), "nothing clips any more");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_on_top_clips_by_its_glyphs() {
|
||||
let mut s = session();
|
||||
let art = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 5, "y": 80, "text": "O", "size": 90})).unwrap());
|
||||
let b = s.doc().unwrap().doc.node(t).unwrap().geometric_bounds().unwrap();
|
||||
select(&mut s, &[art, t]);
|
||||
let g = id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap());
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let NodeKind::Text(text) = &d.node(t).unwrap().kind else { panic!("still text") };
|
||||
assert!(text.runs.iter().all(|r| r.style.fill.is_none() && r.style.stroke.is_none()), "the clipping path loses its paint");
|
||||
// Across the middle of the "O": clear, ring, the counter (clear), ring, clear.
|
||||
let cy = ((b.y0 + b.y1) / 2.0) as u32;
|
||||
let r = render(&s);
|
||||
let row: Vec<bool> = (0..100).map(|x| opaque(&r, x, cy)).collect();
|
||||
assert_eq!(row.windows(2).filter(|w| w[0] != w[1]).count(), 4, "{row:?}");
|
||||
let probes: Vec<(u32, u32)> = (0..100).step_by(3).map(|x| (x, cy)).collect();
|
||||
assert_outputs_agree(&mut s, &probes);
|
||||
// Text clips clicks by its frame.
|
||||
assert_eq!(hit(&s, (b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0), Some(art));
|
||||
assert!(b.x1 < 95.0, "{b:?}");
|
||||
assert_eq!(hit(&s, b.x1 + 3.0, (b.y0 + b.y1) / 2.0), None, "beside the text");
|
||||
// Release gives the text back.
|
||||
select(&mut s, &[g]);
|
||||
s.execute("object.clippingMask.release", &json!({})).unwrap();
|
||||
assert!(matches!(s.doc().unwrap().doc.node(t).unwrap().kind, NodeKind::Text(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_refuses_a_top_object_that_cannot_clip() {
|
||||
let mut s = session();
|
||||
let art = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
|
||||
let (a, b) = (ellipse(&mut s, 10.0, 20.0), ellipse(&mut s, 50.0, 20.0));
|
||||
select(&mut s, &[a, b]);
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
let group = s.doc().unwrap().selection.objects[0];
|
||||
select(&mut s, &[art, group]);
|
||||
let err = s.execute("object.clippingMask.make", &json!({})).unwrap_err();
|
||||
assert!(err.to_string().contains("path, compound path or text"), "{err}");
|
||||
}
|
||||
|
||||
/// An SVG clip path made of several shapes (imported as one multi-subpath clipping path) and an
|
||||
/// even-odd star clip path.
|
||||
const SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<clipPath id="two"><rect x="0" y="0" width="30" height="40"/><rect x="60" y="0" width="30" height="40"/></clipPath>
|
||||
<clipPath id="star"><path clip-rule="evenodd" d="M70 49 L84.7 94.2 L46.2 66.3 L93.8 66.3 L55.3 94.2 Z"/></clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#two)"><rect x="0" y="0" width="100" height="45" fill="#000"/></g>
|
||||
<g clip-path="url(#star)"><rect x="40" y="45" width="60" height="55" fill="#000"/></g>
|
||||
</svg>"##;
|
||||
|
||||
#[test]
|
||||
fn imported_multi_shape_and_even_odd_clip_paths_clip() {
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({"name": "clips.svg", "dataBase64": vectorcraft_format::base64_encode(SVG.as_bytes())})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let mut kinds = vec![];
|
||||
d.walk(|n| {
|
||||
if let NodeKind::Group { children, clip: true } = &n.kind {
|
||||
kinds.push(children[0].kind_label());
|
||||
}
|
||||
});
|
||||
assert_eq!(kinds.len(), 2, "two clip groups");
|
||||
let r = render(&s);
|
||||
assert!(opaque(&r, 15, 20) && opaque(&r, 75, 20), "inside both shapes of the clip path");
|
||||
assert!(!opaque(&r, 45, 20), "between them");
|
||||
assert!(!opaque(&r, 15, 43), "below them");
|
||||
// The star's arms cross: the centre of the crossing is outside under even-odd.
|
||||
assert!(opaque(&r, 70, 56), "an arm");
|
||||
assert!(!opaque(&r, 70, 75), "the even-odd centre");
|
||||
assert_outputs_agree(&mut s, &[(15, 20), (75, 20), (45, 20), (15, 43), (70, 56), (70, 75)]);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
//! Opacity-mask editing and the Transparency panel's command reach (M3.25): commands act on the
|
||||
//! masked object while its mask is edited, accept ids without a selection, take opacity in percent,
|
||||
//! report mixed values, and saves and exports never carry the editing layer.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::maskedit::MASK_EDIT_LAYER;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, w: f64) -> NodeId {
|
||||
let r = s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": w, "height": 200})).unwrap();
|
||||
NodeId(r["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// An object (0..200) masked by a white rectangle (0..100), with the mask being edited.
|
||||
fn editing_mask(s: &mut Session) -> NodeId {
|
||||
let obj = rect(s, 0.0, 200.0);
|
||||
let m = rect(s, 0.0, 100.0);
|
||||
s.execute("paint.setFill", &json!({"color": "#ffffff", "ids": [m.0]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [obj.0, m.0]})).unwrap();
|
||||
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
|
||||
s.execute("transparency.editOpacityMask", &json!({})).unwrap();
|
||||
obj
|
||||
}
|
||||
|
||||
fn doc(s: &Session) -> &Document {
|
||||
&s.doc().unwrap().doc
|
||||
}
|
||||
|
||||
fn mask(s: &Session, id: NodeId) -> Option<&vectorcraft_doc::OpacityMask> {
|
||||
doc(s).node(id).and_then(|n| n.mask.as_deref())
|
||||
}
|
||||
|
||||
fn has_edit_layer(d: &Document) -> bool {
|
||||
d.layers.iter().any(|l| l.name.as_deref() == Some(MASK_EDIT_LAYER))
|
||||
}
|
||||
|
||||
fn data(v: &Value) -> Vec<u8> {
|
||||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_options_act_on_the_masked_object_while_editing() {
|
||||
let mut s = session();
|
||||
let obj = editing_mask(&mut s);
|
||||
// The selection is the mask art; the panel's commands still reach the object's mask.
|
||||
assert!(!s.doc().unwrap().selection.contains(obj));
|
||||
s.execute("transparency.setOpacityMask", &json!({"clip": false, "invert": true})).unwrap();
|
||||
s.execute("transparency.unlinkOpacityMask", &json!({})).unwrap();
|
||||
s.execute("transparency.disableOpacityMask", &json!({})).unwrap();
|
||||
let m = mask(&s, obj).unwrap();
|
||||
assert!(!m.clip && m.invert && !m.linked && m.disabled);
|
||||
assert!(doc(&s).mask_edit.is_some(), "still editing");
|
||||
let info = s.execute("transparency.opacityMaskInfo", &json!({})).unwrap();
|
||||
assert_eq!(info[0]["id"], obj.0);
|
||||
// Opacity and blend go to the masked object too, not to the mask art.
|
||||
s.execute("transparency.set", &json!({"opacity": 40, "blend": "Multiply"})).unwrap();
|
||||
let n = doc(&s).node(obj).unwrap();
|
||||
assert!((n.opacity - 0.4).abs() < 1e-6 && n.blend == vectorcraft_color::BlendMode::Multiply);
|
||||
let info = s.execute("transparency.info", &json!({})).unwrap();
|
||||
assert_eq!((info["ids"].clone(), info["opacity"].clone(), info["editingMask"].clone()), (json!([obj.0]), json!(40.0), json!(obj.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_while_editing_leaves_editing_first() {
|
||||
let mut s = session();
|
||||
let obj = editing_mask(&mut s);
|
||||
// Widen the mask while editing; releasing keeps that edit.
|
||||
s.execute("object.move", &json!({"dx": 50, "dy": 0})).unwrap();
|
||||
s.execute("transparency.releaseOpacityMask", &json!({})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.doc.mask_edit.is_none() && st.isolation.is_none() && !has_edit_layer(&st.doc));
|
||||
assert!(mask(&s, obj).is_none());
|
||||
let layer = &st.doc.layers[0];
|
||||
let kids = layer.children().unwrap();
|
||||
assert_eq!(kids.len(), 2, "the object and its released mask art");
|
||||
assert_eq!(kids[1].geometric_bounds().unwrap().x0, 50.0);
|
||||
assert_eq!(st.active_layer, Some(layer.id));
|
||||
// One undo step restores the mask and editing mode.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(mask(&s, obj).is_some() && doc(&s).mask_edit.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_object_gets_an_empty_mask_drawn_in_editing_mode() {
|
||||
let mut s = session();
|
||||
let obj = rect(&mut s, 0.0, 200.0);
|
||||
let r = s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
|
||||
assert_eq!(r, json!({"id": obj.0, "editing": true}));
|
||||
let me = doc(&s).mask_edit.unwrap();
|
||||
assert_eq!(me.object, obj);
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(me.layer));
|
||||
assert!(s.doc().unwrap().selection.is_empty());
|
||||
// An empty clipping mask hides the object; drawing white in editing mode reveals it there.
|
||||
let render = |s: &Session| vectorcraft_render::Renderer::new().render(doc(s), 300, 300, vectorcraft_geom::Affine::IDENTITY, &Default::default());
|
||||
assert_eq!(render(&s).pixel(50, 100)[3], 0);
|
||||
let m = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 200})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ffffff", "ids": [m]})).unwrap();
|
||||
let img = render(&s);
|
||||
assert!(img.pixel(50, 100)[3] > 200);
|
||||
assert_eq!(img.pixel(150, 100)[3], 0);
|
||||
s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap();
|
||||
assert_eq!(mask(&s, obj).unwrap().art.geometric_bounds().unwrap().x1, 100.0);
|
||||
// Making a mask while editing one is refused.
|
||||
s.execute("transparency.editOpacityMask", &json!({"id": obj.0})).unwrap();
|
||||
assert!(s.execute("transparency.makeOpacityMask", &json!({"ids": [obj.0]})).is_err());
|
||||
// One undo step undoes making the empty mask and entering editing; isolation follows.
|
||||
let mut s = session();
|
||||
let obj = rect(&mut s, 0.0, 200.0);
|
||||
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
|
||||
let layer = doc(&s).mask_edit.unwrap().layer;
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(mask(&s, obj).is_none() && doc(&s).mask_edit.is_none());
|
||||
assert_eq!(s.doc().unwrap().isolation, None);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!((s.doc().unwrap().isolation, s.doc().unwrap().active_layer), (Some(layer), Some(layer)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_and_transparency_commands_take_ids_without_a_selection() {
|
||||
let mut s = session();
|
||||
let (a, b) = (rect(&mut s, 0.0, 200.0), rect(&mut s, 0.0, 100.0));
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let r = s.execute("transparency.makeOpacityMask", &json!({"ids": [b.0, a.0], "invert": true})).unwrap();
|
||||
assert_eq!(r["id"], a.0, "the top object (b) masks the one below, whatever the order given");
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.execute("transparency.setOpacityMask", &json!({"id": a.0, "clip": false})).unwrap();
|
||||
s.execute("transparency.disableOpacityMask", &json!({"ids": [a.0]})).unwrap();
|
||||
let m = mask(&s, a).unwrap();
|
||||
assert!(!m.clip && m.invert && m.disabled);
|
||||
s.execute("transparency.set", &json!({"ids": [a.0], "opacity": 25})).unwrap();
|
||||
assert!((doc(&s).node(a).unwrap().opacity - 0.25).abs() < 1e-6);
|
||||
assert!(s.execute("transparency.set", &json!({"opacity": 25})).is_err(), "no selection, no ids");
|
||||
let layer = s.execute("transparency.editOpacityMask", &json!({"id": a.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert_eq!(doc(&s).mask_edit.unwrap().layer, NodeId(layer));
|
||||
s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.execute("transparency.releaseOpacityMask", &json!({"id": a.0})).unwrap();
|
||||
assert!(mask(&s, a).is_none());
|
||||
let err = s.execute("transparency.releaseOpacityMask", &json!({"id": a.0})).unwrap_err();
|
||||
assert!(err.to_string().contains("no target object has an opacity mask"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_is_a_percentage_everywhere() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 100.0);
|
||||
s.execute("appearance.setItem", &json!({"index": 0, "opacity": 1})).unwrap();
|
||||
let fill_opacity = |s: &Session| match &doc(s).node(a).unwrap().appearance.items[0] {
|
||||
vectorcraft_doc::AppearanceItem::Fill(f) => f.opacity,
|
||||
vectorcraft_doc::AppearanceItem::Stroke(st) => st.opacity,
|
||||
};
|
||||
assert!((fill_opacity(&s) - 0.01).abs() < 1e-6);
|
||||
s.execute("appearance.setItem", &json!({"index": 0, "opacity": 100})).unwrap();
|
||||
assert_eq!(fill_opacity(&s), 1.0);
|
||||
s.execute("object.setProps", &json!({"opacity": 1})).unwrap();
|
||||
assert!((doc(&s).node(a).unwrap().opacity - 0.01).abs() < 1e-6);
|
||||
s.execute("object.setProps", &json!({"opacity": 150})).unwrap();
|
||||
assert_eq!(doc(&s).node(a).unwrap().opacity, 1.0);
|
||||
s.execute("transparency.set", &json!({"opacity": 0.5})).unwrap();
|
||||
assert!((doc(&s).node(a).unwrap().opacity - 0.005).abs() < 1e-6);
|
||||
// Appearance items take ids without a selection too.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert!(s.execute("appearance.setItem", &json!({"index": 0, "opacity": 30})).is_err(), "no selection, no ids");
|
||||
s.execute("appearance.setItem", &json!({"index": 0, "ids": [a.0], "opacity": 30})).unwrap();
|
||||
assert!((fill_opacity(&s) - 0.3).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_reports_mixed_values_as_null() {
|
||||
let mut s = session();
|
||||
let (a, b) = (rect(&mut s, 0.0, 100.0), rect(&mut s, 120.0, 100.0));
|
||||
s.execute("transparency.set", &json!({"ids": [a.0], "opacity": 50, "isolate": true})).unwrap();
|
||||
s.execute("transparency.set", &json!({"ids": [a.0, b.0], "blend": "Screen"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let info = s.execute("transparency.info", &json!({})).unwrap();
|
||||
assert_eq!(info, json!({"ids": [a.0, b.0], "opacity": null, "blend": "Screen", "isolate": null, "knockout": false, "editingMask": null}));
|
||||
let one = s.execute("transparency.info", &json!({"id": a.0})).unwrap();
|
||||
assert_eq!((one["opacity"].clone(), one["isolate"].clone()), (json!(50.0), json!(true)));
|
||||
let i = s.transparency_info();
|
||||
assert_eq!((i.opacity, i.blend, i.knockout), (None, Some(vectorcraft_color::BlendMode::Screen), Some(false)));
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("transparency.info", &json!({})).unwrap()["opacity"], Value::Null);
|
||||
// Many targets are gathered in one walk of the tree.
|
||||
let ids: Vec<u64> = (0..12).map(|i| rect(&mut s, i as f64 * 20.0, 10.0).0).collect();
|
||||
s.execute("transparency.set", &json!({"ids": ids, "opacity": 30})).unwrap();
|
||||
let many = s.execute("transparency.info", &json!({"ids": ids})).unwrap();
|
||||
assert_eq!(many["opacity"], json!(30.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_while_editing_writes_no_editing_layer() {
|
||||
let mut s = session();
|
||||
let obj = editing_mask(&mut s);
|
||||
s.execute("object.move", &json!({"dx": 50, "dy": 0})).unwrap();
|
||||
let bytes = data(&s.execute("document.save", &json!({})).unwrap());
|
||||
let back = vectorcraft_format::load(&bytes).unwrap();
|
||||
assert!(back.mask_edit.is_none() && !has_edit_layer(&back));
|
||||
assert_eq!(back.layers.len(), 1);
|
||||
// The edit made while editing is in the saved mask.
|
||||
assert_eq!(back.node(obj).unwrap().mask.as_ref().unwrap().art.geometric_bounds().unwrap().x0, 50.0);
|
||||
// Still editing in the session.
|
||||
assert!(doc(&s).mask_edit.is_some() && has_edit_layer(doc(&s)));
|
||||
// A file saved mid-edit by an older version (editing record and layer written) opens clean.
|
||||
let mut v: Value = serde_json::from_slice(&vectorcraft_format::save(doc(&s), false)).unwrap();
|
||||
let full = serde_json::to_value(doc(&s)).unwrap();
|
||||
v["document"]["layers"] = full["layers"].clone();
|
||||
v["document"]["mask_edit"] = json!({"object": obj.0, "layer": doc(&s).mask_edit.unwrap().layer.0});
|
||||
let old = vectorcraft_format::load(&serde_json::to_vec(&v).unwrap()).unwrap();
|
||||
assert!(old.mask_edit.is_none() && !has_edit_layer(&old) && old.layers.len() == 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_while_editing_leave_the_editing_layer_out() {
|
||||
let mut s = session();
|
||||
editing_mask(&mut s);
|
||||
let svg = |s: &mut Session| s.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
let pdf = |s: &mut Session| data(&s.execute("document.export", &json!({"format": "pdf"})).unwrap());
|
||||
let png = |s: &mut Session| data(&s.execute("document.export", &json!({"format": "png"})).unwrap());
|
||||
let (svg_editing, pdf_editing, png_editing) = (svg(&mut s), pdf(&mut s), png(&mut s));
|
||||
assert!(!svg_editing.contains("Opacity Mask Editing") && !svg_editing.contains("Opacity_Mask_Editing"));
|
||||
// Before the fix the editing layer's art was written too: the PDF would differ from the one
|
||||
// the document gives without it (same length: only the creation date may differ).
|
||||
let raw = crate::export_pdf(doc(&s), &Default::default()).unwrap();
|
||||
assert_ne!(raw.len(), pdf_editing.len(), "the editing layer would add content");
|
||||
s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap();
|
||||
assert_eq!(svg(&mut s), svg_editing);
|
||||
assert_eq!(pdf(&mut s).len(), pdf_editing.len());
|
||||
assert_eq!(png(&mut s), png_editing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_and_redo_walk_in_and_out_of_mask_editing() {
|
||||
let mut s = session();
|
||||
let obj = rect(&mut s, 0.0, 200.0);
|
||||
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
|
||||
let layer = doc(&s).mask_edit.unwrap().layer;
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 200})).unwrap();
|
||||
s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap();
|
||||
let state = |s: &Session| {
|
||||
let st = s.doc().unwrap();
|
||||
(st.doc.mask_edit.map(|m| m.layer), st.isolation, st.active_layer == Some(layer), has_edit_layer(&st.doc))
|
||||
};
|
||||
let editing = (Some(layer), Some(layer), true, true);
|
||||
assert_eq!(state(&s), (None, None, false, false));
|
||||
// Back into editing with the drawn art, then before it, then before the mask.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(state(&s), editing);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(state(&s), editing);
|
||||
assert!(doc(&s).node(layer).unwrap().children().unwrap().is_empty());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(state(&s), (None, None, false, false));
|
||||
assert!(mask(&s, obj).is_none());
|
||||
// And forward again.
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(state(&s), editing);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(state(&s), (None, None, false, false));
|
||||
assert_eq!(mask(&s, obj).unwrap().art.geometric_bounds().unwrap().x1, 100.0);
|
||||
}
|
||||
@@ -162,7 +162,7 @@ fn eyedropper_copies_appearance_to_selection_and_defaults() {
|
||||
let src = rect(&mut s, 300.0, 300.0, 50.0, 50.0);
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 4})).unwrap();
|
||||
s.execute("object.setProps", &json!({"opacity": 0.5})).unwrap();
|
||||
s.execute("object.setProps", &json!({"opacity": 50})).unwrap();
|
||||
let dst = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
|
||||
s.execute("paint.default", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [dst.0]})).unwrap();
|
||||
|
||||
@@ -59,14 +59,16 @@ struct File {
|
||||
images: BTreeMap<String, Image>,
|
||||
}
|
||||
|
||||
/// Serialize a document (pretty = human-diffable).
|
||||
/// Serialize a document (pretty = human-diffable). Editing-mode working copies are left out
|
||||
/// ([`Document::without_edit_modes`]).
|
||||
pub fn save(doc: &Document, pretty: bool) -> Vec<u8> {
|
||||
let doc = doc.without_edit_modes();
|
||||
let images = doc.images.iter().map(|(k, b)| (k.clone(), Image { mime: b.mime.clone(), data: base64_encode(&b.bytes) })).collect();
|
||||
let f = File {
|
||||
format: "vectorcraft".into(),
|
||||
version: VERSION,
|
||||
generator: format!("VectorCraft {}", env!("CARGO_PKG_VERSION")),
|
||||
document: doc.clone(),
|
||||
document: doc.into_owned(),
|
||||
images,
|
||||
};
|
||||
if pretty { serde_json::to_vec_pretty(&f).unwrap_or_default() } else { serde_json::to_vec(&f).unwrap_or_default() }
|
||||
@@ -95,6 +97,8 @@ pub fn load(bytes: &[u8]) -> Result<Document, FormatError> {
|
||||
let bytes = base64_decode(&img.data).ok_or_else(|| FormatError::BadImage(k.clone()))?;
|
||||
doc.images.insert(k, ImageBlob { mime: img.mime, bytes: Arc::new(bytes) });
|
||||
}
|
||||
// Saved mid-edit by an older version: drop the opacity-mask editing layer.
|
||||
doc.drop_edit_modes();
|
||||
doc.fix_next_id();
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
@@ -393,32 +393,15 @@ impl Exporter<'_> {
|
||||
}
|
||||
}
|
||||
NodeKind::Group { children, clip: true } => {
|
||||
let clip = children.first().and_then(|c| match &c.kind {
|
||||
NodeKind::Path { path, rule, .. } => Some((path.to_bezpath(), *rule)),
|
||||
NodeKind::Compound { children, rule } => {
|
||||
let mut bp = BezPath::new();
|
||||
for ch in children {
|
||||
if let Some(p) = ch.path_data() {
|
||||
bp.extend(p.to_bezpath());
|
||||
}
|
||||
}
|
||||
Some((bp, *rule))
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
match clip.and_then(|(bp, r)| to_path(&bp).map(|p| (p, r))) {
|
||||
Some((p, r)) => {
|
||||
s.push_clip_path(&p, &rule(r));
|
||||
for c in children.iter().skip(1) {
|
||||
self.node(s, c, page, false);
|
||||
}
|
||||
s.pop();
|
||||
}
|
||||
None => {
|
||||
for c in children {
|
||||
self.node(s, c, page, false);
|
||||
}
|
||||
// The region every output clips to; nothing to clip by hides the clipped art.
|
||||
if let Some((clip, rest)) = children.split_first()
|
||||
&& let Some((p, r)) = vectorcraft_effects::clip_outline(clip).and_then(|(bp, r)| to_path(&bp).map(|p| (p, r)))
|
||||
{
|
||||
s.push_clip_path(&p, &rule(r));
|
||||
for c in rest {
|
||||
self.node(s, c, page, false);
|
||||
}
|
||||
s.pop();
|
||||
}
|
||||
}
|
||||
NodeKind::Path { path, rule, guide, .. } => {
|
||||
|
||||
+40
-15
@@ -198,9 +198,14 @@ struct GeomEntry {
|
||||
stamp: u64,
|
||||
}
|
||||
|
||||
/// A clipping path (kept alive so its address can't be reused) and the region it clips to.
|
||||
type ClipEntry = (Arc<Node>, Option<Arc<(BezPath, FillRule)>>);
|
||||
|
||||
/// Reusable renderer (keeps the render context, decoded images and glyph caches between frames).
|
||||
pub struct Renderer {
|
||||
texts: PtrMap<usize, (Arc<Node>, Arc<TextGeom>)>,
|
||||
/// Clip regions per clipping path (see [`Self::clip_of`]).
|
||||
clips: PtrMap<usize, ClipEntry>,
|
||||
/// Context reused when rendering single-threaded (`threads == 0`).
|
||||
ctx_st: Option<RenderContext>,
|
||||
/// Worker threads for the multithreaded rasterizer (0 = single-threaded).
|
||||
@@ -257,6 +262,7 @@ impl Renderer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
texts: PtrMap::default(),
|
||||
clips: PtrMap::default(),
|
||||
ctx_st: None,
|
||||
threads: default_threads(),
|
||||
geom: PtrMap::default(),
|
||||
@@ -415,6 +421,23 @@ impl Renderer {
|
||||
b
|
||||
}
|
||||
|
||||
/// Cached region a clip group's clipping path `a` clips to ([`effects::clip_outline`]):
|
||||
/// outlining text and uniting shapes is too slow to repeat every frame.
|
||||
fn clip_of(&mut self, a: &Arc<Node>) -> Option<Arc<(BezPath, FillRule)>> {
|
||||
let key = Arc::as_ptr(a) as usize;
|
||||
if let Some((node, region)) = self.clips.get(&key)
|
||||
&& Arc::ptr_eq(node, a)
|
||||
{
|
||||
return region.clone();
|
||||
}
|
||||
let region = effects::clip_outline(a).map(Arc::new);
|
||||
if self.clips.len() > 1024 {
|
||||
self.clips.clear();
|
||||
}
|
||||
self.clips.insert(key, (a.clone(), region.clone()));
|
||||
region
|
||||
}
|
||||
|
||||
/// Cached BezPath of a path node.
|
||||
fn path_of(&mut self, a: &Arc<Node>) -> Option<Arc<BezPath>> {
|
||||
let key = Arc::as_ptr(a) as usize;
|
||||
@@ -550,23 +573,23 @@ impl Renderer {
|
||||
self.draw_arc(ctx, f, c);
|
||||
}
|
||||
}
|
||||
NodeKind::Group { children, clip: true } if outline => {
|
||||
for c in children {
|
||||
self.draw_node(ctx, f, c, false);
|
||||
}
|
||||
}
|
||||
NodeKind::Group { children, clip: true } => {
|
||||
let clip = children.first().and_then(|c| c.path_data()).map(|p| p.to_bezpath());
|
||||
match (clip, outline) {
|
||||
(Some(clip), false) => {
|
||||
ctx.set_transform(f.view);
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
ctx.push_clip_layer(&clip);
|
||||
for c in children.iter().skip(1) {
|
||||
self.draw_arc(ctx, f, c);
|
||||
}
|
||||
ctx.pop_layer();
|
||||
}
|
||||
_ => {
|
||||
for c in children {
|
||||
self.draw_node(ctx, f, c, false);
|
||||
}
|
||||
// Nothing to clip by hides the clipped art (as in the SVG and PDF output).
|
||||
if let Some((clip, rest)) = children.split_first()
|
||||
&& let Some(region) = self.clip_of(clip)
|
||||
{
|
||||
ctx.set_transform(f.view);
|
||||
ctx.set_fill_rule(fill_rule(region.1));
|
||||
ctx.push_clip_layer(®ion.0);
|
||||
for c in rest {
|
||||
self.draw_arc(ctx, f, c);
|
||||
}
|
||||
ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
NodeKind::Path { path, rule, guide, .. } => {
|
||||
@@ -987,3 +1010,5 @@ fn now() -> u64 {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_clip;
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Clip groups clip by any clipping path (M3.26): compound holes, even-odd fills, the union of a
|
||||
//! group's members, text outlines; with nothing to clip by the clipped art is hidden.
|
||||
|
||||
use super::*;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, CharStyle};
|
||||
use vectorcraft_geom::{PathData, Point, SubPath, shapes};
|
||||
|
||||
const BLACK: [u8; 4] = [0, 0, 0, 255];
|
||||
const CLEAR: [u8; 4] = [0, 0, 0, 0];
|
||||
|
||||
fn path(d: &mut Document, p: PathData, rule: FillRule) -> Arc<Node> {
|
||||
let mut n = Node::path(d.alloc_id(), p, Appearance::basic(Paint::None, Paint::None, 0.0));
|
||||
if let NodeKind::Path { rule: r, .. } = &mut n.kind {
|
||||
*r = rule;
|
||||
}
|
||||
Arc::new(n)
|
||||
}
|
||||
|
||||
fn rect(r: Rect) -> PathData {
|
||||
shapes::rectangle(r)
|
||||
}
|
||||
|
||||
/// `clip` clipping a black square over the whole 100×100 canvas, rendered transparent.
|
||||
fn clipped(mut d: Document, clip: Arc<Node>) -> Rendered {
|
||||
let art = Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
|
||||
let g = Node::new(d.alloc_id(), NodeKind::Group { children: vec![clip, Arc::new(art)], clip: true });
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, g).unwrap();
|
||||
Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_clip_keeps_its_hole() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let (outer, inner) = (rect(Rect::new(10.0, 10.0, 90.0, 90.0)), rect(Rect::new(35.0, 35.0, 65.0, 65.0)));
|
||||
let children = vec![path(&mut d, outer, FillRule::NonZero), path(&mut d, inner, FillRule::NonZero)];
|
||||
let donut = Arc::new(Node::new(d.alloc_id(), NodeKind::Compound { children, rule: FillRule::EvenOdd }));
|
||||
let r = clipped(d, donut);
|
||||
assert_eq!(r.pixel(20, 50), BLACK, "the ring");
|
||||
assert_eq!(r.pixel(50, 50), CLEAR, "the hole stays transparent");
|
||||
assert_eq!(r.pixel(5, 5), CLEAR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn even_odd_star_clips_with_an_empty_centre() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
// A pentagram drawn in one stroke: its centre pentagon is outside under even-odd.
|
||||
let pts: Vec<Point> = (0..5)
|
||||
.map(|k| {
|
||||
let a = (k * 2 % 5) as f64 * std::f64::consts::TAU / 5.0 - std::f64::consts::FRAC_PI_2;
|
||||
Point::new(50.0 + 48.0 * a.cos(), 52.0 + 48.0 * a.sin())
|
||||
})
|
||||
.collect();
|
||||
let star = path(&mut d, PathData::single(SubPath::polyline(&pts, true)), FillRule::EvenOdd);
|
||||
let r = clipped(d, star);
|
||||
assert_eq!(r.pixel(50, 52), CLEAR, "the centre is empty");
|
||||
assert_eq!(r.pixel(50, 12), BLACK, "a point");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_clip_is_the_union_of_its_members() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
// Opposite windings: a plain concatenation would cancel out where they overlap.
|
||||
let mut b = rect(Rect::new(40.0, 0.0, 100.0, 50.0));
|
||||
b.reverse();
|
||||
let members = vec![path(&mut d, rect(Rect::new(0.0, 0.0, 60.0, 50.0)), FillRule::NonZero), path(&mut d, b, FillRule::NonZero)];
|
||||
let g = Arc::new(Node::group(d.alloc_id(), members));
|
||||
let r = clipped(d, g);
|
||||
for x in [20, 50, 80] {
|
||||
assert_eq!(r.pixel(x, 25), BLACK, "x = {x}");
|
||||
}
|
||||
assert_eq!(r.pixel(50, 75), CLEAR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_clips_by_its_glyph_outlines() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let t = vectorcraft_doc::TextObject::point(Point::new(5.0, 80.0), "O", CharStyle { size: 90.0, ..CharStyle::default() });
|
||||
let text = Arc::new(Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))));
|
||||
let b = text.geometric_bounds().unwrap();
|
||||
let r = clipped(d, text);
|
||||
let cy = ((b.y0 + b.y1) / 2.0) as u32;
|
||||
// Across the middle of the "O": clear, ring, the counter (clear), ring, clear.
|
||||
let row: Vec<bool> = (0..100).map(|x| r.pixel(x, cy)[3] > 128).collect();
|
||||
let runs = row.windows(2).filter(|w| w[0] != w[1]).count();
|
||||
assert_eq!(runs, 4, "two ring crossings with the counter between: {row:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_to_clip_by_hides_the_art() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let empty = Arc::new(Node::group(d.alloc_id(), vec![]));
|
||||
let r = clipped(d, empty);
|
||||
assert_eq!(r.pixel(50, 50), CLEAR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_regions_are_cached_per_clipping_path() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let clip = path(&mut d, rect(Rect::new(0.0, 0.0, 50.0, 100.0)), FillRule::NonZero);
|
||||
let mut r = Renderer::new();
|
||||
let first = r.clip_of(&clip).unwrap();
|
||||
assert!(Arc::ptr_eq(&first, &r.clip_of(&clip).unwrap()));
|
||||
assert_eq!(first.1, FillRule::NonZero);
|
||||
}
|
||||
@@ -530,32 +530,6 @@ impl Writer<'_> {
|
||||
self.line("</g>");
|
||||
}
|
||||
|
||||
/// `(d, rule)` of every path in a clipping object.
|
||||
fn clip_shapes(&self, n: &Node, out: &mut Vec<(String, FillRule)>) {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, rule, .. } => out.push((self.path_d(path, self.xf), *rule)),
|
||||
NodeKind::Compound { children, rule } => {
|
||||
let d: Vec<String> = children.iter().filter_map(|c| c.path_data()).map(|p| self.path_d(p, self.xf)).collect();
|
||||
out.push((d.join(" "), *rule));
|
||||
}
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
|
||||
for c in children {
|
||||
self.clip_shapes(c, out);
|
||||
}
|
||||
}
|
||||
NodeKind::Image(im) => {
|
||||
let r = Rect::new(0.0, 0.0, im.width as f64, im.height as f64);
|
||||
let p = vectorcraft_geom::shapes::rectangle(r).transformed(im.xf);
|
||||
out.push((self.path_d(&p, self.xf), FillRule::NonZero));
|
||||
}
|
||||
NodeKind::Text(_) | NodeKind::SymbolInstance { .. } => {}
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
|
||||
let g = vectorcraft_doc::live::expand_deep(n, None);
|
||||
self.clip_shapes(&g, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An object with an opacity mask: `<g mask="url(#…)">` around the unmasked object. The mask
|
||||
/// art goes in `<defs>`; no-clip adds a white backdrop and invert a colour-inverting filter.
|
||||
fn masked(&mut self, n: &Node, m: &vectorcraft_doc::OpacityMask) {
|
||||
@@ -622,11 +596,12 @@ impl Writer<'_> {
|
||||
}
|
||||
NodeKind::Group { children, clip: true } => {
|
||||
let Some((clip, rest)) = children.split_first() else { return };
|
||||
let mut shapes = Vec::new();
|
||||
self.clip_shapes(clip, &mut shapes);
|
||||
let cid = self.fresh_id("clip-path");
|
||||
self.def(1, &format!("<clipPath id=\"{cid}\">"));
|
||||
for (d, rule) in shapes {
|
||||
// The region every output clips to; with nothing to clip by, an empty clip path
|
||||
// hides the clipped art.
|
||||
if let Some((bp, rule)) = vectorcraft_effects::clip_outline(clip) {
|
||||
let d = self.path_d(&PathData::from_bezpath(&bp), self.xf);
|
||||
let r = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" };
|
||||
self.def(2, &format!("<path d=\"{d}\"{r}/>"));
|
||||
}
|
||||
|
||||
@@ -338,7 +338,7 @@ fn opacity_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, opaci
|
||||
egui::Popup::menu(&resp)
|
||||
.open_memory(clicked.then_some(egui::SetOpenCommand::Toggle))
|
||||
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
|
||||
.show(|ui| match widgets::opacity_blend(ui, ("ap-op", item), opacity, blend, true) {
|
||||
.show(|ui| match widgets::opacity_blend(ui, ("ap-op", item), Some(opacity), Some(blend), true) {
|
||||
Some(TransparencyEdit::Blend(b)) => {
|
||||
app.run("transparency.set", json!({"item": item, "blend": b.label()})).ok();
|
||||
}
|
||||
|
||||
@@ -298,7 +298,7 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
ui.label(egui::RichText::new("Opacity").size(12.0));
|
||||
ui.add_space(8.0);
|
||||
if let Some(o) = widgets::plain_field(ui, "ap-op", n.opacity as f64 * 100.0, "%", 0, 64.0) {
|
||||
app.run("object.setProps", json!({"opacity": o.clamp(0.0, 100.0) / 100.0})).ok();
|
||||
app.run("object.setProps", json!({"opacity": o.clamp(0.0, 100.0)})).ok();
|
||||
}
|
||||
if widgets::icon_button(ui, "ellipsis", "Transparency", false, 22.0).clicked() {
|
||||
app.ui.open_panel = Some("transparency".into());
|
||||
|
||||
@@ -5,28 +5,51 @@
|
||||
use egui::{Sense, Stroke, StrokeKind, Ui, vec2};
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::{Node, NodeId, OpacityMask};
|
||||
use vectorcraft_engine::cmd::TransparencyInfo;
|
||||
|
||||
use super::{current_paints, current_transparency, first_selected, live_run, pstate, selection_len, set_pstate};
|
||||
use super::{current_paints, current_transparency, live_run, pstate, selection_len, set_pstate};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{self, TransparencyEdit, menu_item};
|
||||
use crate::{VectorcraftApp, icons};
|
||||
|
||||
/// The panel's values ([`vectorcraft_engine::Session::transparency_info`]) and its first target
|
||||
/// (the selection, or the object whose mask is being edited), recomputed only when the document
|
||||
/// or the selection changes.
|
||||
fn state(app: &VectorcraftApp, ctx: &egui::Context) -> (TransparencyInfo, Option<Node>) {
|
||||
type Key = (u64, u64, usize, Option<NodeId>, Option<NodeId>);
|
||||
let Some(st) = app.session.active() else { return Default::default() };
|
||||
let sel = &st.selection.objects;
|
||||
let key: Key = (st.uid, st.revision, sel.len(), sel.first().copied(), sel.last().copied());
|
||||
let id = egui::Id::new("tr-state");
|
||||
if let Some((k, v)) = ctx.data(|d| d.get_temp::<(Key, (TransparencyInfo, Option<Node>))>(id))
|
||||
&& k == key
|
||||
{
|
||||
return v;
|
||||
}
|
||||
let info = app.session.transparency_info();
|
||||
let first = info.ids.first().and_then(|id| st.doc.node(*id)).cloned();
|
||||
let v = (info, first);
|
||||
ctx.data_mut(|d| d.insert_temp(id, (key, v.clone())));
|
||||
v
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// While editing a mask the panel shows the masked object (the selection is its mask art).
|
||||
let editing = app.session.active().and_then(|d| d.doc.mask_edit);
|
||||
let n = match editing {
|
||||
Some(me) => app.session.active().and_then(|d| d.doc.node(me.object).cloned()),
|
||||
None => first_selected(app),
|
||||
};
|
||||
let (info, n) = state(app, ui.ctx());
|
||||
let has = n.is_some();
|
||||
let (isolate, knockout) = n.as_ref().map_or((false, false), |n| (n.isolate, n.knockout));
|
||||
// Opacity and blend of the Appearance panel's active item, else of the object.
|
||||
let (op, blend) = match editing {
|
||||
None => current_transparency(app),
|
||||
Some(_) => n.as_ref().map(|n| (n.opacity, n.blend)),
|
||||
}
|
||||
.unwrap_or((1.0, BlendMode::Normal));
|
||||
let (isolate, knockout) = (info.isolate.unwrap_or(false), info.knockout.unwrap_or(false));
|
||||
// Opacity and blend of the Appearance panel's active item (not while a mask is edited: the
|
||||
// panel then shows the masked object), else of the targets, blank where they differ; with no
|
||||
// target, the defaults for new art.
|
||||
let item = editing.is_none().then(|| app.session.appearance_item()).flatten().and_then(|_| current_transparency(app));
|
||||
let (op, blend) = match item {
|
||||
Some((o, b)) => (Some(o), Some(b)),
|
||||
None if has => (info.opacity.map(|o| (o / 100.0) as f32), info.blend),
|
||||
None => (Some(1.0), Some(BlendMode::Normal)),
|
||||
};
|
||||
match widgets::opacity_blend(ui, "tr", op, blend, has) {
|
||||
Some(TransparencyEdit::Blend(b)) => {
|
||||
app.run("transparency.set", json!({"blend": b.label()})).ok();
|
||||
@@ -37,7 +60,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
widgets::divider(ui);
|
||||
// Thumbnails and the opacity-mask controls.
|
||||
let hide_thumbs: bool = pstate(ui.ctx(), "tr-hide-thumbs");
|
||||
let mask = n.as_ref().and_then(|n| n.mask.as_deref().cloned());
|
||||
let mask = n.as_ref().and_then(|n| n.mask.as_deref());
|
||||
let (new_clip, new_invert) = app.session.new_mask_defaults();
|
||||
ui.horizontal(|ui| {
|
||||
if !hide_thumbs {
|
||||
@@ -63,8 +86,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
}
|
||||
match &mask {
|
||||
Some(m) => {
|
||||
match (mask, n.as_ref().map(|n| n.id)) {
|
||||
(Some(m), Some(id)) => {
|
||||
// Link toggle between the object and its mask.
|
||||
let (lr, lresp) = ui.allocate_exact_size(vec2(14.0, 50.0), Sense::click());
|
||||
icons::paint(
|
||||
@@ -96,11 +119,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if shift {
|
||||
app.run(if m.disabled { "transparency.enableOpacityMask" } else { "transparency.disableOpacityMask" }, json!({})).ok();
|
||||
} else if editing.is_none() {
|
||||
app.run("transparency.editOpacityMask", json!({})).ok();
|
||||
app.run("transparency.editOpacityMask", json!({"id": id.0})).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
_ => {
|
||||
let (m, _) = ui.allocate_exact_size(vec2(50.0, 50.0), Sense::hover());
|
||||
ui.painter().rect_stroke(m, 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
|
||||
icons::paint(ui, "dc-mask-none", m.shrink(12.0), t.text_disabled);
|
||||
@@ -108,15 +131,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
ui.vertical(|ui| {
|
||||
let can_make = selection_len(app) >= 2;
|
||||
let label = if mask.is_some() { "Release" } else { "Make Mask" };
|
||||
let enabled = mask.is_some() || can_make;
|
||||
let enabled = mask.is_some() || (has && editing.is_none());
|
||||
let r = ui.add_enabled_ui(enabled, |ui| widgets::flat_button(ui, label, 96.0)).inner;
|
||||
if r.on_disabled_hover_text("Select the art and, on top of it, the mask object").clicked() {
|
||||
if r.on_disabled_hover_text("Select the art (and, on top of it, the mask object)").clicked() {
|
||||
let id = if mask.is_some() { "transparency.releaseOpacityMask" } else { "transparency.makeOpacityMask" };
|
||||
app.run(id, json!({})).ok();
|
||||
}
|
||||
let (clip, invert) = mask.as_ref().map(|m| (m.clip, m.invert)).unwrap_or((new_clip, new_invert));
|
||||
let (clip, invert) = mask.map(|m| (m.clip, m.invert)).unwrap_or((new_clip, new_invert));
|
||||
if widgets::check(ui, "Clip", clip, mask.is_some()) {
|
||||
app.run("transparency.setOpacityMask", json!({"clip": !clip})).ok();
|
||||
}
|
||||
@@ -150,9 +172,32 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
set_pstate(ui.ctx(), "tr-hide-options", !hide_opts);
|
||||
}
|
||||
ui.separator();
|
||||
let mask = first_selected(app).and_then(|n| n.mask.as_deref().cloned());
|
||||
let can_make = selection_len(app) >= 2 && mask.is_none();
|
||||
let items: [(&str, &str, bool); 4] = match &mask {
|
||||
// The masked object's mask, also while editing it (the selection then is the mask art).
|
||||
let editing = app.session.active().is_some_and(|d| d.doc.mask_edit.is_some());
|
||||
let (_, n) = state(app, ui.ctx());
|
||||
let mask = n.as_ref().and_then(|n| n.mask.as_deref());
|
||||
for (label, id, enabled) in mask_items(mask, n.is_some() && !editing) {
|
||||
if menu_item(ui, label, enabled, false) {
|
||||
app.run(id, json!({})).ok();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
let (clip, invert) = app.session.new_mask_defaults();
|
||||
if menu_item(ui, "New Opacity Masks Are Clipping", true, clip) {
|
||||
app.run("transparency.toggleNewMasksClipping", json!({})).ok();
|
||||
}
|
||||
if menu_item(ui, "New Opacity Masks Are Inverted", true, invert) {
|
||||
app.run("transparency.toggleNewMasksInverted", json!({})).ok();
|
||||
}
|
||||
ui.separator();
|
||||
menu_item(ui, "Page Isolated Blending", false, false);
|
||||
menu_item(ui, "Page Knockout Group", false, false);
|
||||
}
|
||||
|
||||
/// The menu's opacity-mask rows (label, command, enabled) for the target's `mask`; Make needs a
|
||||
/// target without one, outside mask editing (`can_make`).
|
||||
fn mask_items(mask: Option<&OpacityMask>, can_make: bool) -> [(&'static str, &'static str, bool); 4] {
|
||||
match mask {
|
||||
Some(m) => [
|
||||
("Make Opacity Mask", "transparency.makeOpacityMask", false),
|
||||
("Release Opacity Mask", "transparency.releaseOpacityMask", true),
|
||||
@@ -173,23 +218,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
("Disable Opacity Mask", "", false),
|
||||
("Unlink Opacity Mask", "", false),
|
||||
],
|
||||
};
|
||||
for (label, id, enabled) in items {
|
||||
if menu_item(ui, label, enabled, false) {
|
||||
app.run(id, json!({})).ok();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
let (clip, invert) = app.session.new_mask_defaults();
|
||||
if menu_item(ui, "New Opacity Masks Are Clipping", true, clip) {
|
||||
app.run("transparency.toggleNewMasksClipping", json!({})).ok();
|
||||
}
|
||||
if menu_item(ui, "New Opacity Masks Are Inverted", true, invert) {
|
||||
app.run("transparency.toggleNewMasksInverted", json!({})).ok();
|
||||
}
|
||||
ui.separator();
|
||||
menu_item(ui, "Page Isolated Blending", false, false);
|
||||
menu_item(ui, "Page Knockout Group", false, false);
|
||||
}
|
||||
|
||||
/// A rendered thumbnail of `n` (which may live outside the tree, like mask art). One texture per
|
||||
@@ -224,14 +253,80 @@ mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
/// Run the panel and its ≡ menu for one headless frame.
|
||||
fn frame(app: &mut VectorcraftApp) {
|
||||
/// Run the panel and its menu for one headless frame: the texts drawn.
|
||||
fn frame(app: &mut VectorcraftApp) -> Vec<String> {
|
||||
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
show(app, ui);
|
||||
menu(app, ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
let mut v = vec![];
|
||||
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
|
||||
v
|
||||
}
|
||||
|
||||
fn run(app: &mut VectorcraftApp, id: &str, p: serde_json::Value) -> serde_json::Value {
|
||||
app.session.execute(id, &p).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_in_mask_editing_mode_shows_and_edits_the_masked_object() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
|
||||
let a = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap();
|
||||
run(&mut app, "transparency.set", json!({"opacity": 60}));
|
||||
// One object: an empty mask, drawn in editing mode.
|
||||
run(&mut app, "transparency.makeOpacityMask", json!({}));
|
||||
run(&mut app, "shape.ellipse", json!({"x": 0, "y": 0, "width": 30, "height": 30}));
|
||||
let texts = frame(&mut app);
|
||||
assert!(texts.iter().any(|t| t == "60%"), "the masked object's opacity, not the mask art's: {texts:?}");
|
||||
assert!(texts.iter().any(|t| t == "Release"));
|
||||
let (info, n) = state(&app, &egui::Context::default());
|
||||
assert_eq!((info.ids, n.as_ref().map(|n| n.id.0)), (vec![vectorcraft_doc::NodeId(a)], Some(a)));
|
||||
// The menu is enabled for the masked object while editing (Make is not).
|
||||
let items = mask_items(n.as_ref().and_then(|n| n.mask.as_deref()), false);
|
||||
assert_eq!(items.map(|(_, _, on)| on), [false, true, true, true]);
|
||||
// The panel's controls run without ids and reach the masked object.
|
||||
run(&mut app, "transparency.setOpacityMask", json!({"clip": false}));
|
||||
run(&mut app, "transparency.unlinkOpacityMask", json!({}));
|
||||
run(&mut app, "transparency.set", json!({"blend": "Multiply"}));
|
||||
frame(&mut app);
|
||||
let st = app.session.active().unwrap();
|
||||
let obj = st.doc.node(vectorcraft_doc::NodeId(a)).unwrap();
|
||||
let m = obj.mask.as_ref().unwrap();
|
||||
assert!(!m.clip && !m.linked && obj.blend == BlendMode::Multiply);
|
||||
assert!(st.doc.mask_edit.is_some());
|
||||
// Release leaves editing and puts the drawn mask art back.
|
||||
run(&mut app, "transparency.releaseOpacityMask", json!({}));
|
||||
assert!(frame(&mut app).iter().any(|t| t == "Make Mask"));
|
||||
assert!(app.session.active().unwrap().doc.mask_edit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_values_show_blank() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
|
||||
let a = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].clone();
|
||||
run(&mut app, "transparency.set", json!({"opacity": 50, "blend": "Screen"}));
|
||||
let b = run(&mut app, "shape.rectangle", json!({"x": 60, "y": 0, "width": 30, "height": 30}))["id"].clone();
|
||||
run(&mut app, "select.set", json!({"ids": [a, b]}));
|
||||
let texts = frame(&mut app);
|
||||
assert!(!texts.iter().any(|t| t.ends_with('%') || t == "Screen" || t == "Normal"), "{texts:?}");
|
||||
run(&mut app, "select.set", json!({"ids": [a]}));
|
||||
let texts = frame(&mut app);
|
||||
assert!(texts.iter().any(|t| t == "50%") && texts.iter().any(|t| t == "Screen"), "{texts:?}");
|
||||
// Nothing selected: the defaults for new art, disabled.
|
||||
run(&mut app, "select.none", json!({}));
|
||||
let texts = frame(&mut app);
|
||||
assert!(texts.iter().any(|t| t == "100%") && texts.iter().any(|t| t == "No Selection"), "{texts:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -132,13 +132,27 @@ pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value:
|
||||
|
||||
/// A plain number field (percent, degrees, counts) with optional suffix.
|
||||
pub fn plain_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: f64, suffix: &str, decimals: usize, width: f32) -> Option<f64> {
|
||||
mixed_field(ui, id, Some(value), suffix, decimals, width)
|
||||
}
|
||||
|
||||
/// [`plain_field`] for a value the selected objects may not share: `None` shows a blank field.
|
||||
pub fn mixed_field(
|
||||
ui: &mut Ui,
|
||||
id: impl std::hash::Hash + std::fmt::Debug,
|
||||
value: Option<f64>,
|
||||
suffix: &str,
|
||||
decimals: usize,
|
||||
width: f32,
|
||||
) -> Option<f64> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let id = ui.id().with(id);
|
||||
let shown = {
|
||||
let s = format!("{:.*}", decimals, value);
|
||||
let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s };
|
||||
format!("{s}{suffix}")
|
||||
};
|
||||
let shown = value
|
||||
.map(|value| {
|
||||
let s = format!("{:.*}", decimals, value);
|
||||
let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s };
|
||||
format!("{s}{suffix}")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let editing = ui.memory(|m| m.has_focus(id));
|
||||
let mut buf: String = if editing { ui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_else(|| shown.clone()) } else { shown.clone() };
|
||||
let resp = ui
|
||||
@@ -312,15 +326,15 @@ pub fn blend_separator_before(i: usize) -> bool {
|
||||
}
|
||||
|
||||
/// The blend-mode dropdown, its groups separated (Normal | darken | lighten | contrast | inversion
|
||||
/// | component modes). Returns the chosen mode.
|
||||
pub fn blend_dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: BlendMode, width: f32) -> Option<BlendMode> {
|
||||
combo(ui, id, current.label(), width, |ui| {
|
||||
/// | component modes). `None` (objects that differ) shows blank. Returns the chosen mode.
|
||||
pub fn blend_dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: Option<BlendMode>, width: f32) -> Option<BlendMode> {
|
||||
combo(ui, id, current.map_or("", BlendMode::label), width, |ui| {
|
||||
let mut chosen = None;
|
||||
for (i, m) in BlendMode::ALL.into_iter().enumerate() {
|
||||
if blend_separator_before(i) {
|
||||
ui.separator();
|
||||
}
|
||||
if ui.selectable_label(m == current, m.label()).clicked() {
|
||||
if ui.selectable_label(Some(m) == current, m.label()).clicked() {
|
||||
chosen = Some(m);
|
||||
}
|
||||
}
|
||||
@@ -336,7 +350,7 @@ pub fn blend_param(key: &str, value: &serde_json::Value) -> Option<BlendMode> {
|
||||
|
||||
/// [`blend_dropdown`] for a blend-mode parameter; returns the chosen mode's parameter value.
|
||||
pub fn blend_param_dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: BlendMode) -> Option<serde_json::Value> {
|
||||
blend_dropdown(ui, id, current, 120.0).map(|m| serde_json::Value::String(m.label().to_ascii_lowercase()))
|
||||
blend_dropdown(ui, id, Some(current), 120.0).map(|m| serde_json::Value::String(m.label().to_ascii_lowercase()))
|
||||
}
|
||||
|
||||
/// An edit made with [`opacity_blend`].
|
||||
@@ -349,11 +363,12 @@ pub enum TransparencyEdit {
|
||||
|
||||
/// The transparency controls shared by the Transparency panel and the Appearance panel's Opacity
|
||||
/// popups: the grouped blend-mode dropdown, the opacity percent field and its slider popup.
|
||||
/// `opacity` (0..1) and `blend` are `None` where the objects differ (shown blank).
|
||||
pub fn opacity_blend(
|
||||
ui: &mut Ui,
|
||||
id: impl std::hash::Hash + std::fmt::Debug + Copy,
|
||||
opacity: f32,
|
||||
blend: BlendMode,
|
||||
opacity: Option<f32>,
|
||||
blend: Option<BlendMode>,
|
||||
enabled: bool,
|
||||
) -> Option<TransparencyEdit> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -367,7 +382,7 @@ pub fn opacity_blend(
|
||||
dim_label(ui, "Opacity:");
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
ui.add_enabled_ui(enabled, |ui| {
|
||||
if let Some(o) = plain_field(ui, (id, "opacity"), opacity as f64 * 100.0, "%", 0, 50.0) {
|
||||
if let Some(o) = mixed_field(ui, (id, "opacity"), opacity.map(|o| o as f64 * 100.0), "%", 0, 50.0) {
|
||||
edit = Some(TransparencyEdit::Opacity(o.clamp(0.0, 100.0), Live::Released));
|
||||
}
|
||||
});
|
||||
@@ -375,7 +390,7 @@ pub fn opacity_blend(
|
||||
ui.painter().rect_stroke(r, 2, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
|
||||
icons::paint(ui, "chevron-right", r.shrink2(vec2(3.0, 7.0)), if enabled { t.icon } else { t.text_disabled });
|
||||
egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
let mut o = opacity * 100.0;
|
||||
let mut o = opacity.unwrap_or(1.0) * 100.0;
|
||||
let r = ui.add(egui::Slider::new(&mut o, 0.0..=100.0).show_value(false));
|
||||
let phase = if r.drag_stopped() || (r.changed() && !r.dragged()) {
|
||||
Live::Released
|
||||
|
||||
+27
@@ -163,3 +163,30 @@ vectorcraft-cli run --cmd file.new --params '{"width":800,"height":600}' \
|
||||
applies to the `--cmd` just before it. `run` also accepts the host commands `file.open`, `file.save`, `file.export`,
|
||||
`file.exportForScreens` and `tool.select`. `run --in`, `convert` and `info` read every format `document.open` reads
|
||||
(`vectorcraft-cli --help` lists them).
|
||||
|
||||
## Transparency and opacity masks
|
||||
|
||||
Transparency and opacity-mask commands take `ids` (or `id`), so they need no selection. Opacity is a percentage
|
||||
(0..100) in every command (`transparency.set`, `object.setProps`, `appearance.setItem`). `transparency.info` returns
|
||||
the Transparency panel's values, with `null` where the objects differ. Making a mask from one object gives it an
|
||||
empty mask and enters mask editing: art drawn then becomes the mask, and the mask commands act on the masked object
|
||||
until `transparency.stopEditingOpacityMask`. Saves and exports never include the editing layer.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"transparency.set","params":{"ids":[12],"opacity":40,"blend":"Multiply"}}}
|
||||
{"name":"run_command","arguments":{"command":"transparency.makeOpacityMask","params":{"ids":[12,15],"invert":true}}}
|
||||
{"name":"run_command","arguments":{"command":"transparency.setOpacityMask","params":{"id":12,"clip":false}}}
|
||||
{"name":"run_command","arguments":{"command":"transparency.info","params":{"ids":[12,20]}}}
|
||||
```
|
||||
|
||||
## Clipping masks
|
||||
|
||||
`object.clippingMask.make` clips the selected objects by the topmost one, which may be a path, a compound path or a
|
||||
text object (it loses its paint). Compound holes, even-odd fills, glyph outlines and the union of a group's members
|
||||
clip alike on screen, in raster export and in SVG and PDF. `object.clippingMask.release` turns the clip group into a
|
||||
plain group and keeps the clipping path, unpainted.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"select.set","params":{"ids":[12,15]}}}
|
||||
{"name":"run_command","arguments":{"command":"object.clippingMask.make","params":{}}}
|
||||
```
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ pub const TABLE: &[(&str, Class)] = &[
|
||||
/// The L0 foundation is a small chain: `raster` builds on `color` and
|
||||
/// `geom`, which the §3 diagram draws on one line. The GPU backend (`gpu`)
|
||||
/// reuses the CPU reference (`compose`) for LUTs and parity tests.
|
||||
pub const INTRA_LAYER_ORDER: &[&[&str]] = &[&["geom", "color"], &["pathops", "effects"], &["pathops", "trace"]];
|
||||
pub const INTRA_LAYER_ORDER: &[&[&str]] = &[&["geom", "color"], &["pathops", "effects"], &["pathops", "trace"], &["text", "effects"]];
|
||||
|
||||
fn intra_layer_allowed(from: &str, to: &str) -> bool {
|
||||
let (from, to) = (short_name(from), short_name(to));
|
||||
|
||||
Reference in New Issue
Block a user