refactor(svg): split node rendering and text setup

This commit is contained in:
Dylan M. Taylor
2026-10-28 16:00:36 +00:00
parent 8df2e4f8dc
commit 763c3a8dc4
2 changed files with 135 additions and 88 deletions
+18 -14
View File
@@ -271,7 +271,10 @@ fn on_path(cx: &mut Ctx, t: &usvg::Text, world: Affine, parent: NodeId) -> bool
local.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
td.path = Some(Box::new(figcraft_doc::TextPath { start: f64::from(tp.start_offset()) / len, ..figcraft_doc::TextPath::new(&local) }));
let transform = world * Affine::translate((bb.x0, bb.y0));
add_layer(cx, t, &[line], td, transform, (bb.width().max(1.0), bb.height().max(1.0)), world, 1.0, parent);
add_layer(
cx,
TextLayer { source: t, block: &[line], data: td, transform, size: (bb.width().max(1.0), bb.height().max(1.0)), world, scale: 1.0, parent },
);
true
}
@@ -293,23 +296,24 @@ fn layer(cx: &mut Ctx, t: &usvg::Text, block: &[Line], m: Affine, s: f64, world:
let (w, h) = (lay.width.ceil().max(1.0), lay.height.max(1.0));
let baseline = lay.lines.first().map_or(0.0, |l| l.baseline);
let transform = m * Affine::translate((s * first.x - anchor_k(first.anchor) * w, s * first.y - baseline));
add_layer(cx, t, block, td, transform, (w, h), world, s, parent);
add_layer(cx, TextLayer { source: t, block, data: td, transform, size: (w, h), world, scale: s, parent });
}
struct TextLayer<'text, 'block, 'span> {
source: &'text usvg::Text,
block: &'block [Line<'span>],
data: TextData,
transform: Affine,
size: (f64, f64),
world: Affine,
scale: f64,
parent: NodeId,
}
/// Add text layer `td` of `block` at `transform` with box `(w, h)`, with the spans' fills and the
/// first span's stroke (`world`: the SVG text's space; `s`: the scale folded into font sizes).
#[allow(clippy::too_many_arguments)]
fn add_layer(
cx: &mut Ctx,
t: &usvg::Text,
block: &[Line],
mut td: TextData,
transform: Affine,
(w, h): (f64, f64),
world: Affine,
s: f64,
parent: NodeId,
) {
fn add_layer(cx: &mut Ctx, layer: TextLayer<'_, '_, '_>) {
let TextLayer { source: t, block, data: mut td, transform, size: (w, h), world, scale: s, parent } = layer;
let Some(first) = block.first() else { return };
let spans: Vec<&usvg::TextSpan> = block.iter().flat_map(|l| l.spans.iter().map(|sp| sp.2)).collect();
if spans.iter().all(|sp| !sp.is_visible()) {
+117 -74
View File
@@ -61,6 +61,18 @@ impl Default for SvgOptions {
}
}
struct NodePaint<'a> {
node: &'a Node,
xf: Affine,
depth: usize,
paint_clip: String,
kid_clip: Option<String>,
inherited: Option<String>,
outline: Option<(BezPath, figcraft_render::FillRule)>,
grains: String,
size: (f64, f64),
}
struct W<'a> {
doc: &'a Document,
/// The exported layers, in z-order (what backdrops see).
@@ -458,6 +470,12 @@ impl W<'_> {
NodeKind::Group | NodeKind::Page { .. } => None,
_ => figcraft_render::node_outline(self.doc, n),
};
self.render_node(NodePaint { node: n, xf, depth, paint_clip, kid_clip, inherited, outline, grains, size: (w, h) });
self.body.push_str("</g>");
}
fn render_node(&mut self, paint: NodePaint<'_>) {
let NodePaint { node: n, xf, depth, paint_clip, kid_clip, inherited, outline, grains, size: (w, h) } = paint;
let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Text(_));
let live_text = if self.opts.outline_text { None } else { self.text_element(n, xf) };
let run_paths = if live_text.is_none() { text_run_paths(n) } else { None };
@@ -486,92 +504,117 @@ impl W<'_> {
}
// Inner shadows paint over the fills, under the children and strokes.
if let Some(o) = &outline {
for e in n.effects.iter().filter(|e| e.visible) {
if let EffectKind::InnerShadow(s) = &e.kind {
self.inner_shadow(o, s, xf);
}
}
self.inner_shadows(n, o, xf);
}
if !matches!(n.kind, NodeKind::BooleanOp { .. }) && !n.children.is_empty() {
let mut kid_clip = kid_clip;
if n.clip
&& n.kind.is_frame_like()
&& let Some((p, _)) = &outline
{
// Its own clip, within the clips around it.
let cid = self.id("clip");
let mut cp = p.clone();
cp.apply_affine(xf);
let _ = write!(self.defs, "<clipPath id=\"{cid}\"{}><path d=\"{}\"/></clipPath>", clip_attr(&kid_clip), path_d(&cp));
kid_clip = Some(cid);
}
self.clip = kid_clip;
let kids = n.children.clone();
for run in self.doc.paint_runs(n) {
let Some(&c) = kids.get(run.start) else { continue };
if self.doc.node(c).is_some_and(|cn| cn.is_mask && cn.visible) {
let kind = self.doc.node(c).map(|mn| mn.mask_type).unwrap_or_default();
if kind == figcraft_doc::MaskType::Vector {
// A vector mask is a clip path: its outline.
let cid = self.id("clip");
let outline =
self.doc.node(c).and_then(|mn| Some((xf * mn.transform, figcraft_render::subtree_outline(self.doc, mn, depth + 1)?)));
let (shape, cr) = match &outline {
Some((mxf, (mp, rule))) => (
format!(" transform=\"{}\" d=\"{}\"", matrix(*mxf), path_d(mp)),
if *rule == figcraft_render::FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" },
),
None => (" d=\"\"".to_string(), ""),
};
let _ = write!(self.defs, "<clipPath id=\"{cid}\"><path{shape}{cr}/></clipPath>");
let _ = write!(self.body, "<g clip-path=\"url(#{cid})\">");
} else {
// The layer as drawn: its alpha or luminance masks.
let mid = self.id("mask");
let saved = std::mem::take(&mut self.body);
let outer = self.mask.replace(c);
let clip = self.clip.take();
self.node(c, xf, depth + 1);
self.clip = clip;
self.mask = outer;
let mask_body = std::mem::replace(&mut self.body, saved);
let alpha = if kind == figcraft_doc::MaskType::Alpha { " style=\"mask-type:alpha\"" } else { "" };
let _ = write!(self.defs, "<mask id=\"{mid}\" maskUnits=\"userSpaceOnUse\"{alpha}>{mask_body}</mask>");
let _ = write!(self.body, "<g mask=\"url(#{mid})\">");
}
for k in kids.get(run.start + 1..run.end).unwrap_or(&[]) {
self.node(*k, xf, depth + 1);
}
self.body.push_str("</g>");
} else {
self.node(c, xf, depth + 1);
}
}
self.clip = inherited;
self.children(n, xf, depth, outline.as_ref(), kid_clip, inherited);
}
if n.has_visible_stroke()
&& let Some(simple) = self.opts.simplify_stroke.then(|| self.simple_stroke(n, xf)).flatten()
{
self.body.push_str(&clipped(&paint_clip, simple));
} else if n.has_visible_stroke() {
let geom = match &n.kind {
NodeKind::BooleanOp { .. } | NodeKind::Text(_) => outline.as_ref().map(|(p, _)| p.clone()),
_ => figcraft_doc::geometry::stroke_geometry(n),
self.stroke_paths(n, xf, outline.as_ref(), closed, &paint_clip);
}
self.body.push_str(&grains);
}
/// Paints the children of `n`, each mask layer wrapping the layers it covers.
fn children(
&mut self,
n: &Node,
xf: Affine,
depth: usize,
outline: Option<&(BezPath, figcraft_render::FillRule)>,
mut kid_clip: Option<String>,
inherited: Option<String>,
) {
if n.clip
&& n.kind.is_frame_like()
&& let Some((p, _)) = &outline
{
// Its own clip, within the clips around it.
let cid = self.id("clip");
let mut cp = p.clone();
cp.apply_affine(xf);
let _ = write!(self.defs, "<clipPath id=\"{cid}\"{}><path d=\"{}\"/></clipPath>", clip_attr(&kid_clip), path_d(&cp));
kid_clip = Some(cid);
}
self.clip = kid_clip;
let kids = n.children.clone();
for run in self.doc.paint_runs(n) {
let Some(&c) = kids.get(run.start) else { continue };
if self.doc.node(c).is_some_and(|cn| cn.is_mask && cn.visible) {
let kind = self.doc.node(c).map(|mn| mn.mask_type).unwrap_or_default();
self.open_mask(c, kind, xf, depth);
for k in kids.get(run.start + 1..run.end).unwrap_or(&[]) {
self.node(*k, xf, depth + 1);
}
self.body.push_str("</g>");
} else {
self.node(c, xf, depth + 1);
}
}
self.clip = inherited;
}
/// The inner shadows of `n` over its `outline`.
fn inner_shadows(&mut self, n: &Node, outline: &Outline, xf: Affine) {
for e in n.effects.iter().filter(|e| e.visible) {
if let EffectKind::InnerShadow(s) = &e.kind {
self.inner_shadow(outline, s, xf);
}
}
}
/// Opens the `<g>` that the mask layer `c` clips or masks the layers after it with.
fn open_mask(&mut self, c: NodeId, kind: figcraft_doc::MaskType, xf: Affine, depth: usize) {
if kind == figcraft_doc::MaskType::Vector {
// A vector mask is a clip path: its outline.
let cid = self.id("clip");
let outline = self.doc.node(c).and_then(|mn| Some((xf * mn.transform, figcraft_render::subtree_outline(self.doc, mn, depth + 1)?)));
let (shape, cr) = match &outline {
Some((mxf, (mp, rule))) => (
format!(" transform=\"{}\" d=\"{}\"", matrix(*mxf), path_d(mp)),
if *rule == figcraft_render::FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" },
),
None => (" d=\"\"".to_string(), ""),
};
if let Some(g) = geom {
let area = figcraft_render::draw::stroke_area(n, &g, closed);
let even = n.stroke.sides.is_some() || (n.stroke.dashes.is_empty() && closed && n.stroke.align != figcraft_doc::StrokeAlign::Center);
let fr = if even { " fill-rule=\"evenodd\"" } else { "" };
for p in &n.strokes {
if let Some(a) = self.paint_attr(p, w, h) {
let el = format!("<path transform=\"{}\" d=\"{}\" {a}{fr}/>", matrix(xf), path_d(&area));
self.body.push_str(&clipped(&paint_clip, el));
}
let _ = write!(self.defs, "<clipPath id=\"{cid}\"><path{shape}{cr}/></clipPath>");
let _ = write!(self.body, "<g clip-path=\"url(#{cid})\">");
} else {
// The layer as drawn: its alpha or luminance masks.
let mid = self.id("mask");
let saved = std::mem::take(&mut self.body);
let outer = self.mask.replace(c);
let clip = self.clip.take();
self.node(c, xf, depth + 1);
self.clip = clip;
self.mask = outer;
let mask_body = std::mem::replace(&mut self.body, saved);
let alpha = if kind == figcraft_doc::MaskType::Alpha { " style=\"mask-type:alpha\"" } else { "" };
let _ = write!(self.defs, "<mask id=\"{mid}\" maskUnits=\"userSpaceOnUse\"{alpha}>{mask_body}</mask>");
let _ = write!(self.body, "<g mask=\"url(#{mid})\">");
}
}
/// The visible strokes of `n` as filled outline paths.
fn stroke_paths(&mut self, n: &Node, xf: Affine, outline: Option<&(BezPath, figcraft_render::FillRule)>, closed: bool, paint_clip: &str) {
let geom = match &n.kind {
NodeKind::BooleanOp { .. } | NodeKind::Text(_) => outline.map(|(p, _)| p.clone()),
_ => figcraft_doc::geometry::stroke_geometry(n),
};
if let Some(g) = geom {
let area = figcraft_render::draw::stroke_area(n, &g, closed);
let even = n.stroke.sides.is_some() || (n.stroke.dashes.is_empty() && closed && n.stroke.align != figcraft_doc::StrokeAlign::Center);
let fr = if even { " fill-rule=\"evenodd\"" } else { "" };
for p in &n.strokes {
if let Some(a) = self.paint_attr(p, n.width, n.height) {
let el = format!("<path transform=\"{}\" d=\"{}\" {a}{fr}/>", matrix(xf), path_d(&area));
self.body.push_str(&clipped(paint_clip, el));
}
}
}
self.body.push_str(&grains);
self.body.push_str("</g>");
}
/// `name` as an `id` not used yet in this file (spaces as underscores; repeats get `_2`…).