M3.68: groups, layers and type have their own fills, strokes and effects, with Contents and Characters rows

A group's or layer's own fills and strokes paint every member's geometry, below or above the
members as the new contents slot (Appearance.contents_index) says; its geometry effects reshape
the members as one piece and its raster effects apply to the composite (one combined shadow),
respecting knockout groups. Type's own fills and strokes paint under or over its characters.
Render, SVG/PDF (via bake) and Expand Appearance evaluate them alike.

Commands: appearance.* / effect.* take target: "object"|"contents" and layer ids;
appearance.moveItem moves the Contents slot (from: "contents") or an item across it
(contents); appearance.targetContents selects a group's members. Graphic styles applied to a
group now style the group itself. The Appearance panel shows Contents/Characters rows that
drag, and double-click to target the members or edit the characters' paint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 00:59:34 +07:00
co-authored by Claude Opus 5.5
parent 25c8f32fdd
commit 8a837fd4b9
35 changed files with 1282 additions and 245 deletions
+39 -25
View File
@@ -678,6 +678,18 @@ impl Exporter<'_> {
fn text(&mut self, s: &mut Surface, n: &Node, t: &TextObject) {
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let tb = t.xf.transform_rect_bbox(layout.bounds);
// The object's own fills and strokes paint the whole outline: those below the Characters
// row under the characters, the others over them.
let (below, above) = n.appearance.split_contents();
let all = (!n.appearance.items.is_empty()).then(|| {
let mut all = layout.to_bezpath();
all.apply_affine(t.xf);
to_path(&all)
});
let all = all.flatten();
if let Some(path) = &all {
self.text_items(s, below, path, tb);
}
s.push_transform(&xf(t.xf));
for (i, run) in t.runs.iter().enumerate() {
let mut bp = BezPath::new();
@@ -701,34 +713,36 @@ impl Exporter<'_> {
s.set_fill(None);
s.set_stroke(None);
s.pop();
if !n.appearance.items.is_empty() {
let mut all = layout.to_bezpath();
all.apply_affine(t.xf);
let Some(path) = to_path(&all) else { return };
for item in &n.appearance.items {
match item {
AppearanceItem::Fill(fl) if fl.visible => {
if let Some(paint) = self.paint(&fl.paint, tb) {
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: norm(fl.opacity), rule: krilla::paint::FillRule::NonZero }));
s.draw_path(&path);
}
}
AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 => {
if let Some(paint) = self.paint(&st.paint, st.paint_bounds(tb)) {
s.set_fill(None);
s.set_stroke(Some(Stroke { paint, width: st.width as f32, opacity: norm(st.opacity), ..Default::default() }));
s.draw_path(&path);
}
}
_ => {}
}
}
s.set_fill(None);
s.set_stroke(None);
if let Some(path) = &all {
self.text_items(s, above, path, tb);
}
}
/// A type object's own fills and strokes `items` on its glyph outlines `path` (`tb`: their bounds).
fn text_items(&mut self, s: &mut Surface, items: &[AppearanceItem], path: &Path, tb: Rect) {
for item in items {
match item {
AppearanceItem::Fill(fl) if fl.visible => {
if let Some(paint) = self.paint(&fl.paint, tb) {
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: norm(fl.opacity), rule: krilla::paint::FillRule::NonZero }));
s.draw_path(path);
}
}
AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 => {
if let Some(paint) = self.paint(&st.paint, st.paint_bounds(tb)) {
s.set_fill(None);
s.set_stroke(Some(Stroke { paint, width: st.width as f32, opacity: norm(st.opacity), ..Default::default() }));
s.draw_path(path);
}
}
_ => {}
}
}
s.set_fill(None);
s.set_stroke(None);
}
fn load_image(&mut self, key: &str) -> Option<Image> {
if let Some(i) = self.images.get(key) {
return i.clone();
+2 -2
View File
@@ -461,7 +461,7 @@ impl<'a> Device<'a> for Builder {
PathDrawMode::Fill(rule) => {
let mut f = FillLayer::new(paint);
f.opacity = opacity;
let mut n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Fill(f)], effects: vec![] });
let mut n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Fill(f)], ..Default::default() });
if let NodeKind::Path { rule: r, .. } = &mut n.kind {
*r = fill_rule(*rule);
}
@@ -480,7 +480,7 @@ impl<'a> Device<'a> for Builder {
Arc::make_mut(last).appearance.items.push(AppearanceItem::Stroke(st));
return;
}
let n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] });
let n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() });
self.push_node(n);
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ fn fitted_dashes_on_a_rectangle_look_as_on_the_canvas() {
let mut n = Node::path(
NodeId(0),
shapes::rectangle(Rect::new(20.0, 20.0, 120.0, 70.0)),
Appearance { items: vec![AppearanceItem::Stroke(dashed(true))], effects: vec![] },
Appearance { items: vec![AppearanceItem::Stroke(dashed(true))], ..Default::default() },
);
n.id = d.alloc_id();
let layer = d.default_layer().unwrap();
+1 -1
View File
@@ -13,7 +13,7 @@ pub(crate) fn line_doc(st: StrokeLayer) -> (Document, Node) {
let mut n = Node::path(
NodeId(0),
shapes::line(Point::new(20.0, 50.0), Point::new(80.0, 50.0)),
Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] },
Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() },
);
n.id = d.alloc_id();
let layer = d.default_layer().unwrap();
+1 -1
View File
@@ -56,7 +56,7 @@ fn outside_strokes_keep_their_miter_spikes() {
s.join = LineJoin::Miter;
s.miter_limit = 10.0;
});
let mut n = Node::path(NodeId(0), PathData::single(tri), Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] });
let mut n = Node::path(NodeId(0), PathData::single(tri), Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() });
n.id = d.alloc_id();
let layer = d.default_layer().unwrap();
d.insert(Some(layer), 0, n).unwrap();