Files
photocraft/crates/engine/src/inspect.rs
T
Shayan HorbankandClaude Opus 5.5 a342ab8820 Add centered and transformable painting symmetry axes (#1821)
Photoshop's Vertical, Horizontal, Dual Axis and Diagonal paint symmetry presets beside the
path-based symmetry: axes start at the document centre, Transform Symmetry moves and rotates
them (Shift snaps to 15°, Enter applies, Esc cancels), and all copies merge into one coverage
pass so overlaps don't build opacity. New paint.setSymmetry command and preset icons. Fixes #1648.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:44:24 -04:00

157 lines
7.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! JSON views of engine state for automation, tests and debugging.
use photocraft_doc::{Layer, LayerContent, SmartSource};
use serde_json::{Value, json};
use crate::{DocState, Session};
pub fn session(s: &Session) -> Value {
json!({
"active": s.active_index(),
"documents": s.documents().iter().enumerate().map(|(i, d)| json!({
"index": i,
"name": d.doc.name,
"path": d.path,
"width": d.doc.size.width,
"height": d.doc.size.height,
"dirty": d.is_dirty(),
"revision": d.revision,
})).collect::<Vec<_>>(),
"foreground": s.tools.foreground,
"background": s.tools.background,
})
}
pub fn document(d: &DocState) -> Value {
let doc = &d.doc;
let selected = d.selected_layers();
json!({
"name": doc.name,
"width": doc.size.width,
"height": doc.size.height,
"mode": format!("{:?}", doc.mode),
"depth": doc.depth.bits(),
"resolution": doc.resolution_dpi,
"activeLayer": d.active_layer.map(|l| l.0),
"selectedLayers": selected.iter().map(|l| l.0).collect::<Vec<_>>(),
"isolatedLayers": d.isolated_layers.iter().map(|l| l.0).collect::<Vec<_>>(),
"hasSelection": doc.selection.is_some(),
"selectionBounds": doc.selection.as_ref().map(|s| { let r = s.content_bounds(); [r.x0, r.y0, r.width() as i32, r.height() as i32] }),
"layers": doc.layers.iter().rev().map(|l| layer_sel(l, &selected)).collect::<Vec<_>>(),
"history": d.history.entries(),
"canUndo": d.history.can_undo(),
"canRedo": d.history.can_redo(),
"revision": d.revision,
"channels": crate::channel_cmds::channels_json(d),
"quickMask": doc.quick_mask.is_some(),
"symmetry": d.symmetry.as_ref().map(|s| s.inspect()),
"layerMaskView": crate::mask_view_cmds::view_json(d),
"layerComps": doc.layer_comps.iter().map(|c| json!({"id": c.id, "name": c.name})).collect::<Vec<_>>(),
"lastAppliedComp": doc.last_applied_comp,
"measurement": {"scale": doc.measurement.scale.describe(), "ruler": doc.measurement.ruler.map(|r| json!({"start": r.start, "end": r.end, "protractor": r.protractor})), "count": doc.measurement.count_total(), "countGroups": doc.measurement.count_groups.len()},
"notes": doc.notes.iter().map(|n| json!({"author": n.author, "text": n.text, "position": n.position})).collect::<Vec<_>>(),
"timeline": doc.timeline.as_ref().map(|t| json!({"fps": t.fps, "duration": t.duration, "current": t.current})),
"variables": (!doc.variables.is_empty()).then(|| json!({
"defs": doc.variables.defs.iter().map(|vd| json!({"name": vd.name, "layer": vd.layer.0})).collect::<Vec<_>>(),
"dataSets": doc.variables.data_sets.iter().map(|s| s.name.clone()).collect::<Vec<_>>(),
"active": doc.variables.active,
})),
})
}
/// Layer tree top-to-bottom (display order).
pub fn layer(l: &Layer) -> Value {
layer_sel(l, &[])
}
/// [`layer`] with a `selected` flag on every node (Layers panel multi-selection).
fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId]) -> Value {
let bounds = l.surface().map(|s| {
let r = s.content_bounds();
[r.x0, r.y0, r.width() as i32, r.height() as i32]
});
let mut v = json!({
"id": l.id.0,
"name": l.name,
"kind": l.content.kind_name(),
"visible": l.visible,
"labelColor": l.label.id(),
"opacity": l.opacity,
"fill": l.fill_opacity,
"blend": l.blend.label(),
"clipped": l.clipped,
"hasMask": l.mask.is_some(),
"bounds": bounds,
"selected": selected.contains(&l.id),
"linkGroup": l.link_group,
});
match &l.content {
LayerContent::Group(g) => {
v["children"] = Value::Array(g.children.iter().rev().map(|c| layer_sel(c, selected)).collect());
v["expanded"] = json!(g.expanded);
if let Some(a) = &g.artboard {
v["artboard"] = json!({"rect": [a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height()], "background": a.background.name(), "preset": a.preset});
}
}
LayerContent::Adjustment(a) => {
v["adjustment"] = serde_json::to_value(a).unwrap_or(Value::Null);
}
LayerContent::Smart(sm) => {
v["smartFilters"] = Value::Array(
sm.smart_filters
.iter()
.map(|f| json!({"command": f.command, "params": f.params, "visible": f.visible, "opacity": f.opacity, "blend": f.blend.label()}))
.collect(),
);
v["smartFiltersEnabled"] = json!(sm.filters_enabled);
// Where the contents come from. A PSD placed layer (embedded or linked in Photoshop) is
// `linked` to the `Idnt` uuid of its file, which its duplicates share; New Smart Object
// via Copy embeds its own copy of the file instead.
v["smartSource"] = match &sm.source {
SmartSource::Embedded { file_name, .. } => json!({"kind": "embedded", "fileName": file_name}),
SmartSource::Linked { path } => json!({"kind": "linked", "path": path}),
};
}
LayerContent::Text(t) => {
v["text"] = json!({"text": t.text, "font": t.font_family, "sizePt": t.size_pt});
}
_ => {}
}
// Advanced Blending › Channels (only when some channel is left out).
if l.excluded_channels != 0 {
v["channels"] = json!((0..4).map(|i| l.excluded_channels & (1 << i) == 0).collect::<Vec<_>>());
}
// Blending Options › Advanced Blending switches (only when one differs from Photoshop's default).
if !l.advanced.is_default() {
v["advancedBlending"] = serde_json::to_value(l.advanced).unwrap_or(Value::Null);
}
// Blending Options › Blend If (only when set): range index (0 = Gray, then the mode's
// channels) with This Layer / Underlying Layer as [blackLo, blackHi, whiteLo, whiteHi].
if !l.blend_if.is_default() {
v["blendIf"] = l
.blend_if
.ranges
.iter()
.enumerate()
.filter(|(_, r)| !r.iter().all(photocraft_doc::BlendRange::is_full))
.map(|(i, [this, under])| json!({"channel": i, "thisLayer": this.to_bytes(), "underlying": under.to_bytes()}))
.collect();
}
// Layer styles, so agents can verify what they applied (full settings via the style commands).
if !l.effects.items.is_empty() {
v["effects"] = json!({
"enabled": l.effects.enabled,
"items": l.effects.items.iter().map(|e| json!({"kind": e.label(), "enabled": e.enabled()})).collect::<Vec<_>>(),
});
}
// Video layer (Layer › Video Layers): the frame stack behind the displayed content.
if let Some(vid) = &l.video {
let source = match &vid.source {
photocraft_doc::VideoSource::Blank => Value::String("blank".into()),
photocraft_doc::VideoSource::File { path } => json!({ "file": path }),
};
v["video"] = json!({"frames": vid.frames.len(), "fps": vid.fps, "showAltered": vid.show_altered, "source": source});
}
v
}