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:
bflatastic
2026-10-04 22:07:49 +07:00
31 changed files with 1303 additions and 261 deletions
Generated
+1
View File
@@ -4122,6 +4122,7 @@ dependencies = [
"vectorcraft-doc",
"vectorcraft-geom",
"vectorcraft-pathops",
"vectorcraft-text",
]
[[package]]
+11 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+1
View File
@@ -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 }
+26
View File
@@ -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()
}
+4
View File
@@ -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};
+7 -3
View File
@@ -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;
+4
View File
@@ -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 }))
}
+7 -2
View File
@@ -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),
+107 -42
View File
@@ -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());
+3
View File
@@ -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;
+32 -8
View File
@@ -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,
+182 -49
View File
@@ -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();
+18 -17
View File
@@ -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))
+4
View File
@@ -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;
+3 -3
View File
@@ -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"}));
+172
View File
@@ -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)]);
}
+268
View File
@@ -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);
}
+1 -1
View File
@@ -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();
+6 -2
View File
@@ -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)
}
+8 -25
View File
@@ -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
View File
@@ -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(&region.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;
+106
View File
@@ -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);
}
+4 -29
View File
@@ -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}/>"));
}
+1 -1
View File
@@ -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();
}
+1 -1
View File
@@ -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());
+137 -42
View File
@@ -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]
+29 -14
View File
@@ -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
View File
@@ -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
View File
@@ -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));