Merge pull request #5226 from dotCooCoo/feat/compound-shape-blend-envelope-members

Pathfinder: blends, envelopes and warped objects can be compound shape members
This commit is contained in:
BFlat
2026-11-02 16:05:44 +07:00
committed by GitHub
11 changed files with 867 additions and 67 deletions
+197 -27
View File
@@ -19,11 +19,13 @@
use std::sync::Arc;
use serde_json::Value;
use vectorcraft_doc::color::{Color, Paint};
use vectorcraft_doc::color::{BlendMode, Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeId, NodeKind, ShapeMode, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData, Rect};
use vectorcraft_pathops::{self as po, PathfinderOp};
use crate::reshape::SymbolNodes;
/// Effect ids of the Pathfinder submenu with their operation, in Illustrator's menu order.
pub const PATHFINDER_EFFECTS: [(&str, &str, PathfinderOp); 12] = [
("pathfinder.add", "Add", PathfinderOp::Unite),
@@ -61,35 +63,188 @@ pub fn has_pathfinder(n: &Node) -> bool {
/// with the object's opacity and blend mode.
pub type OutlineHook<'a> = &'a dyn Fn(&Node) -> Option<Node>;
fn leaves(n: &Node, hook: Option<OutlineHook>, out: &mut Vec<Node>) {
/// The paths and compound paths `n` takes part in a compound shape or a Pathfinder operation as,
/// back to front: a compound shape as its area, a blend, an envelope or an object with a warp as
/// the art it draws, and type and symbol instances as their art through `hook`. Past
/// [`SYMBOL_ART_MAX_NODES`](crate::SYMBOL_ART_MAX_NODES) objects of symbol art, further
/// instances add nothing.
pub fn member_leaves(n: &Node, hook: Option<OutlineHook>) -> Vec<Node> {
let mut out = vec![];
leaves(n, hook, &SymbolNodes::default(), &mut out);
out
}
/// [`member_leaves`], with the symbol art given so far counted in `nodes`.
fn leaves(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes, out: &mut Vec<Node>) {
if !n.visible {
return;
}
// A blend, an envelope or an object with a warp takes part as the art it draws.
if let Some(art) = shape_art_in(n, hook, nodes) {
let first = out.len();
leaves(&art, hook, nodes, out);
// Every path of a live object's, type's or symbol instance's art is drawn inside the
// object's opacity and blend mode.
if crate::needs_outline(n) {
for l in out.iter_mut().skip(first) {
within(l, n);
}
}
return;
}
match &n.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => out.push(n.clone()),
// A compound shape takes part as its area, styled like it.
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => match compound_shape_art(n, hook) {
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => match compound_shape_art_in(n, hook, nodes) {
Some(art) => out.push(art),
None => {
for c in children {
leaves(c, hook, out);
leaves(c, hook, nodes, out);
}
}
},
_ => {
// Every path of the outline art, at any depth, takes the object's opacity and blend
// mode.
if let Some(art) = hook.and_then(|h| h(n)) {
// Every path of the outline art, at any depth, is drawn inside the object's opacity
// and blend mode. A symbol's art comes with the live objects and the instances of
// other symbols inside it given as their art, and the type inside it outlined, so the
// art has nothing left for `hook` to give.
if let Some(h) = hook
&& let Some(art) = h(n)
{
let art = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } if nodes.charge(&art) => {
let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol);
crate::reshape::outline_nested_text(&mut art, false);
transparency_into_members(&mut art);
art
}
NodeKind::SymbolInstance { .. } => return,
_ => art,
};
let first = out.len();
leaves(&art, None, out);
leaves(&art, None, nodes, out);
for l in out.iter_mut().skip(first) {
(l.opacity, l.blend) = (n.opacity, n.blend);
within(l, n);
}
}
}
}
}
/// `l`, a path drawn inside `n`: its opacity multiplied by `n`'s, and `n`'s blend mode when it is
/// not Normal.
fn within(l: &mut Node, n: &Node) {
drawn_in(l, n.opacity, n.blend);
}
/// `l` drawn inside a group of `opacity` and `blend`: its opacity multiplied by `opacity`, and
/// `blend` when it is not Normal.
fn drawn_in(l: &mut Node, opacity: f32, blend: BlendMode) {
l.opacity *= opacity;
if blend != BlendMode::Normal {
l.blend = blend;
}
}
/// Move the opacity and blend mode of every group and layer inside `n` (not `n` itself, and not
/// compound shapes, which take part with their own) onto the objects it holds ([`drawn_in`]), so
/// that each path of `n` carries the transparency it is drawn with.
fn transparency_into_members(n: &mut Node) {
let Some(ch) = n.children_mut() else { return };
for c in ch.iter_mut() {
if !is_container(c) || c.is_compound_shape() {
continue;
}
let c = Arc::make_mut(c);
let (opacity, blend) = (c.opacity, c.blend);
if let Some(inner) = c.children_mut() {
for g in inner.iter_mut() {
drawn_in(Arc::make_mut(g), opacity, blend);
}
}
(c.opacity, c.blend) = (1.0, BlendMode::Normal);
transparency_into_members(c);
}
}
/// `n` as the art its visible geometry gives a compound shape or a Pathfinder operation, where that
/// is not its own paths: a live object evaluated (a blend's steps, an envelope's distorted art,
/// type inside it outlined), and a path, compound path, group, compound shape, live object, type or
/// symbol instance with visible geometry effects (such as a warp) reshaped by them, as the renderer
/// draws it. Type and symbol instances inside the art are given as their art by `hook` (without
/// one, type is outlined and symbol instances stay as they are);
/// [`outline_object`](crate::outline_object) gives a symbol instance its symbol's art. The art
/// keeps the effects of `n` other than its geometry effects, and the groups inside it pass their
/// opacity and blend mode to the objects they hold. `None` when `n` is none of these, for images,
/// for symbol instances without geometry effects, and for one without art from `hook`.
pub fn shape_art(n: &Node, hook: Option<OutlineHook>) -> Option<Node> {
shape_art_in(n, hook, &SymbolNodes::default())
}
/// [`shape_art`], with the symbol art given so far counted in `nodes`.
fn shape_art_in(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Option<Node> {
let mut art = reshaped_art(n, hook, nodes)?;
transparency_into_members(&mut art);
Some(art)
}
/// [`shape_art_in`] before the groups inside the art pass on their transparency.
fn reshaped_art(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Option<Node> {
let fx = crate::reshape::geometry_effects(n);
let outline = hook.unwrap_or(&crate::outline_text);
match &n.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } if !fx.is_empty() => Some(reshaped(n, n)),
NodeKind::Group { .. } | NodeKind::Layer { .. } if !fx.is_empty() => match compound_shape_art_in(n, hook, nodes) {
Some(area) => Some(reshaped(&area, n)),
None => {
let mut m = crate::reshape::outlined_members_with(None, n, outline, nodes);
crate::reshape::reshape_leaves(&mut m, &fx);
without_geometry_effects(&mut m);
Some(m)
}
},
// A symbol instance with a warp: its symbol's art placed by the instance, with the type
// inside it outlined, reshaped as one piece, as the renderer draws it.
NodeKind::SymbolInstance { symbol, .. } if !fx.is_empty() => {
let h = hook?;
let art = h(n).filter(|art| nodes.charge(art))?;
let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol);
crate::reshape::outline_nested_text(&mut art, false);
crate::reshape::reshape_leaves(&mut art, &fx);
(art.opacity, art.blend, art.isolate) = (n.opacity, n.blend, n.isolate);
art.appearance.effects = n.appearance.effects.clone();
without_geometry_effects(&mut art);
Some(art)
}
NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => None,
_ if crate::needs_outline(n) && (vectorcraft_doc::live::is_live(n) || !fx.is_empty()) => {
crate::reshape::as_art_with(None, n, None, outline, nodes)
}
_ => None,
}
}
/// `src`, a path, compound path or compound shape's area, as one path reshaped by the visible
/// geometry effects of `n`, without those effects.
fn reshaped(src: &Node, n: &Node) -> Node {
let mut m = src.clone();
if let Some((path, rule)) = geometry(src) {
let bounds = vectorcraft_geom::Shape::bounding_box(&path.to_bezpath());
let g = crate::apply_geometry_with(&n.appearance.effects, &path, bounds, &crate::GeomContext::of(n));
m.kind = NodeKind::Path { path: g, rule, live: None, clipping: false, guide: false };
}
without_geometry_effects(&mut m);
m
}
fn without_geometry_effects(n: &mut Node) {
if n.appearance.effects.iter().any(|e| crate::is_geometry(&e.id)) {
let mut ap = (*n.appearance).clone();
ap.effects.retain(|e| !crate::is_geometry(&e.id));
n.appearance = ap.into();
}
}
fn geometry(n: &Node) -> Option<(PathData, FillRule)> {
match &n.kind {
NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)),
@@ -114,8 +269,13 @@ fn shape_op(mode: ShapeMode) -> po::BoolOp {
/// as its outlines through `hook`, open paths closed) combined with the area of the members below
/// it by the member's shape mode; the backmost member starts the area. `None` when `n` is not a
/// compound shape, or its members are past a live Pathfinder's budget ([`PATHFINDER_MAX_SEGMENTS`],
/// [`PATHFINDER_MAX_OVERLAPS`]).
/// [`PATHFINDER_MAX_OVERLAPS`], [`SYMBOL_ART_MAX_NODES`](crate::SYMBOL_ART_MAX_NODES)).
pub fn compound_shape_area(n: &Node, hook: Option<OutlineHook>) -> Option<PathData> {
compound_shape_area_in(n, hook, &SymbolNodes::default())
}
/// [`compound_shape_area`], with the symbol art given so far counted in `nodes`.
fn compound_shape_area_in(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Option<PathData> {
if !n.is_compound_shape() {
return None;
}
@@ -124,12 +284,12 @@ pub fn compound_shape_area(n: &Node, hook: Option<OutlineHook>) -> Option<PathDa
let mut members: Vec<(po::BoolOp, usize)> = vec![];
for c in n.children()? {
let before = all.len();
leaves(c, hook, &mut all);
leaves(c, hook, nodes, &mut all);
if all.len() > before {
members.push((shape_op(c.extras().shape_mode), all.len() - before));
}
}
if !within_budget(&all) {
if nodes.spent() || !within_budget(&all) {
return None;
}
if all.is_empty() {
@@ -151,7 +311,12 @@ pub fn compound_shape_area(n: &Node, hook: Option<OutlineHook>) -> Option<PathDa
/// Compound shape `n` as one path of its area ([`compound_shape_area`]) with its appearance,
/// opacity and blend mode: how it takes part in a Pathfinder operation.
pub fn compound_shape_art(n: &Node, hook: Option<OutlineHook>) -> Option<Node> {
let area = compound_shape_area(n, hook)?;
compound_shape_art_in(n, hook, &SymbolNodes::default())
}
/// [`compound_shape_art`], with the symbol art given so far counted in `nodes`.
fn compound_shape_art_in(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Option<Node> {
let area = compound_shape_area_in(n, hook, nodes)?;
let mut art = Node::path(n.id, area, Appearance { items: n.appearance.items.clone(), ..Default::default() });
(art.opacity, art.blend) = (n.opacity, n.blend);
Some(art)
@@ -256,7 +421,8 @@ fn within_budget<'a>(leaves: impl IntoIterator<Item = &'a Node>) -> bool {
/// Evaluate the visible Pathfinder effects of `group` in order: the resulting child objects.
/// `None` when the group has no Pathfinder effect. Past the budget ([`PATHFINDER_MAX_SEGMENTS`],
/// [`PATHFINDER_MAX_OVERLAPS`]) it returns the children as they are.
/// [`PATHFINDER_MAX_OVERLAPS`], [`SYMBOL_ART_MAX_NODES`](crate::SYMBOL_ART_MAX_NODES) for
/// each effect) it returns the children as they are.
pub fn pathfinder_children(group: &Node, hook: Option<OutlineHook>) -> Option<Vec<Arc<Node>>> {
if !has_pathfinder(group) {
return None;
@@ -274,11 +440,11 @@ pub fn pathfinder_children(group: &Node, hook: Option<OutlineHook>) -> Option<Ve
};
for e in group.appearance.effects.iter().filter(|e| e.visible) {
if e.id == crate::trap::TRAP {
let mut lv = vec![];
let (mut lv, nodes) = (vec![], SymbolNodes::default());
for c in &stack {
leaves(c, hook, &mut lv);
leaves(c, hook, &nodes, &mut lv);
}
if !within_budget(&lv) {
if nodes.spent() || !within_budget(&lv) {
return Some(source.to_vec());
}
let (ids, srcs): (Vec<NodeId>, Vec<crate::trap::TrapSource>) = lv
@@ -295,15 +461,16 @@ pub fn pathfinder_children(group: &Node, hook: Option<OutlineHook>) -> Option<Ve
let Some(op) = op_of(&e.id) else { continue };
let shape_mode =
matches!(op, PathfinderOp::Unite | PathfinderOp::MinusFront | PathfinderOp::Intersect | PathfinderOp::Exclude | PathfinderOp::MinusBack);
let nodes = SymbolNodes::default();
let all: Vec<Vec<Node>> = stack
.iter()
.map(|c| {
let mut lv = vec![];
leaves(c, hook, &mut lv);
leaves(c, hook, &nodes, &mut lv);
lv
})
.collect();
if !within_budget(all.iter().flatten()) {
if nodes.spent() || !within_budget(all.iter().flatten()) {
return Some(source.to_vec());
}
// (style source, shape) per stack entry; keys index `sources`.
@@ -440,9 +607,10 @@ pub fn has_container_appearance(n: &Node) -> bool {
/// The regions a group's or layer's own fills and strokes paint, in document space, each with its
/// fill rule: every visible member path and compound path, type as its glyph outlines and live
/// objects evaluated; the members of a clip group or clipping layer without its clipping path.
/// Images, symbol instances and guides add nothing. A Repeat counts the Repeats in the art of
/// `doc`'s symbols that its instances show toward its instance budget.
/// objects evaluated; a compound shape as its area, with the symbol instances among its members
/// as their symbols' art from `doc`; the members of a clip group or clipping layer without its
/// clipping path. Images, other symbol instances and guides add nothing. A Repeat counts the
/// Repeats in the art of `doc`'s symbols that its instances show toward its instance budget.
pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRule)> {
fn push(doc: Option<&Document>, n: &Node, out: &mut Vec<(PathData, FillRule)>) {
if !n.visible {
@@ -451,8 +619,8 @@ pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRul
match &n.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => out.extend(crate::reshape::leaf_geometry(n)),
NodeKind::Group { .. } if n.is_compound_shape() => {
let hook: &dyn Fn(&Node) -> Option<Node> = &crate::outline_text;
match compound_shape_area(n, Some(hook)) {
let hook = |c: &Node| crate::outline_object(doc, c);
match compound_shape_area(n, Some(&hook)) {
Some(area) => out.push((area, FillRule::NonZero)),
None => members(doc, n, out),
}
@@ -518,7 +686,9 @@ fn item_art(id: NodeId, item: &AppearanceItem, shapes: &[(PathData, FillRule)],
/// own fills and strokes turned into art painting the members' geometry ([`member_shapes`]) below
/// the members (after a clipping path) or above them, as its contents slot says. Its
/// transparency, opacity mask and raster effects stay. `None` when it has none of these. Live
/// objects among the members are evaluated with the symbols of `doc` (see [`member_shapes`]).
/// objects among the members are evaluated with the symbols of `doc` (see [`member_shapes`]), and
/// a Pathfinder operation or compound shape takes the symbol instances among its members as their
/// symbols' art from `doc`.
pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option<Node> {
if !is_container(n) {
return None;
@@ -531,8 +701,8 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option<Node> {
}
let mut m = n.clone();
if pathfinder {
let hook: &dyn Fn(&Node) -> Option<Node> = &crate::outline_text;
let result = pathfinder_children(n, Some(hook)).unwrap_or_default();
let hook = |c: &Node| crate::outline_object(doc, c);
let result = pathfinder_children(n, Some(&hook)).unwrap_or_default();
if let Some(ch) = m.children_mut() {
*ch = result;
}
+4 -4
View File
@@ -71,16 +71,16 @@ pub use bake::{StrokeArt, bake_appearance, bake_document, expand_art, expand_lea
pub use clip::clip_outline;
pub use group::{
OutlineHook, PATHFINDER_EFFECTS, compound_shape_area, compound_shape_art, evaluate_container, has_container_appearance, has_pathfinder,
is_pathfinder, member_shapes, paints, pathfinder_children,
is_pathfinder, member_leaves, member_shapes, paints, pathfinder_children, shape_art,
};
pub use lighting_presets::{LIGHTING_PRESETS, LightingPreset, lighting_preset_params, matching_lighting_preset};
pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner};
pub use live::{expand_live, expand_live_deep, expanded_live_group, outline_object, text_outliner};
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
pub use raster::{RASTERIZE, RasterFx, Rasterize, outset, raster_effects};
pub use reshape::{
expand_outlined, has_gradient_copies, has_item_geometry, has_transform_art, has_transform_options, image_paint, needs_outline, outline_art,
outline_text, reshape,
SYMBOL_ART_MAX_NODES, expand_outlined, has_gradient_copies, has_item_geometry, has_transform_art, has_transform_options, image_paint,
needs_outline, outline_art, outline_text, reshape,
};
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
pub use solids::{
+4 -3
View File
@@ -21,8 +21,9 @@ pub fn text_outliner() -> Outliner<'static> {
/// Type outlined, or a symbol instance as its symbol's art (from `doc`) placed by the instance,
/// through the symbol's 9-slice guides when it has them ([`Document::place_symbol_art`], `None`
/// past its object limit).
fn outline(doc: Option<&Document>, n: &Node) -> Option<Node> {
/// past its object limit). As an [`OutlineHook`](crate::OutlineHook), it gives compound shapes and
/// Pathfinder operations the symbol instances among their members.
pub fn outline_object(doc: Option<&Document>, n: &Node) -> Option<Node> {
match &n.kind {
NodeKind::SymbolInstance { symbol, xf } => {
let doc = doc?;
@@ -38,7 +39,7 @@ fn outline(doc: Option<&Document>, n: &Node) -> Option<Node> {
/// Run `f` with the hooks live evaluation needs; symbols and patterns come from `doc` (without a
/// document, symbol instances and pattern tiles stay as they are).
fn with_hooks<R>(doc: Option<&Document>, f: impl FnOnce(Hooks) -> R) -> R {
let outline = |n: &Node| outline(doc, n);
let outline = |n: &Node| outline_object(doc, n);
let tiles = |name: &str, xf: Affine, region: Rect| Some(doc?.pattern(name)?.instances_in(xf, region));
f(Hooks { outline: Some(&outline), appearance: Some(&bake_appearance), pattern: Some(&tiles), symbols: doc })
}
+107 -6
View File
@@ -17,7 +17,7 @@ use vectorcraft_doc::live::Hooks;
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, shapes};
use crate::{Budget, GeomContext, apply_one, is_geometry, merged_params};
use crate::{Budget, GeomContext, OutlineHook, apply_one, is_geometry, merged_params};
/// Can object-level effects apply to `n` only through [`outline_art`] / an offscreen layer (it is
/// neither a path nor a container)?
@@ -249,6 +249,11 @@ fn with_id(art: &Node, id: NodeId) -> Node {
/// through the guides could hold more than [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects. A Repeat counts the Repeats in the art of
/// `doc`'s symbols that its instances show toward its instance budget.
pub fn outline_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
outline_art_with(doc, n, symbol_art, &outline_text, &SymbolNodes::default())
}
/// [`outline_art`] with live objects evaluated through `outline` ([`evaluated_with`]).
fn outline_art_with(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outline: OutlineHook, nodes: &SymbolNodes) -> Option<Node> {
let mut art = match &n.kind {
NodeKind::Text(_) => outline_text(n)?,
NodeKind::SymbolInstance { symbol, xf } => {
@@ -272,7 +277,7 @@ pub fn outline_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>)
one.appearance.effects.clear();
Node::group(n.id, vec![Arc::new(one)])
}
_ if vectorcraft_doc::live::is_live(n) => evaluated(doc, n),
_ if vectorcraft_doc::live::is_live(n) => evaluated_with(doc, n, outline, nodes),
_ => return None,
};
outline_nested_text(&mut art, false);
@@ -290,8 +295,93 @@ pub fn outline_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>)
/// Repeats among them count the Repeats in the art of `doc`'s symbols that their instances show
/// toward their instance budget ([`vectorcraft_doc::RepeatSpec::expand`]).
pub(crate) fn evaluated(doc: Option<&Document>, n: &Node) -> Node {
let hook: &dyn Fn(&Node) -> Option<Node> = &outline_text;
vectorcraft_doc::live::expand_deep_hooks(n, Hooks { outline: Some(hook), symbols: doc, ..Default::default() })
evaluated_with(doc, n, &outline_text, &SymbolNodes::default())
}
/// Most objects of symbol art that one compound shape or live Pathfinder operation gives its
/// members in all ([`SymbolNodes`]): four for each segment the operation takes in
/// ([`crate::group::PATHFINDER_MAX_SEGMENTS`]).
pub const SYMBOL_ART_MAX_NODES: u64 = 4 * crate::group::PATHFINDER_MAX_SEGMENTS as u64;
/// The objects of symbol art given to one compound shape or live Pathfinder operation so far,
/// counted against [`SYMBOL_ART_MAX_NODES`]. Instances of symbols that hold several instances of
/// others multiply at each level, which the depth limit does not stop.
#[derive(Debug, Default)]
pub(crate) struct SymbolNodes(std::cell::Cell<u64>);
impl SymbolNodes {
/// Count `art`'s objects: `false` once they would pass [`SYMBOL_ART_MAX_NODES`], and from then
/// on for all art.
pub(crate) fn charge(&self, art: &Node) -> bool {
let total = self.0.get().saturating_add(art.count() as u64);
let fits = total <= SYMBOL_ART_MAX_NODES;
self.0.set(if fits { total } else { u64::MAX });
fits
}
/// Has some symbol art been left out for passing [`SYMBOL_ART_MAX_NODES`]?
pub(crate) fn spent(&self) -> bool {
self.0.get() > SYMBOL_ART_MAX_NODES
}
}
/// [`evaluated`] with type and symbol instances inside live objects given as their art by
/// `outline`, and every symbol instance left in the result (a blend's steps, an instance in a
/// plain group) replaced by its art from `outline`, with the instance's name, visibility and
/// transparency. The live objects and symbol instances inside that art are given as their art in
/// turn, except an instance of a symbol that holds an instance of itself, of a symbol nested
/// more than [`vectorcraft_doc::depth::MAX_DEPTH`] deep, or whose art would take `nodes` past
/// [`SYMBOL_ART_MAX_NODES`], which stays as it is. With [`outline_text`], symbol instances stay as
/// they are.
pub(crate) fn evaluated_with(doc: Option<&Document>, n: &Node, outline: OutlineHook, nodes: &SymbolNodes) -> Node {
resolved(doc, n, outline, nodes, &[])
}
/// `art`, the art `outline` gave an instance of the symbol named `symbol`, with the live objects
/// and symbol instances inside it given as their art as [`evaluated_with`] does.
pub(crate) fn symbol_art_with(art: &Node, outline: OutlineHook, nodes: &SymbolNodes, symbol: &str) -> Node {
resolved(None, art, outline, nodes, &[symbol.to_string()])
}
/// [`evaluated_with`] inside the art of the symbols named in `shown`, outermost first.
fn resolved(doc: Option<&Document>, n: &Node, outline: OutlineHook, nodes: &SymbolNodes, shown: &[String]) -> Node {
let inner = |m: &Node| art_of(doc, m, outline, nodes, shown);
let mut art = vectorcraft_doc::live::expand_deep_hooks(n, Hooks { outline: Some(&inner), symbols: doc, ..Default::default() });
instances_as_art(doc, &mut art, outline, nodes, shown);
art
}
/// `n`'s art from `outline`, inside the art of the symbols named in `shown`. For a symbol instance
/// it is the symbol's art with the live objects and instances inside it given as their art in turn
/// ([`resolved`]); `None` for an instance of a symbol in `shown` (one that holds an instance of
/// itself), for every instance once `shown` holds [`vectorcraft_doc::depth::MAX_DEPTH`] symbols,
/// and for an instance whose symbol's art `nodes` cannot take ([`SymbolNodes::charge`]).
fn art_of(doc: Option<&Document>, n: &Node, outline: OutlineHook, nodes: &SymbolNodes, shown: &[String]) -> Option<Node> {
let NodeKind::SymbolInstance { symbol, .. } = &n.kind else { return outline(n) };
if shown.len() >= vectorcraft_doc::depth::MAX_DEPTH || shown.contains(symbol) {
return None;
}
let art = outline(n).filter(|art| nodes.charge(art))?;
let mut inside = shown.to_vec();
inside.push(symbol.clone());
Some(resolved(doc, &art, outline, nodes, &inside))
}
/// Replace every symbol instance under `n` for which [`art_of`] gives art by that art. `shown`
/// names the symbols whose art `n` is in, outermost first.
fn instances_as_art(doc: Option<&Document>, n: &mut Node, outline: OutlineHook, nodes: &SymbolNodes, shown: &[String]) {
let Some(ch) = n.children_mut() else { return };
for c in ch.iter_mut() {
if matches!(c.kind, NodeKind::SymbolInstance { .. }) {
if let Some(mut o) = art_of(doc, c, outline, nodes, shown) {
o.name = c.name.clone();
(o.visible, o.opacity, o.blend, o.isolate) = (c.visible, c.opacity, c.blend, c.isolate);
*c = Arc::new(o);
}
} else if c.children().is_some() {
instances_as_art(doc, Arc::make_mut(c), outline, nodes, shown);
}
}
}
/// Replace type anywhere inside `n` by its outlines, as art that keeps the type's look
@@ -356,6 +446,11 @@ pub fn expand_outlined(doc: Option<&Document>, n: &Node, symbol_art: Option<&Nod
/// [`reshape`] without its checks (no geometry effects: just the art).
pub(crate) fn as_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
as_art_with(doc, n, symbol_art, &outline_text, &SymbolNodes::default())
}
/// [`as_art`] with live objects evaluated through `outline` ([`evaluated_with`]).
pub(crate) fn as_art_with(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outline: OutlineHook, nodes: &SymbolNodes) -> Option<Node> {
let fx = geometry_effects(n);
let mut art = match &n.kind {
NodeKind::Image(im) => {
@@ -383,7 +478,7 @@ pub(crate) fn as_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>
Node::group(n.id, rows(below).into_iter().chain([Arc::new(image)]).chain(rows(above)).collect())
}
}
_ => outline_art(doc, n, symbol_art)?,
_ => outline_art_with(doc, n, symbol_art, outline, nodes)?,
};
reshape_leaves(&mut art, &fx);
transform_items(&mut art);
@@ -897,7 +992,13 @@ fn placed_count(n: &Node) -> usize {
/// `n` with type anywhere inside it outlined and live objects evaluated, so geometry effects on
/// it reach every member.
pub(crate) fn outlined_members(doc: Option<&Document>, n: &Node) -> Node {
let mut art = evaluated(doc, n);
outlined_members_with(doc, n, &outline_text, &SymbolNodes::default())
}
/// [`outlined_members`] with live objects and symbol instances given as art through `outline`
/// ([`evaluated_with`]).
pub(crate) fn outlined_members_with(doc: Option<&Document>, n: &Node, outline: OutlineHook, nodes: &SymbolNodes) -> Node {
let mut art = evaluated_with(doc, n, outline, nodes);
outline_nested_text(&mut art, false);
art
}
+42 -10
View File
@@ -11,7 +11,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind, ShapeMode};
use vectorcraft_render::effects;
use super::edit::{roots_of, selected_roots};
use super::pathops::{pf_leaves, shape_node, style_of};
use super::pathops::{Style, pf_leaves, shape_node, style_of};
use super::{CommandSpec, EngineError, Result, Session, bad, cmd, has_multi};
const MODE_NAMES: [&str; 4] = ["add", "subtract", "intersect", "exclude"];
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make Compound Shape",
["Window", "Pathfinder"],
None,
"{ids?: [id…] (selected first; default: the selection), mode?: \"add\"|\"subtract\"|\"intersect\"|\"exclude\" (default add; Alt-clicking a Shape Mode button gives its mode)} the two or more selected objects (paths, compound paths, groups, compound shapes, type) become the members of one compound shape, each with the shape mode, in their stacking order where the frontmost was: it draws as the area they make from the backmost up (open paths closed), painted with the appearance and transparency of the topmost member in Add, Intersect or Exclude mode (with every member in Subtract, the backmost), and the members stay editable (Direct Selection, Layers panel, isolation mode) → {id}",
"{ids?: [id…] (selected first; default: the selection), mode?: \"add\"|\"subtract\"|\"intersect\"|\"exclude\" (default add; Alt-clicking a Shape Mode button gives its mode)} the two or more selected objects (paths, compound paths, groups, compound shapes, type, blends, envelopes) become the members of one compound shape, each with the shape mode, in their stacking order where the frontmost was: it draws as the area they make from the backmost up (open paths closed; a blend as its steps, an envelope as its distorted art, an object with a warp or other geometry effect as it draws), painted with the appearance and transparency of the topmost member in Add, Intersect or Exclude mode (with every member in Subtract, the backmost), and the members stay editable (Direct Selection, Layers panel, isolation mode) → {id}",
has_multi,
make
),
@@ -140,19 +140,24 @@ fn make(s: &mut Session, p: &Value) -> Result<Value> {
let mut members = Vec::with_capacity(roots.len());
for id in &roots {
let n = d.node(*id).ok_or(EngineError::NoNode(*id))?;
if !matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } | NodeKind::Group { .. } | NodeKind::Text(_)) {
return Err(bad(C, "compound shapes take paths, compound paths, groups, compound shapes and type"));
if !matches!(
n.kind,
NodeKind::Path { guide: false, .. }
| NodeKind::Compound { .. }
| NodeKind::Group { .. }
| NodeKind::Text(_)
| NodeKind::Blend { .. }
| NodeKind::Envelope { .. }
) {
return Err(bad(C, "compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes"));
}
members.push(n);
}
// The paint and transparency of the topmost member in Add, Intersect or Exclude mode: as they
// all have `mode`, the topmost, or the backmost when they all subtract.
let order: Vec<&Node> = if mode == ShapeMode::Subtract { members } else { members.into_iter().rev().collect() };
let style = order.into_iter().find_map(|n| {
let mut lv = vec![];
pf_leaves(n, &mut lv);
lv.last().map(style_of)
});
let hook = |c: &Node| effects::outline_object(Some(d), c);
let style = order.into_iter().find_map(|n| top_style(d, n, &hook));
let id = s.edit("Make Compound Shape", |d, sel| {
let gid = d.alloc_id();
let id = d.wrap(&roots, |children| {
@@ -177,6 +182,32 @@ fn make(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "id": id.0 }))
}
/// The paint and transparency of the frontmost shape `n` adds to a compound shape's area, from the
/// same objects the area takes: a blend, an envelope or an object with a warp gives those of the
/// art it draws, without its geometry effects; a symbol instance those of its symbol's art (with
/// `hook`); type its first style; a compound shape its own; a group those of its frontmost member
/// that has a shape. A shape taken from inside a group, or from the art of a blend, an envelope or
/// a warped group, also takes that object's opacity and blend mode.
fn top_style(d: &Document, n: &Node, hook: effects::OutlineHook) -> Option<Style> {
if let Some(art) = effects::shape_art(n, Some(hook)) {
let mut lv = vec![];
pf_leaves(d, &art, &mut lv);
let inside = matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && !art.is_compound_shape();
return lv.last().map(|l| if inside { style_of(l).within(n) } else { style_of(l) });
}
match &n.kind {
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } if !n.is_compound_shape() => {
children.iter().rev().find_map(|c| top_style(d, c, hook)).map(|s| s.within(n))
}
NodeKind::SymbolInstance { .. } => effects::member_leaves(n, Some(hook)).last().map(style_of),
_ => {
let mut lv = vec![];
pf_leaves(d, n, &mut lv);
lv.last().map(style_of)
}
}
}
fn set_mode(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.compoundShape.setMode";
let mode = mode_param(p, C, None)?;
@@ -235,7 +266,8 @@ fn expand(s: &mut Session, _: &Value) -> Result<Value> {
let (mut items, mut made) = (vec![], vec![]);
for id in &shapes {
let shape = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
let area = effects::compound_shape_area(&shape, effects::text_outliner())
let hook = |c: &Node| effects::outline_object(Some(d), c);
let area = effects::compound_shape_area(&shape, Some(&hook))
.ok_or_else(|| EngineError::Other("the compound shape is too complex to expand".into()))?;
if area.is_empty() {
return Err(EngineError::Other(format!("{LABEL} produced an empty result")));
+32 -9
View File
@@ -241,6 +241,12 @@ impl Style {
n.opacity = self.opacity;
n.blend = self.blend;
}
/// The style of a shape drawn inside `n`: its opacity multiplied by `n`'s, and `n`'s blend
/// mode when it is not Normal.
pub(crate) fn within(self, n: &Node) -> Self {
let blend = if n.blend == BlendMode::Normal { self.blend } else { n.blend };
Style { opacity: self.opacity * n.opacity, blend, ..self }
}
}
/// All subpaths of a path or compound path node, in document coordinates, with its fill rule.
@@ -295,13 +301,15 @@ fn each_leaf(n: &Node, f: &mut impl FnMut(&Node)) {
}
/// [`leaves`] as a Pathfinder operation takes them: a compound shape is one shape, the area it
/// draws painted as it is (its members as they are past a live Pathfinder's budget).
pub(crate) fn pf_leaves(n: &Node, out: &mut Vec<Node>) {
/// draws painted as it is, with the symbol instances among its members as their symbols' art from
/// `d` (its members as they are past a live Pathfinder's budget).
pub(crate) fn pf_leaves(d: &Document, n: &Node, out: &mut Vec<Node>) {
match &n.kind {
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
match vectorcraft_render::effects::compound_shape_art(n, vectorcraft_render::effects::text_outliner()) {
let hook = |c: &Node| vectorcraft_render::effects::outline_object(Some(d), c);
match vectorcraft_render::effects::compound_shape_art(n, Some(&hook)) {
Some(art) => out.push(art),
None => children.iter().for_each(|c| pf_leaves(c, out)),
None => children.iter().for_each(|c| pf_leaves(d, c, out)),
}
}
_ => leaves(n, out),
@@ -309,12 +317,27 @@ pub(crate) fn pf_leaves(n: &Node, out: &mut Vec<Node>) {
}
/// How many shapes a Pathfinder operation takes from `n` selected alone: its leaf shapes, however
/// deep its groups nest (an expanded stroke is a group inside the group, #1635).
/// deep its groups nest (an expanded stroke is a group inside the group, #1635). Inside a compound
/// shape, a blend, an envelope or another live object and a symbol instance count as one shape
/// each, as its area takes the art they draw.
pub fn shape_count(n: &Node) -> usize {
// Counted without copying the shapes: the panel asks every frame.
let mut count = 0;
each_leaf(n, &mut |_| count += 1);
count
fn count(n: &Node, in_shape: bool, total: &mut usize) {
match &n.kind {
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
let in_shape = in_shape || n.is_compound_shape();
for c in children {
count(c, in_shape, total);
}
}
NodeKind::SymbolInstance { .. } if in_shape => *total += 1,
_ if in_shape && vectorcraft_doc::live::is_live(n) => *total += 1,
_ => each_leaf(n, &mut |_| *total += 1),
}
}
let mut total = 0;
count(n, false, &mut total);
total
}
/// A node for `path`: a plain path for one subpath, else a compound path.
@@ -568,7 +591,7 @@ fn run_pf(s: &mut Session, p: &Value, op: PathfinderOp) -> Result<Value> {
for id in &roots {
let n = d.node(*id).ok_or(EngineError::NoNode(*id))?;
let mut lv = vec![];
pf_leaves(n, &mut lv);
pf_leaves(d, n, &mut lv);
if lv.is_empty() {
continue;
}
+472 -2
View File
@@ -1,8 +1,9 @@
//! Pathfinder panel › compound shapes (`object.compoundShape.*`): Make Compound Shape in each
//! shape mode, a member's mode, Release, Expand, Undo, saving and exporting.
//! shape mode, a member's mode, Release, Expand, Undo, saving and exporting; blends, envelopes and
//! warped objects as members.
use serde_json::{Value, json};
use vectorcraft_color::Color;
use vectorcraft_color::{BlendMode, Color};
use vectorcraft_doc::{Node, ShapeMode};
use vectorcraft_render::effects;
@@ -194,3 +195,472 @@ fn pathfinder_operations_take_the_area() {
let n = node(&s, NodeId(out["ids"][0].as_u64().unwrap()));
assert!((node_area(&n) - 10_000.0).abs() < 0.5, "the 50 × 100 area and the 50 × 100 rectangle: {}", node_area(&n));
}
fn rect(s: &mut Session, x: f64, y: f64, width: f64, height: f64) -> NodeId {
NodeId(run(s, "shape.rectangle", json!({"x": x, "y": y, "width": width, "height": height}))["id"].as_u64().unwrap())
}
fn make(s: &mut Session, mode: &str) -> NodeId {
NodeId(run(s, "object.compoundShape.make", json!({"mode": mode}))["id"].as_u64().unwrap())
}
/// The area compound shape `id` draws, symbol instances among its members as their symbols' art.
fn drawn(s: &Session, id: NodeId) -> f64 {
let d = &s.doc().unwrap().doc;
let hook = |c: &Node| effects::outline_object(Some(d), c);
area(&effects::compound_shape_area(&node(s, id), Some(&hook)).unwrap())
}
/// A bar (0–100 across, 10–30 down) behind a blend of two 20 pt squares at 0 and 80 (0–20 down)
/// with one step between them, both selected → (session, bar, blend).
fn bar_and_blend() -> (Session, NodeId, NodeId) {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 400, "height": 300}));
let bar = rect(&mut s, 0.0, 10.0, 100.0, 20.0);
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
let b = rect(&mut s, 80.0, 0.0, 20.0, 20.0);
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
let blend = NodeId(run(&mut s, "object.blend.make", json!({"steps": 1}))["id"].as_u64().unwrap());
run(&mut s, "select.set", json!({"ids": [bar.0, blend.0]}));
(s, bar, blend)
}
/// A blend is a member as its steps: the area takes each step in its shape mode, follows a change
/// of the blend's steps, survives a save, and Expand turns it into that area; Release gives the
/// blend back as a blend.
#[test]
fn a_blend_is_a_member_as_its_steps() {
// Each square covers 20 × 10 of the bar; five squares 20 apart cover all 100 of it.
for (mode, want, more) in [("add", 2_600.0, 3_000.0), ("subtract", 1_400.0, 1_000.0)] {
let (mut s, bar, blend) = bar_and_blend();
let id = make(&mut s, mode);
let members: Vec<NodeId> = node(&s, id).children().unwrap().iter().map(|m| m.id).collect();
assert_eq!(members, vec![bar, blend]);
assert!((drawn(&s, id) - want).abs() < 0.5, "{mode}: {}", drawn(&s, id));
run(&mut s, "select.set", json!({"ids": [blend.0]}));
run(&mut s, "object.blend.options", json!({"spacing": "steps", "value": 3}));
assert!((drawn(&s, id) - more).abs() < 0.5, "{mode}, three steps: {}", drawn(&s, id));
let doc = s.doc().unwrap().doc.clone();
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
assert_eq!(back.node(id), doc.node(id));
assert!((area(&effects::compound_shape_area(back.node(id).unwrap(), None).unwrap()) - more).abs() < 0.5);
run(&mut s, "select.set", json!({"ids": [id.0]}));
let made = run(&mut s, "object.compoundShape.expand", json!({}));
assert!((node_area(&node(&s, NodeId(made["ids"][0].as_u64().unwrap()))) - more).abs() < 0.5, "{mode}");
run(&mut s, "edit.undo", json!({}));
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, "object.compoundShape.release", json!({}));
assert_eq!(selected(&s), vec![bar, blend]);
assert!(matches!(node(&s, blend).kind, NodeKind::Blend { .. }), "{mode}: still a blend");
}
}
/// An envelope is a member as its distorted art: moving a point of its mesh reshapes the area, and
/// Release gives the envelope back.
#[test]
fn an_envelope_is_a_member_as_its_distorted_art() {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 400, "height": 300}));
let content = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
run(&mut s, "select.set", json!({"ids": [content.0]}));
let env = NodeId(run(&mut s, "object.envelope.makeWithMesh", json!({"rows": 1, "cols": 1}))["id"].as_u64().unwrap());
let dot = rect(&mut s, 300.0, 0.0, 10.0, 10.0);
run(&mut s, "select.set", json!({"ids": [env.0, dot.0]}));
let id = make(&mut s, "add");
assert!((drawn(&s, id) - 10_100.0).abs() < 1.0, "{}", drawn(&s, id));
// The corner at (100, 100) moved 100 to the right: a trapezoid of (100 + 200) / 2 × 100.
let NodeKind::Envelope { kind, .. } = node(&s, env).kind else { panic!("an envelope") };
let vectorcraft_doc::live::EnvelopeKind::Mesh { points, .. } = *kind else { panic!("a mesh envelope") };
let index = points.iter().position(|p| (p.x - 100.0).abs() < 1e-6 && (p.y - 100.0).abs() < 1e-6).unwrap();
run(&mut s, "object.envelope.setMeshPoint", json!({"id": env.0, "index": index, "x": 200, "y": 100}));
assert!((drawn(&s, id) - 15_100.0).abs() < 1.0, "{}", drawn(&s, id));
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, "object.compoundShape.release", json!({}));
assert_eq!(selected(&s), vec![env, dot]);
assert!(matches!(node(&s, env).kind, NodeKind::Envelope { .. }), "still an envelope");
}
/// An object with a warp is a member as it draws: the area follows the warp's bend, and Release
/// gives the object back with its warp.
#[test]
fn a_warped_object_is_a_member_as_it_draws() {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 400, "height": 300}));
let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0);
run(&mut s, "select.set", json!({"ids": [r.0]}));
run(&mut s, "effect.apply", json!({"effect": "warp.bulge", "params": {"bend": 50}}));
let dot = rect(&mut s, 300.0, 0.0, 10.0, 10.0);
run(&mut s, "select.set", json!({"ids": [r.0, dot.0]}));
let id = make(&mut s, "add");
// The rectangle as the canvas draws it.
let warped = |s: &Session| {
let n = node(s, r);
let (path, _) = crate::cmd::pathops::node_path(&n).unwrap();
let bounds = vectorcraft_geom::Shape::bounding_box(&path.to_bezpath());
area(&effects::apply_geometry_with(&n.appearance.effects, &path, bounds, &effects::GeomContext::of(&n)))
};
let bulged = warped(&s);
assert!((bulged - 5_000.0).abs() > 100.0, "the warp changes the area: {bulged}");
assert!((drawn(&s, id) - (bulged + 100.0)).abs() < 1.0, "{} vs {}", drawn(&s, id), bulged + 100.0);
run(&mut s, "effect.setParams", json!({"ids": [r.0], "index": 0, "item": null, "params": {"bend": -50}}));
let pinched = warped(&s);
assert!((pinched - bulged).abs() > 100.0, "{pinched} vs {bulged}");
assert!((drawn(&s, id) - (pinched + 100.0)).abs() < 1.0, "{} vs {}", drawn(&s, id), pinched + 100.0);
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, "object.compoundShape.release", json!({}));
assert_eq!(selected(&s), vec![r, dot]);
assert_eq!(node(&s, r).appearance.effects.first().map(|e| e.id.as_str()), Some("warp.bulge"), "it keeps its warp");
}
/// A session with a green 20 pt square (0–20 across and down) made into the symbol "Dot", its
/// instance selected → (session, instance).
fn dot_symbol() -> (Session, NodeId) {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 400, "height": 300}));
rect(&mut s, 0.0, 0.0, 20.0, 20.0);
run(&mut s, "paint.setFill", json!({"color": "#00ff00"}));
let a = NodeId(run(&mut s, "symbol.new", json!({"name": "Dot"}))["id"].as_u64().unwrap());
(s, a)
}
/// Symbol instances count as their symbols' art: a blend of two instances is a member as its
/// steps and gives the compound shape their paint, an envelope around an instance is a member as
/// its distorted art, and an instance inside a group as its art; Expand makes the same area.
#[test]
fn symbol_instances_in_members_count_as_their_art() {
for (mode, want) in [("add", 2_600.0), ("subtract", 1_400.0)] {
let (mut s, a) = dot_symbol();
let b = NodeId(run(&mut s, "symbol.place", json!({"name": "Dot", "x": 90, "y": 10}))["id"].as_u64().unwrap());
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
let blend = NodeId(run(&mut s, "object.blend.make", json!({"steps": 1}))["id"].as_u64().unwrap());
let bar = rect(&mut s, 0.0, 10.0, 100.0, 20.0);
run(&mut s, "select.set", json!({"ids": [bar.0]}));
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
run(&mut s, "object.arrange.sendToBack", json!({}));
run(&mut s, "select.set", json!({"ids": [bar.0, blend.0]}));
let id = make(&mut s, mode);
assert!((drawn(&s, id) - want).abs() < 0.5, "blend, {mode}: {}", drawn(&s, id));
if mode == "add" {
assert_eq!(node(&s, id).appearance.fill_paint().color(), Some(Color::rgb(0.0, 1.0, 0.0)), "the blend's paint");
}
assert!((expanded(&mut s, id) - want).abs() < 0.5, "expanded, {mode}");
}
let (mut s, _) = dot_symbol();
let env = NodeId(run(&mut s, "object.envelope.makeWithMesh", json!({"rows": 1, "cols": 1}))["id"].as_u64().unwrap());
let dot = rect(&mut s, 300.0, 0.0, 10.0, 10.0);
run(&mut s, "select.set", json!({"ids": [env.0, dot.0]}));
let id = make(&mut s, "add");
assert!((drawn(&s, id) - 500.0).abs() < 1.0, "envelope: {}", drawn(&s, id));
// The corner at (20, 20) moved 20 to the right: a trapezoid of (20 + 40) / 2 × 20.
let NodeKind::Envelope { kind, .. } = node(&s, env).kind else { panic!("an envelope") };
let vectorcraft_doc::live::EnvelopeKind::Mesh { points, .. } = *kind else { panic!("a mesh envelope") };
let index = points.iter().position(|p| (p.x - 20.0).abs() < 1e-6 && (p.y - 20.0).abs() < 1e-6).unwrap();
run(&mut s, "object.envelope.setMeshPoint", json!({"id": env.0, "index": index, "x": 40, "y": 20}));
assert!((drawn(&s, id) - 700.0).abs() < 1.0, "envelope, distorted: {}", drawn(&s, id));
assert!((expanded(&mut s, id) - 700.0).abs() < 1.0, "envelope, expanded");
let (mut s, _) = dot_symbol();
let group = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
let dot = rect(&mut s, 300.0, 0.0, 10.0, 10.0);
run(&mut s, "select.set", json!({"ids": [group.0, dot.0]}));
let id = make(&mut s, "add");
assert!((drawn(&s, id) - 500.0).abs() < 1.0, "group: {}", drawn(&s, id));
assert!((expanded(&mut s, id) - 500.0).abs() < 1.0, "group, expanded");
}
/// The 20 pt square symbol "Dot", a symbol "Ring" whose art is an instance of "Dot" and a symbol
/// "Halo" whose art is an instance of "Ring", with an instance of "Halo" in the square's place →
/// (session, the instance of "Halo").
fn halo_symbol() -> (Session, NodeId) {
let (mut s, dot) = dot_symbol();
let ring = NodeId(run(&mut s, "symbol.new", json!({"name": "Ring", "ids": [dot.0]}))["id"].as_u64().unwrap());
let halo = NodeId(run(&mut s, "symbol.new", json!({"name": "Halo", "ids": [ring.0]}))["id"].as_u64().unwrap());
let d = &s.doc().unwrap().doc;
for (outer, inner) in [("Halo", "Ring"), ("Ring", "Dot")] {
let Some(sym) = d.symbols.iter().find(|x| x.name == outer) else { panic!("the symbol {outer}") };
let mut holds = false;
sym.art.walk(&mut |c| holds |= matches!(&c.kind, NodeKind::SymbolInstance { symbol, .. } if symbol == inner));
assert!(holds, "{outer} holds an instance of {inner}");
}
(s, halo)
}
/// Instances of a symbol whose art holds instances of other symbols, nested three deep, count as
/// the innermost symbol's art: in a blend and inside a group.
#[test]
fn nested_symbol_instances_in_members_count_as_their_art() {
for (mode, want) in [("add", 2_600.0), ("subtract", 1_400.0)] {
let (mut s, a) = halo_symbol();
let b = NodeId(run(&mut s, "symbol.place", json!({"name": "Halo", "x": 90, "y": 10}))["id"].as_u64().unwrap());
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
let blend = NodeId(run(&mut s, "object.blend.make", json!({"steps": 1}))["id"].as_u64().unwrap());
let bar = rect(&mut s, 0.0, 10.0, 100.0, 20.0);
run(&mut s, "select.set", json!({"ids": [bar.0]}));
run(&mut s, "object.arrange.sendToBack", json!({}));
run(&mut s, "select.set", json!({"ids": [bar.0, blend.0]}));
let id = make(&mut s, mode);
assert!((drawn(&s, id) - want).abs() < 0.5, "blend, {mode}: {}", drawn(&s, id));
assert!((expanded(&mut s, id) - want).abs() < 0.5, "blend, expanded, {mode}");
}
let (mut s, halo) = halo_symbol();
run(&mut s, "select.set", json!({"ids": [halo.0]}));
let group = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
let dot = rect(&mut s, 300.0, 0.0, 10.0, 10.0);
run(&mut s, "select.set", json!({"ids": [group.0, dot.0]}));
let id = make(&mut s, "add");
assert!((drawn(&s, id) - 500.0).abs() < 1.0, "group: {}", drawn(&s, id));
assert!((expanded(&mut s, id) - 500.0).abs() < 1.0, "group, expanded");
}
/// A symbol whose art holds two instances of itself counts as its art once, in a blend and inside
/// a group, without evaluating forever.
#[test]
fn a_symbol_holding_itself_counts_once() {
let holding_itself = |s: &mut Session| {
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let Some(sym) = d.symbols.iter_mut().find(|x| x.name == "Dot") else { panic!("the symbol Dot") };
let me = |id| std::sync::Arc::new(Node::new(NodeId(id), NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Default::default() }));
sym.art = std::sync::Arc::new(Node::group(NodeId(9_997), vec![sym.art.clone(), me(9_998), me(9_999)]));
};
let (mut s, a) = dot_symbol();
let b = NodeId(run(&mut s, "symbol.place", json!({"name": "Dot", "x": 90, "y": 10}))["id"].as_u64().unwrap());
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
let blend = NodeId(run(&mut s, "object.blend.make", json!({"steps": 1}))["id"].as_u64().unwrap());
let bar = rect(&mut s, 0.0, 10.0, 100.0, 20.0);
run(&mut s, "select.set", json!({"ids": [bar.0, blend.0]}));
let id = make(&mut s, "add");
holding_itself(&mut s);
assert!((drawn(&s, id) - 2_600.0).abs() < 0.5, "blend: {}", drawn(&s, id));
let (mut s, a) = dot_symbol();
run(&mut s, "select.set", json!({"ids": [a.0]}));
let group = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
let dot = rect(&mut s, 300.0, 0.0, 10.0, 10.0);
run(&mut s, "select.set", json!({"ids": [group.0, dot.0]}));
let id = make(&mut s, "add");
holding_itself(&mut s);
assert!((drawn(&s, id) - 500.0).abs() < 1.0, "group: {}", drawn(&s, id));
}
/// Symbols whose art holds two instances of the next symbol, 32 deep (2³¹ squares in all), stop at
/// the symbol art budget: the area of a compound shape with an instance of the first in a member
/// is left as past its budget, and the member's shapes stop there.
#[test]
fn branching_symbol_art_stops_at_its_budget() {
let (mut s, a) = dot_symbol();
run(&mut s, "select.set", json!({"ids": [a.0]}));
let group = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
let dot = rect(&mut s, 300.0, 0.0, 10.0, 10.0);
run(&mut s, "select.set", json!({"ids": [group.0, dot.0]}));
let id = make(&mut s, "add");
{
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let Some(first) = d.symbols.iter().position(|x| x.name == "Dot") else { panic!("the symbol Dot") };
let square = d.symbols[first].art.clone();
let two = |level: u64| {
let one = |k: u64| {
let symbol = format!("Level {level}");
std::sync::Arc::new(Node::new(NodeId(20_000 + 2 * level + k), NodeKind::SymbolInstance { symbol, xf: Default::default() }))
};
std::sync::Arc::new(Node::group(NodeId(10_000 + level), vec![one(0), one(1)]))
};
d.symbols[first].art = two(1);
for level in 1..32 {
let mut sym = d.symbols[first].clone();
sym.name = format!("Level {level}");
sym.art = if level == 31 { square.clone() } else { two(level + 1) };
d.symbols.push(sym);
}
}
let d = &s.doc().unwrap().doc;
let hook = |c: &Node| effects::outline_object(Some(d), c);
assert!(effects::compound_shape_area(&node(&s, id), Some(&hook)).is_none(), "past the budget");
let leaves = effects::member_leaves(&node(&s, group), Some(&hook));
assert!(!leaves.is_empty() && leaves.len() as u64 <= effects::SYMBOL_ART_MAX_NODES, "{} shapes", leaves.len());
}
/// A group whose frontmost member is a blend or a symbol instance gives the compound shape the
/// paint of that member's art, not that of a member below it.
#[test]
fn a_group_of_a_blend_or_an_instance_gives_its_paint() {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 400, "height": 300}));
let bar = rect(&mut s, 0.0, 10.0, 100.0, 20.0);
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
let b = rect(&mut s, 80.0, 0.0, 20.0, 20.0);
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
run(&mut s, "paint.setFill", json!({"color": "#00ff00"}));
run(&mut s, "object.blend.make", json!({"steps": 1}));
let group = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
run(&mut s, "select.set", json!({"ids": [bar.0, group.0]}));
let id = make(&mut s, "add");
assert_eq!(node(&s, id).appearance.fill_paint().color(), Some(Color::rgb(0.0, 1.0, 0.0)), "the blend's paint");
let (mut s, a) = dot_symbol();
run(&mut s, "select.set", json!({"ids": [a.0]}));
let group = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
let bar = rect(&mut s, 0.0, 10.0, 100.0, 20.0);
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
run(&mut s, "object.arrange.sendToBack", json!({}));
run(&mut s, "select.set", json!({"ids": [bar.0, group.0]}));
let id = make(&mut s, "add");
assert_eq!(node(&s, id).appearance.fill_paint().color(), Some(Color::rgb(0.0, 1.0, 0.0)), "the instance's paint");
}
/// A topmost blend, envelope or group with its own opacity and blend mode gives them to the
/// compound shape, multiplied by the opacity of the shape inside it.
#[test]
fn a_topmost_blend_envelope_or_group_gives_its_transparency() {
for member in ["blend", "envelope", "group"] {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 400, "height": 300}));
let bar = rect(&mut s, 0.0, 10.0, 100.0, 20.0);
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
let ids = if member == "blend" {
let b = rect(&mut s, 80.0, 0.0, 20.0, 20.0);
json!([a.0, b.0])
} else {
json!([a.0])
};
run(&mut s, "select.set", json!({"ids": ids}));
let top = match member {
"blend" => run(&mut s, "object.blend.make", json!({"steps": 1})),
"envelope" => run(&mut s, "object.envelope.makeWithMesh", json!({"rows": 1, "cols": 1})),
_ => {
run(&mut s, "transparency.set", json!({"item": null, "opacity": 80}));
run(&mut s, "object.group", json!({}))
}
};
let top = NodeId(top["id"].as_u64().unwrap());
run(&mut s, "select.set", json!({"ids": [top.0]}));
run(&mut s, "transparency.set", json!({"item": null, "opacity": 50, "blend": "multiply"}));
run(&mut s, "select.set", json!({"ids": [bar.0, top.0]}));
let id = make(&mut s, "add");
let n = node(&s, id);
let want = if member == "group" { 0.4 } else { 0.5 };
assert!((n.opacity - want).abs() < 1e-6, "{member}: {}", n.opacity);
assert_eq!(n.blend, BlendMode::Multiply, "{member}");
}
}
/// A symbol instance with a warp is a member as it draws: its symbol's art reshaped by the warp,
/// inside a group and as the shapes the instance gives alone.
#[test]
fn a_warped_symbol_instance_is_a_member_as_it_draws() {
let (mut s, a) = dot_symbol();
run(&mut s, "effect.apply", json!({"effect": "warp.bulge", "params": {"bend": 50}}));
let group = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
let dot = rect(&mut s, 300.0, 0.0, 10.0, 10.0);
run(&mut s, "select.set", json!({"ids": [group.0, dot.0]}));
let id = make(&mut s, "add");
let d = &s.doc().unwrap().doc;
let Some(sym) = d.symbols.iter().find(|x| x.name == "Dot") else { panic!("the symbol Dot") };
let Some(art) = effects::reshape(Some(d), &node(&s, a), Some(&sym.art)) else { panic!("the warped art") };
let mut warped = 0.0;
art.walk(&mut |c| {
if matches!(c.kind, NodeKind::Path { .. }) {
warped += node_area(c);
}
});
assert!((warped - 400.0).abs() > 10.0, "the warp changes the area: {warped}");
assert!((drawn(&s, id) - (warped + 100.0)).abs() < 1.0, "{} vs {}", drawn(&s, id), warped + 100.0);
let hook = |c: &Node| effects::outline_object(Some(d), c);
let alone: f64 = effects::member_leaves(&node(&s, a), Some(&hook)).iter().map(node_area).sum();
assert!((alone - warped).abs() < 1.0, "the instance alone: {alone} vs {warped}");
}
/// A symbol instance gives the compound shape the transparency its art is drawn with: that of an
/// instance of another symbol inside the art, within the instance's own.
#[test]
fn an_instance_gives_the_transparency_of_its_art() {
let (mut s, a) = dot_symbol();
run(&mut s, "transparency.set", json!({"item": null, "opacity": 50, "blend": "multiply"}));
let outer = NodeId(run(&mut s, "symbol.new", json!({"name": "Outer", "ids": [a.0]}))["id"].as_u64().unwrap());
run(&mut s, "select.set", json!({"ids": [outer.0]}));
let group = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
let bar = rect(&mut s, 0.0, 10.0, 100.0, 20.0);
run(&mut s, "object.arrange.sendToBack", json!({}));
run(&mut s, "select.set", json!({"ids": [bar.0, group.0]}));
let NodeKind::SymbolInstance { .. } = node(&s, outer).kind else { panic!("an instance of Outer") };
let id = make(&mut s, "add");
let n = node(&s, id);
assert!((n.opacity - 0.5).abs() < 1e-6, "{}", n.opacity);
assert_eq!(n.blend, BlendMode::Multiply);
}
/// The area the paths and compound paths in `art` fill, at every depth of its groups.
fn art_area(art: &Node) -> f64 {
match &art.kind {
NodeKind::Path { .. } | NodeKind::Compound { .. } => node_area(art),
_ => art.children().map_or(0.0, |ch| ch.iter().map(|c| art_area(c)).sum()),
}
}
/// A session with the type "Hello bo" made into the symbol "Word", its instance selected →
/// (session, instance).
fn word_symbol() -> (Session, NodeId) {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 400, "height": 300}));
run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Hello bo", "size": 36}));
let a = NodeId(run(&mut s, "symbol.new", json!({"name": "Word"}))["id"].as_u64().unwrap());
let Some(sym) = s.doc().unwrap().doc.symbols.iter().find(|x| x.name == "Word").cloned() else { panic!("the symbol Word") };
let mut holds = false;
sym.art.walk(&mut |c| holds |= matches!(c.kind, NodeKind::Text(_)));
assert!(holds, "Word holds type");
(s, a)
}
/// Type inside a symbol's art counts as its outlines: an instance in a group gives the area of the
/// glyphs, and a warped instance the area of the glyphs reshaped by the warp.
#[test]
fn type_in_symbol_art_counts_as_its_outlines() {
let (mut s, a) = word_symbol();
let unwarped = {
let d = &s.doc().unwrap().doc;
let hook = |c: &Node| effects::outline_object(Some(d), c);
effects::member_leaves(&node(&s, a), Some(&hook)).iter().map(node_area).sum::<f64>()
};
assert!(unwarped > 200.0, "the glyphs: {unwarped}");
run(&mut s, "select.set", json!({"ids": [a.0]}));
let group = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
let dot = rect(&mut s, 300.0, 0.0, 10.0, 10.0);
run(&mut s, "select.set", json!({"ids": [group.0, dot.0]}));
let id = make(&mut s, "add");
assert!((drawn(&s, id) - (unwarped + 100.0)).abs() < 1.0, "group: {} vs {}", drawn(&s, id), unwarped + 100.0);
let (mut s, a) = word_symbol();
run(&mut s, "effect.apply", json!({"effect": "warp.bulge", "params": {"bend": 50}}));
let group = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
let dot = rect(&mut s, 300.0, 0.0, 10.0, 10.0);
run(&mut s, "select.set", json!({"ids": [group.0, dot.0]}));
let id = make(&mut s, "add");
let d = &s.doc().unwrap().doc;
let Some(sym) = d.symbols.iter().find(|x| x.name == "Word") else { panic!("the symbol Word") };
let Some(art) = effects::reshape(Some(d), &node(&s, a), Some(&sym.art)) else { panic!("the warped art") };
let warped = art_area(&art);
assert!((warped - unwarped).abs() > 10.0, "the warp changes the area: {warped} vs {unwarped}");
assert!((drawn(&s, id) - (warped + 100.0)).abs() < 1.0, "warped: {} vs {}", drawn(&s, id), warped + 100.0);
}
/// The Pathfinder panel counts a compound shape of two blends by its members, so the operations
/// that take one shape (Outline, Divide) are available for it.
#[test]
fn a_compound_shape_of_blends_counts_its_members() {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 400, "height": 300}));
let mut blends = vec![];
for y in [0.0, 50.0] {
let a = rect(&mut s, 0.0, y, 20.0, 20.0);
let b = rect(&mut s, 80.0, y, 20.0, 20.0);
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
blends.push(run(&mut s, "object.blend.make", json!({"steps": 1}))["id"].as_u64().unwrap());
}
run(&mut s, "select.set", json!({"ids": blends}));
let id = make(&mut s, "add");
assert_eq!(crate::cmd::pathops::shape_count(&node(&s, id)), 2);
}
/// Expand compound shape `id` → the area of the path it makes.
fn expanded(s: &mut Session, id: NodeId) -> f64 {
run(s, "select.set", json!({"ids": [id.0]}));
let made = run(s, "object.compoundShape.expand", json!({}));
node_area(&node(s, NodeId(made["ids"][0].as_u64().unwrap())))
}
+2 -1
View File
@@ -477,7 +477,8 @@ impl Renderer {
{
self.live.evaluated += 1;
}
let v: Expanded = match crate::effects::pathfinder_children(a, crate::effects::text_outliner()) {
let hook = |c: &Node| crate::effects::outline_object(Some(doc), c);
let v: Expanded = match crate::effects::pathfinder_children(a, Some(&hook)) {
Some(children) => Arc::new(children),
None => Arc::new(crate::effects::expand_live(Some(doc), a).into_iter().map(Arc::new).collect()),
};
+5 -3
View File
@@ -2423,9 +2423,11 @@ whatever the options are then.
```
Compound shapes (the Pathfinder panel menu, and Alt-clicking a Shape Mode button): `object.compoundShape.make
{mode?, ids?}` makes the two or more selected objects (paths, compound paths, groups, compound shapes, type) the
members of one live compound shape, each in shape mode `mode` (`"add"`, the default, `"subtract"`, `"intersect"` or
`"exclude"`). It draws, prints and exports as the area its members make from the backmost up, painted with its own
{mode?, ids?}` makes the two or more selected objects (paths, compound paths, groups, compound shapes, type, blends,
envelopes) the members of one live compound shape, each in shape mode `mode` (`"add"`, the default, `"subtract"`,
`"intersect"` or `"exclude"`). It draws, prints and exports as the area its members make from the backmost up (a blend
as its steps, an envelope as its distorted art, an object with a warp or another geometry effect as it draws, each
updated when the member is edited), painted with its own
appearance, which it takes from the topmost member not in Subtract (with every member subtracting, the backmost); a
fill or stroke given to it or to a member paints the compound shape. The members stay editable (Direct Selection,
the Layers panel, isolation mode). `object.compoundShape.setMode {mode, ids?}` sets the mode of the members the
+1 -1
View File
@@ -851,7 +851,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Cmd/Ctrl+4 (`pathfinder.repeat`) repeats the last successful Pathfinder panel operation, direct `object.pathfinder.*` command or `command.batch` step (including Actions playback) on the current selection; the panel menu names it. `action.batch`, Trap, compound-shape edits and live effects do not set history. Public references: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), [Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html).
- Pathfinder Options ([Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html)): the panel menu's Pathfinder Options… sets Precision (0.001–10 pt: results refit to within it, thinner pieces dropped), Remove Redundant Points and Divide and Outline Will Remove Unpainted Artwork (`object.pathfinder.options`, kept in the preferences) for every Shape Mode and Pathfinder operation; the defaults keep the earlier output (`pathops` `precision_drops_pieces_thinner_than_it`, engine `tests_pathops` Pathfinder Options tests, `panels::pathfinder` dialog test).
- Pathfinder › Trap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html), [Pathfinder panel](https://helpx.adobe.com/illustrator/using/pathfinder-panel-overview.html)): the Pathfinder panel menu's Trap… (`object.pathfinder.trap`) and Effect › Pathfinder › Trap… (`pathfinder.trap`, live on groups) add, where two objects' visible areas of different solid colours meet, a strip of the darker one's area along the edge, filled with the lighter colour at the Tint Reduction percentage and set to overprint (Reverse Traps: the darker colour spreads instead, but not a rich black); Thickness 0.01–5000 pt across vertical edges, Height/Width scales it across horizontal ones, Traps with Process Color paints a swatch colour's trap with its CMYK equivalent; the effect also has Precision and Remove Redundant Points. The panel's Trap is one undo step and groups the traps above the topmost selected object, selected; with no adjoining edges of different colours it says so and changes nothing. Tests: `crates/pathops/src/trap.rs`, `crates/effects/src/trap.rs`, `group::tests::trap_effect_adds_an_overprinting_strip_above_the_members`, `crates/engine/src/tests/trap.rs`, `panels::pathfinder::tests::trap_opens_its_options_and_ok_traps_the_selection`.
- Compound shapes ([Create compound shapes with Pathfinder](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/create-compound-shapes-with-pathfinder.html), [Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html)): the Pathfinder panel menu's Make Compound Shape (every member in Add) and Alt-clicking a Shape Mode (every member in that mode) make the two or more selected paths, compound paths, groups, compound shapes or type a live compound shape (`object.compoundShape.make`): a group whose members each keep a shape mode and stay editable, drawn, printed and exported (SVG, PDF and the rest through the baked document) as the area the modes make from the backmost member up, open paths closed, painted with its own appearance, taken from the topmost member not subtracting. With members selected (Direct Selection, Layers panel, isolation mode) a Shape Mode button sets theirs (`object.compoundShape.setMode`) and is lit for the mode they share; paint given to a member goes to the compound shape. Release Compound Shape gives the members back as they were (`object.compoundShape.release`); Expand Compound Shape and the panel's Expand button turn it into the path or compound path of its area (`object.compoundShape.expand`). Shape Modes and Pathfinders take a compound shape as its area. Saved files keep it (`compound_shape`, `shape_mode`; an unknown mode reads as Add). Tests: `crates/engine/src/tests/compound_shape.rs`, `group::tests::compound_shapes_draw_the_area_their_shape_modes_make`, `group::tests::nested_compound_shapes_take_part_as_their_area`, `panels::pathfinder::tests::compound_shape_items_follow_the_selection`, the `CompoundShape` op of the property-test strategies. Not yet: blends, envelopes and warps as members, the question marks on mixed member modes, hit-testing by the area (a click hits the members).
- Compound shapes ([Create compound shapes with Pathfinder](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/create-compound-shapes-with-pathfinder.html), [Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html)): the Pathfinder panel menu's Make Compound Shape (every member in Add) and Alt-clicking a Shape Mode (every member in that mode) make the two or more selected paths, compound paths, groups, compound shapes or type a live compound shape (`object.compoundShape.make`): a group whose members each keep a shape mode and stay editable, drawn, printed and exported (SVG, PDF and the rest through the baked document) as the area the modes make from the backmost member up, open paths closed, painted with its own appearance, taken from the topmost member not subtracting. With members selected (Direct Selection, Layers panel, isolation mode) a Shape Mode button sets theirs (`object.compoundShape.setMode`) and is lit for the mode they share; paint given to a member goes to the compound shape. Release Compound Shape gives the members back as they were (`object.compoundShape.release`); Expand Compound Shape and the panel's Expand button turn it into the path or compound path of its area (`object.compoundShape.expand`). Shape Modes and Pathfinders take a compound shape as its area. Saved files keep it (`compound_shape`, `shape_mode`; an unknown mode reads as Add). Blends, envelopes and objects with a warp or another geometry effect are members as they draw (a blend's steps, an envelope's distorted art, the warped outline), and the area follows an edit of the blend, envelope or warp; Release gives them back as they were. Tests: `crates/engine/src/tests/compound_shape.rs`, `group::tests::compound_shapes_draw_the_area_their_shape_modes_make`, `group::tests::nested_compound_shapes_take_part_as_their_area`, `panels::pathfinder::tests::compound_shape_items_follow_the_selection`, the `CompoundShape` op of the property-test strategies. Not yet: the question marks on mixed member modes, hit-testing by the area (a click hits the members).
- Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is measured by its glyph outlines. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy).
- With Use Preview Bounds on, the bounding box, the Transform panel, Align, Distribute, Distribute Spacing and `object.setBounds` measure each object after its geometry effects. A path is measured by its effected shape, and a fill or stroke with geometry effects of its own is measured by the shape it paints. Type, images, live objects and symbol instances are measured by their reshaped art, and a symbol instance is also measured by its reshaped art when its symbol's art has geometry effects. A group or layer with geometry or Pathfinder effects, or with a fill or stroke of its own that has geometry effects or paints members with geometry effects, is measured by its evaluated art. A blend, envelope or repeat whose objects have geometry effects is measured by its evaluated art, with those objects measured after their effects. A 100 pt square without a stroke, with Offset Path 10, measures 120 pt, and type with Outline Object aligns and sizes by its glyphs. Raster effects are not measured. Public reference: [Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html) (archived copy).
- Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Cap is on by default with continuous curved vector rims; shading uses vector gradients automatically, with sharp profile corners, uniform caps and round seam joins. One light and approximate visibility remain ([revolve.md](revolve.md)).
+1 -1
View File
@@ -320,7 +320,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po
| Pathfinder Divide: an open cutting line splits a filled path | done | #1307; straight, crossed, bent and curved cutters, holes and frontmost appearance regression-tested; native automation: independent piece movement, one Undo/Redo, saved-document round trip and window capture; existing bounded-map fallback remains |
| Pathfinder panel › Pathfinder Options…: Precision, Remove Redundant Points, Divide and Outline Will Remove Unpainted Artwork | done | #3426; `object.pathfinder.options`, kept in the preferences; defaults (0.01 pt, both off) keep the earlier results; [spec](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html) |
| Cmd/Ctrl+4 repeats the last successful Shape Mode or Pathfinder operation on the selection | done | Panel operations, direct `object.pathfinder.*` commands and `command.batch` steps (Actions playback) set history; `action.batch`, Trap, compound-shape edits and live effects do not. Repeat uses current Pathfinder Options; [key spec](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), [menu spec](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html) |
| Pathfinder panel: compound shapes (Make, Release and Expand Compound Shape in the panel menu, Alt-click a Shape Mode, a Shape Mode for selected members, the Expand button) | partial | #4522; `object.compoundShape.make`/`setMode`/`release`/`expand`; members stay editable, the area draws, exports and saves; the button of the members' shared mode is lit; [spec](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/create-compound-shapes-with-pathfinder.html); missing: blends, envelopes and warps as members, question marks for mixed modes, hit-testing by the area |
| Pathfinder panel: compound shapes (Make, Release and Expand Compound Shape in the panel menu, Alt-click a Shape Mode, a Shape Mode for selected members, the Expand button) | partial | #4522; `object.compoundShape.make`/`setMode`/`release`/`expand`; members stay editable, the area draws, exports and saves; the button of the members' shared mode is lit; blends, envelopes and warped objects are members as they draw; [spec](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/create-compound-shapes-with-pathfinder.html); missing: question marks for mixed modes, hit-testing by the area |
| Canvas context menu | done | Layers rows, panels and type have none (gap) |
| SVG Interactivity panel: JavaScript for each object's events and the document's linked JavaScript files, written to SVG and read back as text | done | `svgInteractivity.*`, `ui.javascriptFilesDialog` |
| Retype, Comments, Version History panels | missing | the last two are cloud features (out of scope) |