Merge pull request #2333 from dotCooCoo/feat/outline-object-type-item-effects

Type: geometry effects on a type object's own fills and strokes are drawn and exported
This commit is contained in:
BFlat
2026-10-16 15:44:58 +07:00
committed by GitHub
8 changed files with 139 additions and 16 deletions
+4 -3
View File
@@ -28,7 +28,7 @@ use crate::group::{has_own_paint, paints};
use crate::reshape::{as_art, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves};
use crate::{
GeomContext, adjust_in_document, apply_geometry_with, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry,
has_pathfinder, is_geometry, is_pathfinder, merged_params, needs_outline, outline_text, reshape,
has_item_geometry, has_pathfinder, is_geometry, is_pathfinder, merged_params, needs_outline, outline_text, reshape,
};
fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
@@ -106,9 +106,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. Type that
// 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.
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some()) {
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));
}
+1 -1
View File
@@ -69,7 +69,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::{RasterFx, outset, raster_effects};
pub use reshape::{expand_outlined, needs_outline, outline_art, outline_text, reshape};
pub use reshape::{expand_outlined, has_item_geometry, 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,
+17 -5
View File
@@ -5,7 +5,9 @@
//! (glyph outlines, the evaluated live object, the symbol's art) and [`reshape`] runs each effect
//! over all of them as one piece, with the whole art's bounds as the effect's reference box, so a
//! 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.
//! (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.
use std::sync::Arc;
@@ -30,6 +32,14 @@ pub fn needs_outline(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
/// 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()))
}
/// The glyph outlines of type `t` in text space with its underline and strikethrough bars: one
/// path per run, and all of them as one.
pub(crate) fn glyph_outlines(t: &vectorcraft_doc::TextObject) -> (Vec<kurbo::BezPath>, kurbo::BezPath) {
@@ -155,11 +165,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. The result is a group with `n`'s id, name, visibility, transparency, opacity mask and
/// remaining (raster) effects; `None` when `n` has neither a visible geometry effect nor a
/// perspective, or has no art (a symbol instance without `symbol_art`).
/// 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`).
pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
if (geometry_effects(n).is_empty() && n.projection().is_none()) || !needs_outline(n) {
if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !needs_outline(n) {
return None;
}
as_art(n, symbol_art)
+39
View File
@@ -122,6 +122,45 @@ fn type_fills_go_above_or_below_the_characters() {
assert_eq!(node(&s, t).appearance.contents_at(), 1);
}
#[test]
fn an_effect_on_the_types_own_stroke_exports_as_the_offset_outline() {
let mut s = session();
let t = id_of(&run(&mut s, "text.create", json!({"x": 20, "y": 100, "text": "Hi"})));
run(&mut s, "select.set", json!({"ids": [t.0]}));
run(&mut s, "appearance.addStroke", json!({}));
run(&mut s, "paint.setStroke", json!({"color": "#ff0000", "item": 0}));
run(&mut s, "appearance.moveItem", json!({"from": "contents", "to": 1}));
run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 4}, "item": 0}));
let doc = s.doc().unwrap().doc.clone();
let svg = vectorcraft_svg::export(&doc, &Default::default());
assert!(!svg.contains("<text") && svg.contains("<path"), "{svg}");
// The baked type is its outlines, and the stroke's outline lies 4 pt outside the glyphs on every side.
let glyphs = vectorcraft_render::effects::outline_text(&node(&s, t)).unwrap().geometric_bounds().unwrap();
let baked = vectorcraft_render::effects::bake_document(&doc).unwrap();
let n = baked.node(t).unwrap();
assert!(!matches!(n.kind, NodeKind::Text(_)), "{n:?}");
let b = n.geometric_bounds().unwrap();
let off = [glyphs.x0 - b.x0, glyphs.y0 - b.y0, b.x1 - glyphs.x1, b.y1 - glyphs.y1];
assert!(off.iter().all(|o| (o - 4.0).abs() < 0.5), "{off:?}");
}
#[test]
fn type_with_an_inline_graphic_stays_type_when_its_own_stroke_has_an_effect() {
let mut s = session();
run(&mut s, "shape.ellipse", json!({"x": 150, "y": 150, "width": 10, "height": 10}));
run(&mut s, "symbol.new", json!({"name": "Dot"}));
run(&mut s, "edit.clear", json!({}));
let t = id_of(&run(&mut s, "text.create", json!({"x": 20, "y": 100, "text": "Hi"})));
run(&mut s, "text.insertInline", json!({"id": t.0, "at": 0, "symbol": "Dot"}));
run(&mut s, "select.set", json!({"ids": [t.0]}));
run(&mut s, "appearance.addStroke", json!({}));
run(&mut s, "paint.setStroke", json!({"color": "#ff0000", "item": 0}));
run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 4}, "item": 0}));
// Exports keep it as type, with its inline graphic.
let baked = vectorcraft_render::effects::bake_document(&s.doc().unwrap().doc).unwrap();
assert!(matches!(baked.node(t).unwrap().kind, NodeKind::Text(_)));
}
#[test]
fn group_appearance_round_trips_and_expands() {
let mut s = session();
+15 -5
View File
@@ -67,10 +67,12 @@ 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), or is it a group
/// or layer with an appearance of its own (see [`Renderer::draw_object_fx`])?
/// 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`]), 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)
|| effects::has_container_appearance(n)
|| effects::has_crop_marks(n)
|| effects::has_adjustment(n)
@@ -212,15 +214,23 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c)))
}
_ if has_fx(n) => visual_bounds(symbols, n),
// The reshaped art (a symbol instance's reshaped art is made from its symbol's art), grown
// by how far the effects in a symbol instance's art paint past that art.
// 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.
_ 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()),
_ => None,
};
let reshaped = effects::reshape(n, symbol.as_deref());
let b = reshaped.as_ref().and_then(|r| r.visual_bounds_with(symbols)).or_else(|| n.visual_bounds_with(symbols))?;
let b = match &reshaped {
Some(r) if matches!(n.kind, NodeKind::Text(_)) => {
r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c)))
}
Some(r) => r.visual_bounds_with(symbols),
None => None,
}
.or_else(|| n.visual_bounds_with(symbols))?;
let a = match &reshaped {
Some(r) if after_reshape => reshaped_art_effects_reach(symbols, n, r),
_ => art_effects_reach(symbols, n),
+58 -2
View File
@@ -1,7 +1,7 @@
//! The appearance of groups, layers and type: a container's own fills and strokes paint its
//! members' geometry below or above them (the contents slot), its effects apply to the composite
//! (one combined shadow) and reshape its members as one piece; type's own fills paint under or
//! over its characters.
//! (one combined shadow) and reshape its members as one piece; type's own fills and strokes paint
//! under or over its characters, reshaped by their geometry effects.
use std::sync::Arc;
@@ -292,3 +292,59 @@ fn a_fill_below_the_characters_is_hidden_by_the_glyphs() {
assert!(red_pixels(&render(&type_doc(false))) > 50, "above: the red fill covers the glyphs");
assert_eq!(red_pixels(&render(&type_doc(true))), 0, "below: the black glyphs cover it");
}
/// "IIII" in black at 40 pt with its own red 2 pt stroke under the characters. The stroke carries
/// the effects `row`, and the type carries the effects `object`.
fn stroked_type_doc(row: &[Effect], object: &[Effect]) -> Document {
let mut d = Document::new(100.0, 100.0);
let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(5.0, 60.0), "IIII", style))));
let mut stroke = StrokeLayer::new(solid(1.0, 0.0, 0.0), 2.0);
stroke.effects = row.to_vec();
n.appearance.items.push(AppearanceItem::Stroke(stroke));
n.appearance.set_contents_at(1);
n.appearance.effects = object.to_vec();
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
}
/// The red pixels more than 2 px outside the box of the black ones.
fn red_past_the_glyphs(img: &Rendered) -> usize {
let all = || (0..100).flat_map(|y| (0..100).map(move |x| (x, y)));
let black: Vec<(u32, u32)> = all().filter(|&(x, y)| img.pixel(x, y)[..3].iter().all(|c| *c < 60)).collect();
let (x0, x1) = (black.iter().map(|p| p.0).min().unwrap(), black.iter().map(|p| p.0).max().unwrap());
let (y0, y1) = (black.iter().map(|p| p.1).min().unwrap(), black.iter().map(|p| p.1).max().unwrap());
let past = |x: u32, y: u32| x + 2 < x0 || x > x1 + 2 || y + 2 < y0 || y > y1 + 2;
all().filter(|&(x, y)| past(x, y) && matches!(img.pixel(x, y), [r, g, _, _] if r > 200 && g < 60)).count()
}
#[test]
fn an_offset_path_on_the_types_own_stroke_draws_the_offset_outline() {
assert_eq!(red_past_the_glyphs(&render(&stroked_type_doc(&[], &[]))), 0, "the stroke without the effect stays at the glyphs");
let offset = [fx("path.offsetPath", json!({"offset": 4}))];
assert!(red_past_the_glyphs(&render(&stroked_type_doc(&offset, &[]))) > 50, "the stroke runs 4 pt outside the glyphs");
}
#[test]
fn a_shadow_on_the_type_keeps_the_offset_outline_of_its_own_stroke() {
let offset = [fx("path.offsetPath", json!({"offset": 4}))];
let shadow = [fx("stylize.dropShadow", json!({"x": 0, "y": 30, "blur": 0, "opacity": 50, "mode": "normal"}))];
assert!(red_past_the_glyphs(&render(&stroked_type_doc(&offset, &shadow))) > 50);
}
#[test]
fn the_offset_outline_of_the_types_own_stroke_is_drawn_when_only_it_is_in_view() {
// The view's right edge is 5 pt left of the glyphs, so only the stroke 8 pt outside them
// reaches into it. The second case also gives the type object Offset Path 0, which leaves its
// outlines as they are.
let offset = [fx("path.offsetPath", json!({"offset": 8}))];
for object in [vec![], vec![fx("path.offsetPath", json!({"offset": 0}))]] {
let d = stroked_type_doc(&offset, &object);
let glyphs = effects::outline_text(&d.layers[0].children().unwrap()[0]).unwrap().geometric_bounds().unwrap();
let view = Affine::translate((105.0 - glyphs.x0, 0.0));
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let img = Renderer::new().render(&d, 100, 100, view, &opts);
assert!(red_pixels(&img) > 20, "{} object effects: {} red pixels", object.len(), red_pixels(&img));
}
}
+4
View File
@@ -1202,6 +1202,10 @@ objects themselves (a group's own stack; layers through `ids`) or, with `target:
groups and layers. `appearance.targetContents` selects a group's members (double-clicking the Contents row).
SVG and PDF export bake a group's own fills, strokes and geometry effects into paths; its raster effects stay a
filter on the whole group in SVG and become an image of the whole group in PDF.
A geometry effect on one of type's own fills or strokes (`effect.apply` with `item`) reshapes the whole outline that
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.
```json
{"name":"run_command","arguments":{"command":"appearance.addFill","params":{"ids":[7]}}}
+1
View File
@@ -569,6 +569,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values or Pressure from the first at the lightest pressure to the second at the heaviest; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, auto-generated Pattern brush corners, new tile art picked in Pattern Brush Options, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS.
- **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.
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.
- **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks.
- **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. SVG export writes a knockout group of opaque art plainly, without masks, so shapes meeting edge to edge leave no seam; translucent, blended, masked or brushed art knocks out through masks. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel.
On the canvas, the renderer no longer draws the picture of the art below back as an image each time it copies the backdrop of a non-isolated group that blends: it keeps that picture and draws the art that follows over it. At 2880 × 1800, 50 translucent groups with Multiply members take 65 ms a frame instead of 299 ms on 8 threads (`vectorcraft-cli bench`), and the frames have the same pixels.