mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Appearance: image fills and strokes draw the shape Convert to Shape or Outline Object gives them
Squashed from #2500 (its merges of main dropped).
This commit is contained in:
@@ -739,9 +739,10 @@ pub struct Appearance {
|
||||
pub items: Vec<AppearanceItem>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub effects: Vec<Effect>,
|
||||
/// Groups, layers and type: how many items paint below the object's contents (its members, or
|
||||
/// type's characters), the slot of the Appearance panel's Contents (Characters) row. `None`:
|
||||
/// every item paints above the contents (see [`Self::contents_at`]).
|
||||
/// Groups, layers, type and images: how many items paint below the object's contents (its
|
||||
/// members, type's characters, or an image's pixels), the slot of the Appearance panel's
|
||||
/// Contents (Characters) row. `None`: every item paints above the contents (see
|
||||
/// [`Self::contents_at`]).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub contents_index: Option<usize>,
|
||||
}
|
||||
|
||||
+40
-8
@@ -1,11 +1,12 @@
|
||||
//! Hit testing over the document tree.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use vectorcraft_geom::hit::{fill_contains, stroke_contains};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
use vectorcraft_geom::{BezPath, FillRule, Point, Rect};
|
||||
|
||||
use crate::{Document, Node, NodeId, NodeKind, SymbolBoxes};
|
||||
use crate::{AppearanceItem, Document, Node, NodeId, NodeKind, SymbolBoxes};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum HitKind {
|
||||
@@ -73,6 +74,26 @@ impl Default for HitOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// The shapes that an image's own fills and strokes paint, each with its fill rule and the fill or
|
||||
/// stroke that paints it (none for fills and strokes that paint nothing). Effects are evaluated
|
||||
/// above this crate, so the engine registers one with [`set_image_paint`].
|
||||
pub type ImagePaint = fn(&Node) -> Vec<(BezPath, FillRule, AppearanceItem)>;
|
||||
|
||||
static IMAGE_PAINT: OnceLock<ImagePaint> = OnceLock::new();
|
||||
|
||||
/// Register how the shapes of an image's own fills and strokes are found (the first registration
|
||||
/// stays).
|
||||
pub fn set_image_paint(f: ImagePaint) {
|
||||
// Already set: the same engine, registered by another session.
|
||||
let _ = IMAGE_PAINT.set(f);
|
||||
}
|
||||
|
||||
/// The shapes that the image `n`'s own fills and strokes paint ([`set_image_paint`]), none while
|
||||
/// nothing is registered.
|
||||
pub(crate) fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> {
|
||||
IMAGE_PAINT.get().map_or_else(Vec::new, |f| f(n))
|
||||
}
|
||||
|
||||
/// Topmost editable object under `p`.
|
||||
pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
|
||||
hit_test_skipping(doc, p, opt, &|_| false, true)
|
||||
@@ -439,12 +460,23 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio
|
||||
n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p))?;
|
||||
on_type_path(n, t, p, opt.tol).then_some(HitKind::Outline)
|
||||
}
|
||||
NodeKind::Text(_)
|
||||
| NodeKind::Image(_)
|
||||
| NodeKind::PlacedDocument(_)
|
||||
| NodeKind::SymbolInstance { .. }
|
||||
| NodeKind::Blend { .. }
|
||||
| NodeKind::Envelope { .. } => n.geometric_bounds_with(symbols).filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds),
|
||||
// An image is hit within its frame, and where its own fills and strokes paint
|
||||
// ([`image_paint`]) except in outline mode.
|
||||
NodeKind::Image(_) => {
|
||||
if n.geometric_bounds_with(symbols).is_some_and(|b| b.inflate(opt.tol, opt.tol).contains(p)) {
|
||||
return Some(HitKind::Bounds);
|
||||
}
|
||||
if opt.outline {
|
||||
return None;
|
||||
}
|
||||
image_paint(n).iter().rev().find_map(|(bp, rule, item)| match item {
|
||||
AppearanceItem::Stroke(s) => s.hits(bp, *rule, p, opt.tol).then_some(HitKind::Stroke),
|
||||
AppearanceItem::Fill(_) => (!opt.path_only && fill_contains(bp, *rule, p)).then_some(HitKind::Fill),
|
||||
})
|
||||
}
|
||||
NodeKind::Text(_) | NodeKind::PlacedDocument(_) | NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } => {
|
||||
n.geometric_bounds_with(symbols).filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds)
|
||||
}
|
||||
NodeKind::Repeat(r) => r.expand().iter().find_map(|g| hit_any(g, p, opt, symbols)),
|
||||
NodeKind::Mesh(m) => {
|
||||
let bp = m.outline().to_bezpath();
|
||||
|
||||
+29
-9
@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_geom::corners::cut_corners;
|
||||
use vectorcraft_geom::shapes::{self, CornerKind};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Shape};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::live::{BlendSpec, EnvelopeKind, GradientMesh, Outliner};
|
||||
@@ -1097,8 +1097,10 @@ impl Node {
|
||||
}
|
||||
/// A quick box round everything the object can paint, for culling clicks: paths and compound
|
||||
/// paths grow their geometric bounds by the farthest their strokes can reach from any path
|
||||
/// ([`Appearance::outset`]) instead of measuring along their shape. Symbol instances are
|
||||
/// measured as in [`Node::visual_bounds`].
|
||||
/// ([`Appearance::outset`]) instead of measuring along their shape. An image's box takes in
|
||||
/// the shapes its own fills and strokes paint ([`crate::hit::set_image_paint`]). Groups, layers
|
||||
/// without a clipping mask and Intertwine groups take in their members' boxes, grown by their
|
||||
/// own strokes. Symbol instances are measured as in [`Node::visual_bounds`].
|
||||
pub fn reach_bounds(&self) -> Option<Rect> {
|
||||
self.reach(None)
|
||||
}
|
||||
@@ -1116,6 +1118,22 @@ impl Node {
|
||||
let o = self.appearance.outset();
|
||||
self.geometric(symbols).map(|b| b.inflate(o, o))
|
||||
}
|
||||
// The members' reach, grown by the container's own strokes.
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
|
||||
let o = self.appearance.outset();
|
||||
children
|
||||
.iter()
|
||||
.skip(usize::from(self.extras().shaper.is_some()))
|
||||
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.reach(symbols)))
|
||||
.map(|b| b.inflate(o, o))
|
||||
}
|
||||
NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.reach(symbols))),
|
||||
// The frame and each shape the image's own fills and strokes paint, a stroke's grown by
|
||||
// the farthest it can reach from its shape.
|
||||
NodeKind::Image(_) => crate::hit::image_paint(self).iter().fold(self.geometric(symbols), |acc, (bp, _, item)| {
|
||||
let o = if let crate::AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 };
|
||||
vectorcraft_geom::union_opt(acc, (!bp.elements().is_empty()).then(|| bp.bounding_box().inflate(o, o)))
|
||||
}),
|
||||
_ => self.visual(symbols),
|
||||
}
|
||||
}
|
||||
@@ -1165,9 +1183,9 @@ impl Node {
|
||||
}
|
||||
}
|
||||
/// Visual bounds: what the object paints, its strokes included (paths and compound paths
|
||||
/// measure theirs along their own shape, see [`Appearance::stroked_bounds`]). A symbol
|
||||
/// instance is measured as in [`Node::geometric_bounds`], grown by its own strokes' reach
|
||||
/// ([`Node::visual_bounds_with`] measures its art).
|
||||
/// measure theirs along their own shape, see [`Appearance::stroked_bounds`]). An image is
|
||||
/// measured by its frame. A symbol instance is measured as in [`Node::geometric_bounds`],
|
||||
/// grown by its own strokes' reach ([`Node::visual_bounds_with`] measures its art).
|
||||
pub fn visual_bounds(&self) -> Option<Rect> {
|
||||
self.visual(None)
|
||||
}
|
||||
@@ -1242,6 +1260,8 @@ impl Node {
|
||||
let o = self.appearance.outset().max(chars);
|
||||
self.geometric(symbols).map(|b| b.inflate(o, o))
|
||||
}
|
||||
// An image is measured by its frame, without its own fills and strokes.
|
||||
NodeKind::Image(_) => self.geometric(symbols),
|
||||
_ => {
|
||||
let o = self.appearance.outset();
|
||||
self.geometric(symbols).map(|b| b.inflate(o, o))
|
||||
@@ -1699,11 +1719,11 @@ impl Node {
|
||||
|
||||
impl Node {
|
||||
/// The Appearance panel's row for what this object's own fills and strokes paint around:
|
||||
/// "Contents" for a group or layer (its members), "Characters" for type; `None` for objects
|
||||
/// whose fills and strokes paint their own geometry.
|
||||
/// "Contents" for a group or layer (its members) and for an image (its pixels), "Characters"
|
||||
/// for type; `None` for objects whose fills and strokes paint their own geometry.
|
||||
pub fn contents_label(&self) -> Option<&'static str> {
|
||||
match self.kind {
|
||||
NodeKind::Group { .. } | NodeKind::Layer { .. } => Some("Contents"),
|
||||
NodeKind::Group { .. } | NodeKind::Layer { .. } | NodeKind::Image(_) => Some("Contents"),
|
||||
NodeKind::Text(_) => Some("Characters"),
|
||||
_ => None,
|
||||
}
|
||||
|
||||
@@ -107,9 +107,10 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
let m = evaluate_container(&m).unwrap_or(m);
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
// Type, images, symbol instances and live objects: reshaped through their outlines, and so is
|
||||
// type whose own fills or strokes have geometry effects ([`has_item_geometry`]). Type that
|
||||
// Transform effects only move and copy stays type, moved to each place.
|
||||
// Type, images, symbol instances and live objects: reshaped through their outlines, and so are
|
||||
// type whose own fills or strokes have geometry effects and images whose own fills or strokes
|
||||
// paint a shape ([`has_item_geometry`]). Type that Transform effects only move and copy stays
|
||||
// type, moved to each place.
|
||||
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some() || has_item_geometry(n)) {
|
||||
if let Some(m) = moved_type_group(n) {
|
||||
return Some(bake_pieces(d, m));
|
||||
|
||||
@@ -73,7 +73,7 @@ pub use live::{expand_live, expand_live_deep, expanded_live_group, 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, needs_outline, outline_art, outline_text, reshape};
|
||||
pub use reshape::{expand_outlined, has_gradient_copies, has_item_geometry, image_paint, needs_outline, outline_art, outline_text, reshape};
|
||||
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
|
||||
pub use solids::{
|
||||
EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid,
|
||||
|
||||
+107
-19
@@ -7,12 +7,14 @@
|
||||
//! warp bends a line of type as a whole. An image is clipped by its reshaped frame. Raster effects
|
||||
//! (shadows, glows, blur, feather) stay on the result for the renderer and exporters. Type without
|
||||
//! inline graphics whose own fills or strokes have geometry effects is outlined too: each of those
|
||||
//! effects reshapes the whole outline that its fill or stroke paints.
|
||||
//! effects reshapes the whole outline that its fill or stroke paints. An image's own fills and
|
||||
//! strokes paint only the shape that a Convert to Shape or Outline Object effect on them gives
|
||||
//! them ([`image_rows`]).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Rect, shapes};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, shapes};
|
||||
|
||||
use crate::{GeomContext, apply_one, is_geometry, merged_params};
|
||||
|
||||
@@ -44,11 +46,75 @@ pub fn has_gradient_copies(n: &Node) -> bool {
|
||||
}
|
||||
|
||||
/// Is `n` type without inline graphics that has a fill or stroke of its own that paints and has a
|
||||
/// visible geometry effect? Such an effect reshapes the whole outline that its fill or stroke
|
||||
/// visible geometry effect, or an image with a fill or stroke of its own that paints a shape
|
||||
/// ([`image_rows`])? On type, such an effect reshapes the whole outline that its fill or stroke
|
||||
/// paints ([`outline_text`], which leaves inline graphics out).
|
||||
pub fn has_item_geometry(n: &Node) -> bool {
|
||||
let NodeKind::Text(t) = &n.kind else { return false };
|
||||
t.runs.iter().all(|r| r.inline.is_none()) && n.appearance.items.iter().any(|i| crate::paints(i) && crate::has_geometry(i.effects()))
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
t.runs.iter().all(|r| r.inline.is_none()) && n.appearance.items.iter().any(|i| crate::paints(i) && crate::has_geometry(i.effects()))
|
||||
}
|
||||
NodeKind::Image(_) => n.appearance.items.iter().any(shapes_image),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Does effect `e` give an image's fill or stroke a shape to paint (a visible Convert to Shape or
|
||||
/// Outline Object)?
|
||||
fn gives_shape(e: &Effect) -> bool {
|
||||
e.visible
|
||||
&& matches!(e.id.as_str(), "convertToShape.rectangle" | "convertToShape.roundedRectangle" | "convertToShape.ellipse" | "path.outlineObject")
|
||||
}
|
||||
|
||||
/// Does `item`, a fill or stroke of an image, paint something: does it paint and carry an effect
|
||||
/// that gives it a shape ([`gives_shape`])?
|
||||
fn shapes_image(item: &AppearanceItem) -> bool {
|
||||
crate::paints(item) && item.effects().iter().any(gives_shape)
|
||||
}
|
||||
|
||||
/// The fills and strokes of `items` (an image's own) that paint something ([`shapes_image`]), each
|
||||
/// without the effects before its first Convert to Shape or Outline Object. Convert to Shape sizes
|
||||
/// its shape from the image's box, and Outline Object gives the image's frame. The others paint
|
||||
/// nothing.
|
||||
fn image_rows(items: &[AppearanceItem]) -> Vec<AppearanceItem> {
|
||||
items
|
||||
.iter()
|
||||
.filter(|i| shapes_image(i))
|
||||
.map(|i| {
|
||||
let mut i = i.clone();
|
||||
let fx = i.effects_mut();
|
||||
let first = fx.iter().position(gives_shape).unwrap_or(0);
|
||||
fx.drain(..first);
|
||||
i
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The shapes that the image `n`'s own fills and strokes paint ([`image_rows`]), each with its fill
|
||||
/// rule and the fill or stroke that paints it: the paths (or compound paths) along its frame that
|
||||
/// [`reshape`] makes, with each fill's or stroke's geometry effects applied as the renderer applies
|
||||
/// them. Empty for anything but an image with such fills or strokes.
|
||||
pub fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> {
|
||||
if !matches!(n.kind, NodeKind::Image(_)) || !has_item_geometry(n) {
|
||||
return vec![];
|
||||
}
|
||||
let Some(art) = reshape(n, None) else { return vec![] };
|
||||
let mut out = vec![];
|
||||
art.walk(&mut |c| {
|
||||
let (NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. }) = &c.kind else { return };
|
||||
let Some(g) = c.stroke_path() else { return };
|
||||
let ctx = GeomContext::of(c);
|
||||
for item in c.appearance.items.iter().filter(|i| crate::paints(i)) {
|
||||
let fx = item.effects();
|
||||
let shape = if crate::has_geometry(fx) && !g.elements().is_empty() {
|
||||
crate::apply_geometry_bez(fx, &g, g.bounding_box(), &ctx.item(item))
|
||||
} else {
|
||||
g.clone()
|
||||
};
|
||||
out.push((shape, *rule, item.clone()));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// The glyph outlines of type `t` in text space with its underline and strikethrough bars: one
|
||||
@@ -186,11 +252,13 @@ pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) {
|
||||
|
||||
/// `n` (see [`needs_outline`]) with its object-level geometry effects and its perspective applied:
|
||||
/// its [`outline_art`] reshaped as one piece, or for an image the image clipped by its reshaped
|
||||
/// frame. Type's own fills and strokes keep their effects on its whole outline. The result is a
|
||||
/// group with `n`'s id, name, visibility, transparency, opacity mask and remaining (raster)
|
||||
/// effects; `None` when `n` has no visible geometry effect, no perspective and is not type with
|
||||
/// a geometry effect on a fill or stroke of its own ([`has_item_geometry`]), or has no art (a
|
||||
/// symbol instance without `symbol_art`).
|
||||
/// frame. Type's own fills and strokes keep their effects on its whole outline. An image's own
|
||||
/// fills and strokes that paint a shape ([`image_rows`]) become paths along its frame below and
|
||||
/// above it, reshaped with it, and keep their effects from their first Convert to Shape or Outline
|
||||
/// Object on. The result is a group with `n`'s id, name, visibility, transparency, opacity mask
|
||||
/// and remaining (raster) effects; `None` when `n` has no visible geometry effect, no perspective
|
||||
/// and is not type or an image with a fill or stroke of its own that [`has_item_geometry`] counts,
|
||||
/// or has no art (a symbol instance without `symbol_art`).
|
||||
pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !(needs_outline(n) || has_gradient_copies(n)) {
|
||||
return None;
|
||||
@@ -200,14 +268,17 @@ pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
|
||||
/// `n` (see [`needs_outline`], or a path with [`has_gradient_copies`]) as plain art for Expand
|
||||
/// Appearance: [`reshape`]d when it has
|
||||
/// geometry effects, else type with fills or strokes of its own outlined, in the same form. `None`
|
||||
/// when it has neither.
|
||||
/// geometry effects, else type with fills or strokes of its own outlined, or an image with fills
|
||||
/// or strokes of its own that paint a shape, in the same form. `None` when it has none of these.
|
||||
pub fn expand_outlined(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
// A path whose Transform effect copies it with a gradient: each copy an object of its own.
|
||||
if has_gradient_copies(n) {
|
||||
return as_art(n, symbol_art);
|
||||
}
|
||||
let own_paint = matches!(n.kind, NodeKind::Text(_)) && !n.appearance.items.is_empty();
|
||||
let own_paint = match n.kind {
|
||||
NodeKind::Text(_) => !n.appearance.items.is_empty(),
|
||||
_ => has_item_geometry(n),
|
||||
};
|
||||
if !needs_outline(n) || (geometry_effects(n).is_empty() && !own_paint) {
|
||||
return None;
|
||||
}
|
||||
@@ -220,12 +291,28 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
let mut art = match &n.kind {
|
||||
NodeKind::Image(im) => {
|
||||
let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf);
|
||||
let mut clip = Node::path(n.id, frame, Appearance::default());
|
||||
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
let image = Node::new(n.id, n.kind.clone());
|
||||
Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true })
|
||||
let clipped = || {
|
||||
let mut clip = Node::path(n.id, frame.clone(), Appearance::default());
|
||||
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image.clone())], clip: true })
|
||||
};
|
||||
// The fills and strokes that paint a shape, as paths along the frame below and above
|
||||
// the image.
|
||||
let (below, above) = n.appearance.split_contents();
|
||||
let (below, above) = (image_rows(below), image_rows(above));
|
||||
if below.is_empty() && above.is_empty() {
|
||||
clipped()
|
||||
} else {
|
||||
let rows = |items: Vec<AppearanceItem>| {
|
||||
(!items.is_empty()).then(|| Arc::new(Node::path(n.id, frame.clone(), Appearance { items, ..Default::default() })))
|
||||
};
|
||||
// Without geometry effects, the image is not clipped.
|
||||
let image = if fx.is_empty() { image.clone() } else { clipped() };
|
||||
Node::group(n.id, rows(below).into_iter().chain([Arc::new(image)]).chain(rows(above)).collect())
|
||||
}
|
||||
}
|
||||
_ => outline_art(n, symbol_art)?,
|
||||
};
|
||||
@@ -234,7 +321,8 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
if let Some(h) = n.projection() {
|
||||
project_leaves(&mut art, &h);
|
||||
}
|
||||
// The art is a group with `n`'s id (a clip group for an image).
|
||||
// The art is a group with `n`'s id (for an image without fills or strokes that paint, a clip
|
||||
// group).
|
||||
let mut out = art;
|
||||
out.name = n.name.clone();
|
||||
out.visible = n.visible;
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
//! layer's own fills and strokes paint its members' geometry and its effects apply to them as one
|
||||
//! piece; the Contents row (type: Characters) is the slot that says which of its fills and strokes
|
||||
//! paint below the members and which above ([`Appearance::contents_at`], moved by
|
||||
//! `appearance.moveItem {from: "contents"}`).
|
||||
//! `appearance.moveItem {from: "contents"}`). An image's own fills and strokes paint only the
|
||||
//! shape that a Convert to Shape or Outline Object effect on them gives them, below or above the
|
||||
//! image as its Contents row says; new ones go right below that row.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{BlendMode, Paint};
|
||||
@@ -36,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add New Fill",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack (a copy of its top fill; type without one: its characters' fill; else the default fill)",
|
||||
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row (a copy of its top fill; type without one: its characters' fill; else the default fill)",
|
||||
has_doc,
|
||||
|s, p| add_item(s, p, true)
|
||||
),
|
||||
@@ -45,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add New Stroke",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?|id?, target?} add a stroke on top of each selected object's own stack (a copy of its top stroke, else the default stroke)",
|
||||
"{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row (a copy of its top stroke, else the default stroke)",
|
||||
has_doc,
|
||||
|s, p| add_item(s, p, false)
|
||||
),
|
||||
@@ -408,6 +410,9 @@ fn set_active_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
|
||||
let ids = require_targets(s, p, if fill { "appearance.addFill" } else { "appearance.addStroke" })?;
|
||||
let defaults = s.paint.clone();
|
||||
// Where the new item went in on the active item's object, when it is an image.
|
||||
let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied());
|
||||
let mut inserted = None;
|
||||
s.edit(if fill { "Add New Fill" } else { "Add New Stroke" }, |d, _| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
@@ -429,11 +434,23 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
|
||||
} else {
|
||||
AppearanceItem::Stroke(StrokeLayer::new(paint, n.appearance.stroke_width().max(1.0)))
|
||||
};
|
||||
n.appearance.items.push(item);
|
||||
if matches!(n.kind, NodeKind::Image(_)) {
|
||||
// On an image it goes right below the Contents row (the image).
|
||||
let k = n.appearance.contents_at();
|
||||
n.appearance.items.insert(k, item);
|
||||
n.appearance.set_contents_at(k + 1);
|
||||
if active == Some(*id) {
|
||||
inserted = Some(k);
|
||||
}
|
||||
} else {
|
||||
n.appearance.items.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
// The rows above the new item move up by one.
|
||||
s.remap_appearance_item(&ids, |a| Some(a + usize::from(inserted.is_some_and(|k| k <= a))));
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -478,7 +495,7 @@ fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let (Some(f), Some(t)) = (src.geometric_bounds(), to.geometric_bounds()) {
|
||||
ap.rebase_gradients(f, t);
|
||||
}
|
||||
// Only groups, layers and type have a Contents (Characters) row to keep a slot for.
|
||||
// Only groups, layers, type and images have a Contents (Characters) row to keep a slot for.
|
||||
if to.contents_label().is_none() {
|
||||
ap.contents_index = None;
|
||||
}
|
||||
@@ -717,7 +734,7 @@ fn move_contents(s: &mut Session, ids: &[NodeId], to: usize) -> Result<Value> {
|
||||
n.appearance.set_contents_at(to);
|
||||
slot.get_or_insert(n.appearance.contents_at());
|
||||
}
|
||||
slot.ok_or_else(|| bad("appearance.moveItem", "only groups, layers and type have a Contents (Characters) row"))?;
|
||||
slot.ok_or_else(|| bad("appearance.moveItem", "only groups, layers, type and images have a Contents (Characters) row"))?;
|
||||
Ok(count_changed(d, &before, ids))
|
||||
})?;
|
||||
Ok(json!({ "contents": slot }))
|
||||
|
||||
@@ -408,6 +408,7 @@ fn expandable(n: &Node) -> bool {
|
||||
!n.appearance.items.is_empty() || !n.appearance.effects.is_empty() || children.iter().any(|c| expandable(c))
|
||||
}
|
||||
NodeKind::Text(_) => !n.appearance.items.is_empty() || !n.appearance.effects.is_empty(),
|
||||
NodeKind::Image(_) => effects::has_item_geometry(n) || !n.appearance.effects.is_empty(),
|
||||
_ => !n.appearance.effects.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1349,6 +1349,8 @@ impl Session {
|
||||
// Placed documents are read by the native format's loader.
|
||||
vectorcraft_doc::placed_document::set_loader(cmd::place::document::read_document);
|
||||
vectorcraft_doc::placed_document::set_file_reader(cmd::place::document::read_file_again);
|
||||
// Clicks find an image's own fills and strokes where their effects draw them.
|
||||
vectorcraft_doc::hit::set_image_paint(vectorcraft_render::effects::image_paint);
|
||||
Self {
|
||||
docs: vec![],
|
||||
active: None,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Group, layer and type appearance through commands: their own fills, strokes and effects, the
|
||||
//! Contents (Characters) slot, `target: "contents"`, Target Contents and effects on layers.
|
||||
//! Group, layer, type and image appearance through commands: their own fills, strokes and effects,
|
||||
//! the Contents (Characters) slot, `target: "contents"`, Target Contents and effects on layers.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
@@ -161,6 +161,134 @@ fn type_with_an_inline_graphic_stays_type_when_its_own_stroke_has_an_effect() {
|
||||
assert!(matches!(baked.node(t).unwrap().kind, NodeKind::Text(_)));
|
||||
}
|
||||
|
||||
/// A selected 30×30 red image placed with its frame from (50, 50) to (80, 80).
|
||||
fn image(s: &mut Session) -> NodeId {
|
||||
let mut png = vec![];
|
||||
image::RgbaImage::from_pixel(30, 30, image::Rgba([255, 0, 0, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let r = run(s, "file.place", json!({"name": "red.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [65, 65]}));
|
||||
NodeId(r["ids"][0].as_u64().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_image_fills_and_strokes_go_below_the_image() {
|
||||
let mut s = session();
|
||||
let im = image(&mut s);
|
||||
run(&mut s, "appearance.addStroke", json!({}));
|
||||
run(&mut s, "appearance.addFill", json!({}));
|
||||
// Each lands right below the Contents row: [stroke, fill | image].
|
||||
let ap = node(&s, im).appearance;
|
||||
assert!(!ap.items[0].is_fill() && ap.items[1].is_fill() && ap.contents_at() == 2, "{ap:?}");
|
||||
assert_eq!(run(&mut s, "appearance.moveItem", json!({"from": "contents", "to": 1}))["contents"], 1);
|
||||
assert_eq!(node(&s, im).appearance.contents_at(), 1);
|
||||
// The active row above the image stays active when a new item goes in below it.
|
||||
run(&mut s, "appearance.setActiveItem", json!({"index": 1}));
|
||||
run(&mut s, "appearance.addStroke", json!({}));
|
||||
assert_eq!(s.appearance_item(), Some(2));
|
||||
assert!(node(&s, im).appearance.items[2].is_fill());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_images_stroke_with_outline_object_draws_exports_and_expands_along_its_frame() {
|
||||
let mut s = session();
|
||||
let im = image(&mut s);
|
||||
run(&mut s, "appearance.addStroke", json!({}));
|
||||
run(&mut s, "appearance.setItem", json!({"index": 0, "weight": 4, "color": "#000000"}));
|
||||
// Without an effect the stroke draws nothing, and Use Preview Bounds measures the frame.
|
||||
assert_eq!(pixel(&s, 49, 65), [255; 4]);
|
||||
s.prefs.use_preview_bounds = true;
|
||||
assert_eq!(s.transform_bounds(&[im]), Some(vectorcraft_geom::Rect::new(50.0, 50.0, 80.0, 80.0)));
|
||||
assert!(s.execute("effect.expandAppearance", &json!({})).is_err());
|
||||
run(&mut s, "effect.apply", json!({"effect": "path.outlineObject", "item": 0}));
|
||||
// Under the image, the stroke's outer 2 pt shows.
|
||||
assert_eq!((pixel(&s, 48, 65), pixel(&s, 49, 65), pixel(&s, 51, 65)), ([0, 0, 0, 255], [0, 0, 0, 255], RED));
|
||||
assert_eq!(pixel(&s, 47, 65), [255; 4]);
|
||||
// SVG and PDF hold the image and the frame stroked 4 pt.
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let svg = vectorcraft_svg::export(&doc, &Default::default());
|
||||
assert!(svg.contains("<image") && svg.contains("stroke-width=\"4\""), "{svg}");
|
||||
let back = vectorcraft_pdf::import(&vectorcraft_pdf::export(&doc, &Default::default()).unwrap()).unwrap();
|
||||
let (mut images, mut frames) = (0, 0);
|
||||
for l in &back.layers {
|
||||
l.walk(&mut |n| {
|
||||
images += usize::from(matches!(n.kind, NodeKind::Image(_)));
|
||||
let framed = n.geometric_bounds().is_some_and(|b| [b.x0 - 50.0, b.y0 - 50.0, b.x1 - 80.0, b.y1 - 80.0].iter().all(|d| d.abs() < 0.01));
|
||||
frames += usize::from(matches!(n.kind, NodeKind::Path { .. }) && framed && (n.appearance.stroke_width() - 4.0).abs() < 0.01);
|
||||
});
|
||||
}
|
||||
assert_eq!((images, frames), (1, 1));
|
||||
// Expand Appearance leaves the stroke's outline (a filled path) below the image, in one step.
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
run(&mut s, "effect.expandAppearance", json!({}));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
let n = node(&s, im);
|
||||
let kids = n.children().unwrap();
|
||||
assert_eq!(kids.len(), 2, "{n:?}");
|
||||
assert!(matches!(kids[1].kind, NodeKind::Image(_)), "{n:?}");
|
||||
let outline = kids[0].visual_bounds().unwrap();
|
||||
assert!((outline.x0 - 48.0).abs() < 0.01 && (outline.x1 - 82.0).abs() < 0.01, "{outline:?}");
|
||||
assert_eq!(kids[0].appearance.fill_paint(), Paint::solid(Color::BLACK));
|
||||
}
|
||||
|
||||
/// Click with the Selection tool at (x, y) on a 100% view: what is selected afterwards.
|
||||
fn click(s: &mut Session, x: f64, y: f64) -> Vec<NodeId> {
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
for k in [vectorcraft_tools::PointerKind::Down, vectorcraft_tools::PointerKind::Up] {
|
||||
s.pointer(&vectorcraft_tools::PointerEvent::new(k, x, y), v).unwrap();
|
||||
}
|
||||
s.active().unwrap().selection.objects.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_on_an_images_stroke_or_fill_outside_its_frame_selects_the_image() {
|
||||
let mut s = session();
|
||||
let im = image(&mut s);
|
||||
// A 4 pt stroke 12 pt outside the frame: it paints from x 36 to 40, the frame starts at 50.
|
||||
run(&mut s, "appearance.addStroke", json!({}));
|
||||
run(&mut s, "appearance.setItem", json!({"index": 0, "weight": 4, "color": "#000000"}));
|
||||
run(&mut s, "effect.apply", json!({"effect": "path.outlineObject", "item": 0}));
|
||||
run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 12}, "item": 0}));
|
||||
assert_eq!(pixel(&s, 38, 65), [0, 0, 0, 255]);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
assert_eq!(click(&mut s, 38.0, 65.0), [im]);
|
||||
// Between the stroke and the frame, and outside the stroke, nothing paints.
|
||||
run(&mut s, "select.none", json!({}));
|
||||
assert!(click(&mut s, 45.0, 65.0).is_empty());
|
||||
assert!(click(&mut s, 30.0, 65.0).is_empty());
|
||||
// A fill that Convert to Shape makes 9 pt larger than the frame on each side.
|
||||
run(&mut s, "select.set", json!({"ids": [im.0]}));
|
||||
run(&mut s, "appearance.addFill", json!({}));
|
||||
run(&mut s, "paint.setFill", json!({"color": "#00ff00", "item": 1}));
|
||||
run(&mut s, "effect.apply", json!({"effect": "convertToShape.rectangle", "item": 1}));
|
||||
assert_eq!(pixel(&s, 44, 65), GREEN);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
assert_eq!(click(&mut s, 44.0, 65.0), [im]);
|
||||
// In a group, the stroke selects the group.
|
||||
let r = id_of(&run(&mut s, "shape.rectangle", json!({"x": 120, "y": 120, "width": 20, "height": 20})));
|
||||
run(&mut s, "select.set", json!({"ids": [im.0, r.0]}));
|
||||
let g = id_of(&run(&mut s, "object.group", json!({})));
|
||||
run(&mut s, "select.none", json!({}));
|
||||
assert_eq!(click(&mut s, 38.0, 65.0), [g]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_on_the_copy_of_an_images_stroke_that_a_transform_effect_makes_selects_the_image() {
|
||||
let mut s = session();
|
||||
let im = image(&mut s);
|
||||
run(&mut s, "appearance.addStroke", json!({}));
|
||||
run(&mut s, "appearance.setItem", json!({"index": 0, "weight": 4, "color": "#000000"}));
|
||||
run(&mut s, "effect.apply", json!({"effect": "path.outlineObject", "item": 0}));
|
||||
// One copy 60 pt to the right: its stroke paints from x 138 to 142 on the right.
|
||||
run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": {"moveH": 60, "copies": 1}, "item": null}));
|
||||
assert_eq!(pixel(&s, 141, 65), [0, 0, 0, 255]);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
assert_eq!(click(&mut s, 141.0, 65.0), [im]);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
assert!(click(&mut s, 147.0, 65.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_appearance_round_trips_and_expands() {
|
||||
let mut s = session();
|
||||
|
||||
+12
-10
@@ -79,9 +79,10 @@ pub(crate) fn has_fx(n: &Node) -> bool {
|
||||
|
||||
/// Does `n` carry visible object-level effects that only apply through its art (type, images,
|
||||
/// symbol instances, live objects; Crop Marks and colour adjustments on anything), is it type with
|
||||
/// a geometry effect on a fill or stroke of its own ([`effects::has_item_geometry`]), is it a path
|
||||
/// whose Transform effect copies it with a gradient ([`effects::has_gradient_copies`]), or is it a
|
||||
/// group or layer with an appearance of its own (see [`Renderer::draw_object_fx`])?
|
||||
/// a geometry effect on a fill or stroke of its own or an image with a fill or stroke of its own
|
||||
/// that paints a shape ([`effects::has_item_geometry`]), is it a path whose Transform effect copies
|
||||
/// it with a gradient ([`effects::has_gradient_copies`]), or is it a group or layer with an
|
||||
/// appearance of its own (see [`Renderer::draw_object_fx`])?
|
||||
pub(crate) fn has_object_fx(n: &Node) -> bool {
|
||||
(effects::needs_outline(n) && (visible(&n.appearance.effects) || n.projection().is_some()))
|
||||
|| effects::has_item_geometry(n)
|
||||
@@ -341,9 +342,9 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
r
|
||||
}
|
||||
// As [`cull_bounds_in`] measures them, without the object's own raster effects: the
|
||||
// reshaped art (type's outlines measured one by one with the effects of its own fills and
|
||||
// strokes), grown by how far the effects in a symbol's art and a live object's content
|
||||
// paint past it.
|
||||
// reshaped art (type's outlines, and an image and the paths of its own fills and strokes,
|
||||
// measured one by one with the effects of those fills and strokes), grown by how far the
|
||||
// effects in a symbol's art and a live object's content paint past it.
|
||||
_ if has_object_fx(n) => {
|
||||
let symbol = match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } => symbols.document().symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
|
||||
@@ -351,7 +352,7 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
};
|
||||
let reshaped = effects::reshape(n, symbol.as_deref());
|
||||
let b = match &reshaped {
|
||||
Some(r) if matches!(n.kind, NodeKind::Text(_)) => {
|
||||
Some(r) if matches!(n.kind, NodeKind::Text(_)) || (matches!(n.kind, NodeKind::Image(_)) && !r.clips()) => {
|
||||
r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, painted_reach(symbols, c)))
|
||||
}
|
||||
Some(r) => r.visual_bounds_with(symbols),
|
||||
@@ -444,8 +445,9 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio
|
||||
}
|
||||
_ if has_fx(n) => visual_bounds(symbols, n),
|
||||
// The reshaped art (a symbol instance's reshaped art is made from its symbol's art; type's
|
||||
// outlines measured one by one with the geometry effects of its own fills and strokes),
|
||||
// grown by how far the effects in a symbol instance's art paint past that art.
|
||||
// outlines, and an image and the paths of its own fills and strokes, measured one by one
|
||||
// with the geometry effects of those fills and strokes), grown by how far the effects in a
|
||||
// symbol instance's art paint past that art.
|
||||
_ if has_object_fx(n) => {
|
||||
let symbol = match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } => symbols.document().symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
|
||||
@@ -453,7 +455,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio
|
||||
};
|
||||
let reshaped = effects::reshape(n, symbol.as_deref());
|
||||
let b = match &reshaped {
|
||||
Some(r) if matches!(n.kind, NodeKind::Text(_)) => {
|
||||
Some(r) if matches!(n.kind, NodeKind::Text(_)) || (matches!(n.kind, NodeKind::Image(_)) && !r.clips()) => {
|
||||
r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c)))
|
||||
}
|
||||
Some(r) => r.visual_bounds_with(symbols),
|
||||
|
||||
@@ -8,7 +8,7 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_doc::pattern::{PatternDef, pattern_paint};
|
||||
use vectorcraft_doc::text::{CharStyle, TextObject};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, ImageBlob, ImageObject, Node, NodeKind, Symbol};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, ImageBlob, ImageObject, Node, NodeKind, StrokeLayer, Symbol};
|
||||
use vectorcraft_geom::{Point, shapes};
|
||||
|
||||
use super::*;
|
||||
@@ -679,6 +679,65 @@ fn a_rectangle_on_a_fill_below_type_with_outline_object_fits_the_glyphs() {
|
||||
assert_eq!(lum(img.pixel(x, glyphs.y1 as u32 + 12)), WHITE);
|
||||
}
|
||||
|
||||
/// [`image_doc`] with a black 4 pt stroke of its own that carries the effects `row`, below the
|
||||
/// image when `below`.
|
||||
fn stroked_image_doc(row: Vec<Effect>, below: bool) -> Document {
|
||||
let mut d = image_doc(vec![]);
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
let mut stroke = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0);
|
||||
stroke.effects = row;
|
||||
let ap = &mut d.node_mut(id).unwrap().appearance;
|
||||
ap.items.push(AppearanceItem::Stroke(stroke));
|
||||
ap.set_contents_at(usize::from(below));
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_images_stroke_draws_only_the_shape_convert_to_shape_or_outline_object_gives_it() {
|
||||
// The image's frame runs from 20 to 50. Without an effect the stroke draws nothing.
|
||||
for below in [false, true] {
|
||||
let img = render(&stroked_image_doc(vec![], below));
|
||||
assert_eq!((lum(img.pixel(18, 35)), img.pixel(21, 35)), (WHITE, [255, 0, 0, 255]));
|
||||
}
|
||||
let rectangle = fx("convertToShape.rectangle", json!({"relative": true, "extraW": 0, "extraH": 0}));
|
||||
let outline = fx("path.outlineObject", json!({}));
|
||||
for effect in [rectangle, outline] {
|
||||
// Below the image only the stroke's outer half shows, above it both halves.
|
||||
let img = render(&stroked_image_doc(vec![effect.clone()], true));
|
||||
assert_eq!((lum(img.pixel(17, 35)), lum(img.pixel(18, 35)), img.pixel(21, 35)), (WHITE, 0, [255, 0, 0, 255]), "{}", effect.id);
|
||||
let img = render(&stroked_image_doc(vec![effect.clone()], false));
|
||||
assert_eq!((lum(img.pixel(18, 35)), lum(img.pixel(21, 35)), img.pixel(23, 35)), (0, 0, [255, 0, 0, 255]), "{}", effect.id);
|
||||
// An effect before it is left out.
|
||||
let first = fx("path.offsetPath", json!({"offset": 10}));
|
||||
let img = render(&stroked_image_doc(vec![first, effect.clone()], true));
|
||||
assert_eq!((lum(img.pixel(10, 35)), lum(img.pixel(18, 35))), (WHITE, 0), "{}", effect.id);
|
||||
}
|
||||
// An Offset Path of 2 pt after Outline Object moves the stroke 2 pt out.
|
||||
let img = render(&stroked_image_doc(vec![fx("path.outlineObject", json!({})), fx("path.offsetPath", json!({"offset": 2}))], true));
|
||||
assert_eq!((lum(img.pixel(15, 35)), lum(img.pixel(16, 35)), lum(img.pixel(19, 35))), (WHITE, 0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_images_stroke_is_drawn_when_only_it_is_in_view() {
|
||||
// The stroke runs 8 pt outside the frame, from 10 to 14; the view shows x 15 and less.
|
||||
let row = vec![fx("path.outlineObject", json!({})), fx("path.offsetPath", json!({"offset": 8}))];
|
||||
let d = stroked_image_doc(row, true);
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let img = Renderer::new().render(&d, 100, 100, Affine::translate((85.0, 0.0)), &opts);
|
||||
assert_eq!((lum(img.pixel(96, 35)), lum(img.pixel(99, 35))), (0, WHITE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterize_on_an_image_takes_in_its_strokes_shape() {
|
||||
// The stroke runs 8 pt outside the frame, from 10 to 14; Rasterize at 72 ppi keeps it.
|
||||
let row = vec![fx("path.outlineObject", json!({})), fx("path.offsetPath", json!({"offset": 8}))];
|
||||
let mut d = stroked_image_doc(row, true);
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
d.node_mut(id).unwrap().appearance.effects.push(fx("rasterize", json!({"useDocumentResolution": false, "resolution": 72})));
|
||||
let img = render(&d);
|
||||
assert_eq!((lum(img.pixel(8, 35)), lum(img.pixel(12, 35)), img.pixel(35, 35)), (WHITE, 0, [255, 0, 0, 255]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_strokes_own_glow_paints_around_that_stroke_only() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
|
||||
@@ -625,15 +625,20 @@ fn contents_row(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, label: &str, se
|
||||
chip(ui, cr, n.proxy_paint(stroke, None).map_or(&Paint::None, |(p, ..)| p));
|
||||
}
|
||||
}
|
||||
let resp =
|
||||
resp.on_hover_text(if characters { tl!("Double-click to edit the characters' paint") } else { tl!("Double-click to target the contents") });
|
||||
// An image's row has no members to target.
|
||||
let image = matches!(n.kind, vectorcraft_doc::NodeKind::Image(_));
|
||||
let resp = if image {
|
||||
resp
|
||||
} else {
|
||||
resp.on_hover_text(if characters { tl!("Double-click to edit the characters' paint") } else { tl!("Double-click to target the contents") })
|
||||
};
|
||||
if resp.clicked() || resp.double_clicked() {
|
||||
select_row(app, ui.ctx(), Sel::Contents);
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
if characters {
|
||||
app.ui.open_panel = Some("color".into());
|
||||
} else {
|
||||
} else if !image {
|
||||
app.run("appearance.targetContents", json!({})).ok();
|
||||
}
|
||||
}
|
||||
@@ -1389,6 +1394,22 @@ mod tests {
|
||||
assert_eq!(rows(frame(&mut app)), ["Fill:", "Characters"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_show_a_contents_row_with_new_fills_and_strokes_below_it() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
let run = |app: &mut VectorcraftApp, id: &str, p: Value| app.session.execute(id, &p).unwrap();
|
||||
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
|
||||
let rows = |t: Vec<String>| t.into_iter().filter(|t| ["Fill:", "Stroke:", "Contents"].contains(&t.as_str())).collect::<Vec<_>>();
|
||||
let mut png = vec![];
|
||||
image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
run(&mut app, "file.place", json!({"name": "red.png", "dataBase64": vectorcraft_format::base64_encode(&png)}));
|
||||
assert_eq!(rows(frame(&mut app)), ["Contents"]);
|
||||
run(&mut app, "appearance.addStroke", json!({}));
|
||||
assert_eq!(rows(frame(&mut app)), ["Contents", "Stroke:"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_move_items_across_the_contents_row_and_the_row_itself() {
|
||||
use vectorcraft_doc::FillLayer;
|
||||
|
||||
+9
-1
@@ -1423,7 +1423,8 @@ group together.
|
||||
Groups and layers carry fills, strokes and effects of their own, as in the reference app: their fills and strokes
|
||||
paint every member's geometry, and their effects apply to the members as one piece (one combined drop shadow; a
|
||||
Transform or Warp moves or bends the whole group). Their Contents row (type: Characters) is a slot in the stack:
|
||||
fills and strokes above it paint over the members (characters), those below under them; new items go above it.
|
||||
fills and strokes above it paint over the members (characters), those below under them; new items go above it
|
||||
(on an image, below it).
|
||||
`appearance.moveItem {"from": "contents", "to": n}` puts the row above the bottom `n` items, and an item move takes
|
||||
`contents` to say where the row ends up in the same undo step; `document.node` shows the slot as
|
||||
`appearance.contents_index`. The `appearance.*`, `effect.*` and `graphicStyle.apply` commands edit the selected
|
||||
@@ -1435,6 +1436,13 @@ A geometry effect on one of type's own fills or strokes (`effect.apply` with `it
|
||||
the fill or stroke paints, and SVG, PDF, EPS and the other vector exports write such type as paths. On type that
|
||||
holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective
|
||||
of its own.
|
||||
An image's own fills and strokes draw only when they carry a visible Convert to Shape or Outline Object effect: Convert
|
||||
to Shape sizes its shape from the image's box, Outline Object gives the image's frame, the effects after it reshape
|
||||
that shape, and the effects before it are left out. Its Contents row stands for the image; `appearance.addFill` and
|
||||
`appearance.addStroke` put a new item right below that row, so it paints under the image. SVG, PDF, EPS and the other
|
||||
vector exports write those items as paths, and `effect.expandAppearance` turns them into objects in a group with the
|
||||
image.
|
||||
Use Preview Bounds measures an image by its frame.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"appearance.addFill","params":{"ids":[7]}}}
|
||||
|
||||
@@ -684,6 +684,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
In the Brushes panel, Shift-click selects a range of brushes and Ctrl/Cmd-click adds or removes one. Delete Brush and Duplicate Brush then act on all of them (`brush.delete {names}`, `brush.duplicate {names}`).
|
||||
- **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects.
|
||||
Fill and stroke paint (Fill, Stroke, None, Apply Last Color and Apply Last Gradient, also on characters selected with the Type tool), the Stroke panel's options, gradient and freeform point edits, Swap Fill and Stroke, Default Fill and Stroke, the Eyedropper, and the Appearance panel's item edits, Clear Appearance, Reduce to Basic Appearance, Remove Item, item reordering and Move Appearance record no undo step when they change nothing.
|
||||
An image's own fills and strokes draw the shape that a Convert to Shape or Outline Object effect on them gives them (the image's box or its frame, reshaped by the effects after it), under or over the image as its Contents row says, on the canvas, in exports and through Expand Appearance; new fills and strokes on an image go under it. Without one of those effects they draw nothing, and Use Preview Bounds measures an image by its frame.
|
||||
A geometry effect on one of type's own fills or strokes reshapes the whole outline that the fill or stroke paints, on the canvas and in exports; PDF, SVG, EPS and the other vector exports write such type as paths. On type that holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own.
|
||||
The Appearance panel's inline effect editor labels each parameter as the effect dialogs' generic fields do, without the colon ("Max. Radius", "Channel 1", "Create New Fields by"). Roughen's and Zig Zag's Points, Offset Path's Joins, Inner Glow's Source and the menus of Radial Blur, Smart Blur, Sprayed Strokes, Glass, Mezzotint, Halftone Pattern, Grain, Texturizer and De-Interlace are dropdowns with the dialog's labels that send the dialog's values, and a plug-in's choice is a dropdown of the plug-in's values.
|
||||
Revolve's edge, Extrude & Bevel's bevel, every Warp style's orientation, Rasterize's Color Model, Background and Anti-aliasing and the channel of Levels and Curves are dropdowns too, with the labels their own dialogs show. `effect.apply` and `effect.setParams` refuse a Levels or Curves `channel` other than `rgb`, `red`, `green` or `blue` (or `r`, `g` and `b`, in any letter case).
|
||||
|
||||
Reference in New Issue
Block a user