Files
vectorcraft/crates/engine/src/inspect.rs
T
bflatasticandClaude Opus 5.5 025d758abe M3.83: gradients within, along or across a stroke
The Gradient panel's Stroke buttons now work: with the Stroke proxy on a
linear or radial gradient they lay it within the stroke (placed on the
page, as before), along the path (start to end of each subpath) or across
it (left edge to right edge; inside/outside strokes span their visible
side). `paint.editGradient {stroke: true, strokeMode}` sets it (new art
too with nothing selected); `document.inspect` reports
strokeOptions.gradientMode. Model: StrokeLayer.gradient_mode (default
Within, not written).

Shared geometry in effects::stroke::gradient: ribs on the bisectors of
the flattened path (fans round sharp corners, widened under arrowheads,
following width profiles) cut slices each painted with a linear piece of
the gradient (Gradient::span). The canvas and raster export clip them to
the stroke's outline and arrowheads, grown a pixel and composited with
copy so there are no seams and translucent stops paint once. SVG and PDF
write the same slices clipped to the outline, with a warning (new
vectorcraft_svg::export_with_report). Outline Stroke and Expand make
gradient meshes clipped to the outline. Mesh quads now grow along their
edges so thin quads show no seams. Dashes, profiles and arrowheads keep
working in both modes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:47:54 +07:00

131 lines
5.6 KiB
Rust

//! Agent-friendly document summaries and view-models for panels.
use serde_json::{Value, json};
use vectorcraft_doc::{ArrowAlign, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, WidthProfile};
use crate::Session;
fn rect_json(r: Option<vectorcraft_geom::Rect>) -> Value {
match r {
Some(r) => json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() }),
None => Value::Null,
}
}
/// Compact tree summary of a node (for `document.inspect`).
pub fn node_summary(n: &Node) -> Value {
let mut v = json!({
"id": n.id.0,
"name": n.display_name(),
"kind": n.kind_label(),
"visible": n.visible,
"locked": n.locked,
"bounds": rect_json(n.geometric_bounds()),
});
if n.opacity < 1.0 {
v["opacity"] = json!(n.opacity);
}
if !n.is_container() {
v["fill"] = json!(n.appearance.fill_paint().label());
v["stroke"] = json!(n.appearance.stroke_paint().label());
v["strokeWidth"] = json!(n.appearance.stroke_width());
}
if let Some(st) = node_stroke(n) {
v["strokeOptions"] = stroke_options(&st);
}
match &n.kind {
NodeKind::Path { path, .. } => {
v["anchors"] = json!(path.anchor_count());
v["closed"] = json!(path.is_closed());
}
NodeKind::Text(t) => v["text"] = json!(t.plain_text()),
_ => {}
}
if let Some(ch) = n.children() {
v["children"] = Value::Array(ch.iter().rev().map(|c| node_summary(c)).collect());
}
v
}
pub fn document(s: &Session) -> Value {
let Some(st) = s.active() else { return Value::Null };
let d = &st.doc;
json!({
"title": st.title(),
"path": st.path,
"dirty": st.is_dirty(),
"revision": st.revision,
"units": d.units.label(),
"colorMode": format!("{:?}", d.color_mode),
"artboards": d.artboards.iter().map(|a| json!({"name": a.name, "x": a.rect.x0, "y": a.rect.y0, "width": a.rect.width(), "height": a.rect.height()})).collect::<Vec<_>>(),
// Top of the stack first, like the Layers panel.
"layers": d.layers.iter().rev().map(|l| node_summary(l)).collect::<Vec<_>>(),
"currentLayer": st.active_layer.map(|l| l.0),
"isolation": st.isolation.map(|l| l.0),
"selection": st.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>(),
"selectionBounds": rect_json(d.bounds_of(&st.selection.objects, false)),
// The layer, group or object targeted through the Layers panel (`layer.target`).
"target": st.selection.target.map(|t| t.0),
"history": st.history.undo.iter().map(|h| h.label.clone()).collect::<Vec<_>>(),
"redo": st.history.redo.iter().rev().map(|h| h.label.clone()).collect::<Vec<_>>(),
"objects": d.node_count(),
"tool": s.tool_id(),
"paint": {"fill": s.paint.fill.label(), "stroke": s.paint.stroke.label(), "strokeWidth": s.paint.stroke_width, "fillActive": s.fill_active, "appearanceItem": s.appearance_item()},
})
}
/// Cap names as `stroke.set` takes them.
pub const CAPS: [(LineCap, &str); 3] = [(LineCap::Butt, "butt"), (LineCap::Round, "round"), (LineCap::Square, "square")];
/// Join names as `stroke.set` takes them.
pub const JOINS: [(LineJoin, &str); 3] = [(LineJoin::Miter, "miter"), (LineJoin::Round, "round"), (LineJoin::Bevel, "bevel")];
/// Stroke alignment names as `stroke.set` takes them.
pub const ALIGNS: [(StrokeAlign, &str); 3] = [(StrokeAlign::Center, "center"), (StrokeAlign::Inside, "inside"), (StrokeAlign::Outside, "outside")];
fn name_of<T: PartialEq>(table: &[(T, &'static str)], v: T) -> &'static str {
table.iter().find(|(k, _)| *k == v).map_or("", |(_, n)| n)
}
/// The stroke an object's Stroke panel options describe: type's first run's character stroke,
/// else the topmost stroke of its own (groups and layers too). None without a stroke.
pub fn node_stroke(n: &Node) -> Option<StrokeLayer> {
match &n.kind {
NodeKind::Text(t) => Some(t.runs.first().map_or_else(|| vectorcraft_doc::CharStyle::default().stroke_layer(), |r| r.style.stroke_layer())),
_ => n.appearance.stroke().cloned(),
}
}
/// A stroke's options in `stroke.set` terms: cap, join, miterLimit, align, dash (null when
/// solid), dashOffset, alignDashes, startArrow/endArrow (null for none), arrowAlign and profile,
/// plus widthPoints: the profile's `[t, left, right]` points (width factors; null when uniform)
/// and gradientMode: how a gradient lies on it (`paint.editGradient` strokeMode).
pub fn stroke_options(st: &StrokeLayer) -> Value {
json!({
"cap": name_of(&CAPS, st.cap),
"join": name_of(&JOINS, st.join),
"miterLimit": st.miter_limit,
"align": name_of(&ALIGNS, st.align),
"dash": st.dash.as_ref().map(|d| &d.pattern),
"dashOffset": st.dash.as_ref().map_or(0.0, |d| d.offset),
"alignDashes": st.dash.as_ref().is_some_and(|d| d.align_corners),
"startArrow": st.start_arrow,
"endArrow": st.end_arrow,
"arrowAlign": if st.arrow_align == ArrowAlign::Tip { "tip" } else { "extend" },
"profile": WidthProfile::id_of(st.profile.as_ref()),
"widthPoints": st.profile.as_ref().map(|p| &p.points),
"gradientMode": st.gradient_mode.name(),
})
}
/// Which Stroke panel values the selected objects don't share (their fields show blank), and
/// whether Align Stroke applies to them (no open path or type among them).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct StrokeMixed {
pub weight: bool,
pub cap: bool,
pub join: bool,
pub miter_limit: bool,
pub align: bool,
pub dash: bool,
pub can_align: bool,
}