306 lines
13 KiB
Rust
306 lines
13 KiB
Rust
//! Document inspection as JSON, so agents can verify their work without screenshots.
|
|
|
|
use figcraft_doc::*;
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::Session;
|
|
|
|
/// A node and (to `depth`) its children, with the properties a designer would check.
|
|
pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
|
|
let Some(n) = doc.node(id) else { return Value::Null };
|
|
let c = n.transform.as_coeffs();
|
|
let mut v = json!({
|
|
"id": n.id,
|
|
"name": n.name,
|
|
"type": n.kind.type_name(),
|
|
});
|
|
let Some(o) = v.as_object_mut() else { return v };
|
|
if !n.is_page() {
|
|
o.insert("x".into(), json!(round(c[4])));
|
|
o.insert("y".into(), json!(round(c[5])));
|
|
o.insert("width".into(), json!(round(n.width)));
|
|
o.insert("height".into(), json!(round(n.height)));
|
|
let rot = figcraft_geom::rotation_degrees(n.transform);
|
|
if rot.abs() > 1e-6 {
|
|
o.insert("rotation".into(), json!(round(rot)));
|
|
}
|
|
if let Some(b) = doc.world_bounds(id) {
|
|
o.insert("absoluteBounds".into(), json!({"x": round(b.x0), "y": round(b.y0), "width": round(b.width()), "height": round(b.height())}));
|
|
}
|
|
if !n.visible {
|
|
o.insert("visible".into(), json!(false));
|
|
}
|
|
if n.locked {
|
|
o.insert("locked".into(), json!(true));
|
|
}
|
|
if n.opacity < 1.0 {
|
|
o.insert("opacity".into(), json!(n.opacity));
|
|
}
|
|
if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
|
|
o.insert("blend".into(), json!(n.blend));
|
|
}
|
|
if !n.fills.is_empty() {
|
|
o.insert("fills".into(), paints(&n.fills));
|
|
}
|
|
if !n.strokes.is_empty() {
|
|
o.insert("strokes".into(), paints(&n.strokes));
|
|
o.insert("strokeWeight".into(), json!(n.stroke.weight));
|
|
o.insert("strokeAlign".into(), json!(n.stroke.align));
|
|
if n.stroke.sides.is_some() {
|
|
o.insert("strokeSides".into(), json!(n.stroke.sides));
|
|
}
|
|
if !n.stroke.dashes.is_empty() {
|
|
o.insert("dashes".into(), json!(n.stroke.dashes));
|
|
}
|
|
if let Some(p) = n.stroke.profile() {
|
|
let points = crate::cmd::props::width_points_json(Some(p));
|
|
o.insert("widthProfile".into(), json!({"preset": p.preset().map(|x| x.id()), "points": points}));
|
|
}
|
|
if let Some(b) = &n.stroke.brush {
|
|
o.insert("brush".into(), camel(serde_json::to_value(b).unwrap_or(Value::Null)));
|
|
}
|
|
}
|
|
if !n.effects.is_empty() {
|
|
o.insert("effects".into(), camel(serde_json::to_value(&n.effects).unwrap_or(Value::Null)));
|
|
}
|
|
if n.corners.radii.iter().any(|r| *r > 0.0) {
|
|
o.insert(
|
|
"cornerRadius".into(),
|
|
match n.corners.uniform() {
|
|
Some(r) => json!(r),
|
|
None => json!(n.corners.radii),
|
|
},
|
|
);
|
|
if n.corners.smoothing > 0.0 {
|
|
o.insert("cornerSmoothing".into(), json!(n.corners.smoothing));
|
|
}
|
|
}
|
|
if let Some(l) = &n.layout {
|
|
o.insert("layout".into(), camel(serde_json::to_value(l).unwrap_or(Value::Null)));
|
|
}
|
|
let parent_al = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some());
|
|
if n.layout.is_some() || parent_al || matches!(n.kind, NodeKind::Text(_)) {
|
|
o.insert("sizing".into(), json!({"horizontal": n.child_layout.sizing_h, "vertical": n.child_layout.sizing_v}));
|
|
}
|
|
let cl = &n.child_layout;
|
|
if cl.absolute {
|
|
o.insert("absolute".into(), json!(true));
|
|
}
|
|
for (k, v) in [("minWidth", cl.min_w), ("maxWidth", cl.max_w), ("minHeight", cl.min_h), ("maxHeight", cl.max_h)] {
|
|
if let Some(v) = v {
|
|
o.insert(k.into(), json!(v));
|
|
}
|
|
}
|
|
if let Some(cell) = parent_al
|
|
.then(|| n.parent.and_then(|p| figcraft_layout::grid_geometry(doc, p)))
|
|
.flatten()
|
|
.and_then(|g| g.cells.into_iter().find(|c| c.id == n.id))
|
|
{
|
|
o.insert(
|
|
"gridCell".into(),
|
|
json!({"column": cell.column, "row": cell.row, "columnSpan": cell.column_span, "rowSpan": cell.row_span,
|
|
"anchored": cl.grid_column.is_some() || cl.grid_row.is_some()}),
|
|
);
|
|
}
|
|
if (!parent_al || cl.absolute) && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like()) {
|
|
o.insert("constraints".into(), serde_json::to_value(n.constraints).unwrap_or(Value::Null));
|
|
}
|
|
if n.kind.is_frame_like() {
|
|
o.insert("clip".into(), json!(n.clip));
|
|
}
|
|
if !n.dev_status.is_none() {
|
|
o.insert("devStatus".into(), json!(n.dev_status.id()));
|
|
}
|
|
if n.dev_changed {
|
|
o.insert("devChanged".into(), json!(true));
|
|
}
|
|
if !n.dev_note.is_empty() {
|
|
o.insert("devNote".into(), json!(n.dev_note));
|
|
}
|
|
if let NodeKind::Section { contents_hidden } = n.kind {
|
|
o.insert("sectionContentsHidden".into(), json!(contents_hidden));
|
|
}
|
|
if n.is_mask {
|
|
o.insert("isMask".into(), json!(true));
|
|
o.insert("maskType".into(), json!(n.mask_type.id()));
|
|
}
|
|
if !n.grids.is_empty() {
|
|
o.insert("grids".into(), camel(serde_json::to_value(&n.grids).unwrap_or(Value::Null)));
|
|
}
|
|
if !n.exports.is_empty() {
|
|
o.insert("exports".into(), serde_json::to_value(&n.exports).unwrap_or(Value::Null));
|
|
}
|
|
if !n.annotations.is_empty() {
|
|
o.insert("annotations".into(), serde_json::to_value(&n.annotations).unwrap_or(Value::Null));
|
|
}
|
|
if !n.interactions.is_empty() {
|
|
o.insert("interactions".into(), serde_json::to_value(&n.interactions).unwrap_or(Value::Null));
|
|
}
|
|
if !n.styles.is_empty() {
|
|
o.insert("styles".into(), serde_json::to_value(&n.styles).unwrap_or(Value::Null));
|
|
}
|
|
if !n.bound.is_empty() {
|
|
o.insert("boundVariables".into(), json!(n.bound));
|
|
}
|
|
if !n.overrides.is_empty() {
|
|
o.insert("overrides".into(), json!(n.overrides));
|
|
}
|
|
if let Some(slot) = crate::components::slot_name(doc, n.id) {
|
|
o.insert("slot".into(), json!(slot));
|
|
}
|
|
// An operand combining by an operation of its own.
|
|
if let Some(op) = n.bool_op.filter(|_| crate::booleans::group_of(doc, n.id).is_some()) {
|
|
o.insert("operandOperation".into(), json!(op));
|
|
}
|
|
}
|
|
match &n.kind {
|
|
NodeKind::Text(t) => {
|
|
o.insert("characters".into(), json!(t.characters));
|
|
o.insert("fontFamily".into(), json!(t.style.font_family));
|
|
o.insert("fontStyle".into(), json!(t.style.font_style));
|
|
o.insert("fontSize".into(), json!(t.style.font_size));
|
|
if !t.style.variations.is_empty() {
|
|
o.insert("fontVariations".into(), json!(t.style.variations));
|
|
}
|
|
o.insert("lineHeight".into(), json!(t.style.line_height.label()));
|
|
o.insert("letterSpacing".into(), json!(t.style.letter_spacing.label()));
|
|
o.insert("textAlign".into(), json!(t.align_h));
|
|
o.insert("textAutoResize".into(), json!(t.auto_resize));
|
|
if let Some(tp) = &t.path {
|
|
o.insert("textPath".into(), json!(tp));
|
|
}
|
|
if t.style.decoration != TextDecoration::None {
|
|
o.insert("textDecoration".into(), json!(t.style.decoration));
|
|
}
|
|
if !t.runs.is_empty() {
|
|
o.insert("styledRuns".into(), serde_json::to_value(&t.runs).unwrap_or(Value::Null));
|
|
}
|
|
// Chars linked to text styles: [start, end, style id] (the layer as a whole: `styles.text`).
|
|
let linked: Vec<Value> = t.spans().into_iter().filter_map(|(a, b, s)| s.style_id.as_ref().map(|id| json!([a, b, id]))).collect();
|
|
if !linked.is_empty() {
|
|
o.insert("textStyleRanges".into(), Value::Array(linked));
|
|
}
|
|
}
|
|
NodeKind::Ellipse { start, sweep, inner } if (*sweep).abs() < std::f64::consts::TAU - 1e-9 || *inner > 0.0 => {
|
|
o.insert("arc".into(), json!({"start": start.to_degrees(), "sweep": sweep.to_degrees(), "inner": inner}));
|
|
}
|
|
NodeKind::Polygon { count } => {
|
|
o.insert("pointCount".into(), json!(count));
|
|
}
|
|
NodeKind::Star { count, inner_ratio } => {
|
|
o.insert("pointCount".into(), json!(count));
|
|
o.insert("innerRadius".into(), json!(inner_ratio));
|
|
}
|
|
NodeKind::BooleanOp { op } => {
|
|
o.insert("booleanOperation".into(), json!(op));
|
|
}
|
|
NodeKind::Instance { component, props } => {
|
|
o.insert("mainComponent".into(), json!(component));
|
|
o.insert("mainComponentName".into(), json!(doc.node(*component).map(|c| c.name.clone())));
|
|
if !props.is_empty() {
|
|
o.insert("componentProperties".into(), json!(props));
|
|
}
|
|
}
|
|
NodeKind::Component { description, props } => {
|
|
if !description.is_empty() {
|
|
o.insert("description".into(), json!(description));
|
|
}
|
|
if !props.is_empty() {
|
|
o.insert("componentPropertyDefinitions".into(), serde_json::to_value(props).unwrap_or(Value::Null));
|
|
}
|
|
}
|
|
NodeKind::Vector { network } => {
|
|
o.insert("vertices".into(), json!(network.vertices.len()));
|
|
o.insert("segments".into(), json!(network.segments.len()));
|
|
}
|
|
NodeKind::Page { background, flows, .. } => {
|
|
o.insert("background".into(), json!(background.to_css_hex()));
|
|
if !flows.is_empty() {
|
|
o.insert("flows".into(), serde_json::to_value(flows).unwrap_or(Value::Null));
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
if !n.children.is_empty() {
|
|
if depth > 0 {
|
|
o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json(doc, *c, depth - 1)).collect()));
|
|
} else {
|
|
o.insert("childCount".into(), json!(n.children.len()));
|
|
}
|
|
}
|
|
v
|
|
}
|
|
|
|
fn round(v: f64) -> f64 {
|
|
figcraft_geom::round_to(v, 2)
|
|
}
|
|
|
|
fn paints(p: &[figcraft_color::Paint]) -> Value {
|
|
Value::Array(
|
|
p.iter()
|
|
.map(|x| match &x.kind {
|
|
figcraft_color::PaintKind::Solid { color } => {
|
|
let mut v = json!({"type": "solid", "color": color.to_css_hex()});
|
|
if x.opacity < 1.0 {
|
|
v["opacity"] = json!(x.opacity);
|
|
}
|
|
if !x.visible {
|
|
v["visible"] = json!(false);
|
|
}
|
|
v
|
|
}
|
|
_ => serde_json::to_value(x).unwrap_or(Value::Null),
|
|
})
|
|
.collect(),
|
|
)
|
|
}
|
|
|
|
/// The whole document (current page in depth, other pages summarised).
|
|
pub fn document(s: &Session, depth: usize, all_pages: bool) -> Value {
|
|
let Ok(ds) = s.ds() else { return json!({"documents": 0}) };
|
|
let doc = &ds.doc;
|
|
let pages: Vec<Value> =
|
|
doc.pages.iter().map(|p| if all_pages || *p == ds.page { node_json(doc, *p, depth) } else { node_json(doc, *p, 0) }).collect();
|
|
json!({
|
|
"name": doc.name,
|
|
"path": ds.path,
|
|
"dirty": ds.is_dirty(),
|
|
"currentPage": ds.page,
|
|
"selection": ds.selection,
|
|
"pages": pages,
|
|
"styles": doc.styles.iter().map(|st| json!({"id": st.id, "name": st.name, "type": st.kind.type_name()})).collect::<Vec<_>>(),
|
|
"variables": doc.variables.variables.len(),
|
|
"images": doc.images.len(),
|
|
"colorProfile": doc.color_profile.id(),
|
|
"textEdit": ds.text_edit,
|
|
"history": {"undo": s.undo_labels(20), "redo": s.undo_depth().1},
|
|
})
|
|
}
|
|
|
|
/// snake_case object keys → camelCase (inspect output matches the keys `node.set` accepts).
|
|
pub fn camel(v: Value) -> Value {
|
|
match v {
|
|
Value::Object(m) => Value::Object(
|
|
m.into_iter()
|
|
.map(|(k, v)| {
|
|
let mut out = String::new();
|
|
let mut up = false;
|
|
for c in k.chars() {
|
|
if c == '_' {
|
|
up = true;
|
|
} else if up {
|
|
out.extend(c.to_uppercase());
|
|
up = false;
|
|
} else {
|
|
out.push(c);
|
|
}
|
|
}
|
|
(out, camel(v))
|
|
})
|
|
.collect(),
|
|
),
|
|
Value::Array(a) => Value::Array(a.into_iter().map(camel).collect()),
|
|
other => other,
|
|
}
|
|
}
|