Layer > New > Frame from Layers
Groups the selected layers and clips them to a frame rect (their bounds) via a rectangular layer mask — the visual result of Photoshop's Frame for this command, built from existing group + mask primitives (no doc-model change). Headless/scriptable; 2 tests. Parity 605 -> 606; FLOOR 601. (A distinct Frame layer type + the interactive Frame tool K are a later arc.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c6ad1755ef
commit
571230fdf8
|
|
@ -836,6 +836,7 @@ fn build() -> Vec<CommandSpec> {
|
||||||
v.extend(crate::automate_cmds::specs());
|
v.extend(crate::automate_cmds::specs());
|
||||||
v.extend(crate::print_cmds::specs());
|
v.extend(crate::print_cmds::specs());
|
||||||
v.extend(crate::pick_cmds::specs());
|
v.extend(crate::pick_cmds::specs());
|
||||||
|
v.extend(crate::frame_cmds::specs());
|
||||||
v.extend(crate::variables_cmds::specs());
|
v.extend(crate::variables_cmds::specs());
|
||||||
v
|
v
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
//! Layer › New › Frame from Layers. A frame clips its contents to a rectangle, so a template can
|
||||||
|
//! hold an image (or placeholder) that is cropped to the frame. We build it from existing
|
||||||
|
//! primitives: group the selected layers, then clip the group with a rectangular layer mask the
|
||||||
|
//! size of their bounds — the same visual result Photoshop's Frame tool produces for this command.
|
||||||
|
//! Headless and scriptable like every other command.
|
||||||
|
|
||||||
|
use photocraft_doc::{LayerId, LayerMask, PixelFormat};
|
||||||
|
use photocraft_geom::Rect;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::commands::CommandSpec;
|
||||||
|
use crate::{EngineError, Result, Session};
|
||||||
|
|
||||||
|
fn has_layer(s: &Session) -> std::result::Result<(), String> {
|
||||||
|
match s.active() {
|
||||||
|
Some(d) if !d.selected_layers.is_empty() || d.active_layer.is_some() => Ok(()),
|
||||||
|
Some(_) => Err("select a layer".into()),
|
||||||
|
None => Err("no document".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A reveal mask that is on inside `rect` and off (hidden) everywhere else.
|
||||||
|
fn clip_mask(rect: Rect) -> LayerMask {
|
||||||
|
let mut mask = LayerMask::hide_all();
|
||||||
|
let (w, h) = (rect.width().max(0) as usize, rect.height().max(0) as usize);
|
||||||
|
if w > 0 && h > 0 {
|
||||||
|
mask.surface.write_region(rect, &vec![1.0f32; w * h]);
|
||||||
|
mask.surface.prune();
|
||||||
|
}
|
||||||
|
// Default to GRAY8 like the other mask constructors.
|
||||||
|
debug_assert_eq!(mask.surface.format(), PixelFormat::GRAY8);
|
||||||
|
mask
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frame_from_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
// Group the selected layers (one step), then turn the group into a frame.
|
||||||
|
let mut gp = serde_json::Map::new();
|
||||||
|
if let Some(name) = p.get("name") {
|
||||||
|
gp.insert("name".into(), name.clone());
|
||||||
|
}
|
||||||
|
let grouped = s.execute("layer.new.groupFromLayers", Value::Object(gp))?;
|
||||||
|
let gid = LayerId(
|
||||||
|
grouped
|
||||||
|
.get("layer")
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.ok_or_else(|| EngineError::Other("grouping did not return a layer".into()))?,
|
||||||
|
);
|
||||||
|
let rect = crate::layer_multi_cmds::layer_bounds(
|
||||||
|
s.active().ok_or(EngineError::NoDocument)?.doc.layer(gid).ok_or(EngineError::NoLayer(gid))?,
|
||||||
|
)
|
||||||
|
.filter(|r| !r.is_empty())
|
||||||
|
.unwrap_or_else(|| s.active().unwrap().doc.bounds());
|
||||||
|
|
||||||
|
s.edit("Frame from Layers", |doc, _| {
|
||||||
|
let g = doc.layer_mut(gid).ok_or(EngineError::NoLayer(gid))?;
|
||||||
|
g.mask = Some(clip_mask(rect));
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
Ok(json!({"layer": gid.0, "frame": [rect.x0, rect.y0, rect.width(), rect.height()]}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn specs() -> Vec<CommandSpec> {
|
||||||
|
vec![CommandSpec {
|
||||||
|
id: "layer.new.frameFromLayers",
|
||||||
|
label: "Frame from Layers",
|
||||||
|
menu: &["Layer", "New"],
|
||||||
|
shortcut: None,
|
||||||
|
params: r#"{"name":str?} → {layer, frame:[x,y,w,h]}: groups the selected layers and clips them to a frame rect"#,
|
||||||
|
enabled: has_layer,
|
||||||
|
journal: true,
|
||||||
|
run: |s, p| frame_from_layers(s, &p),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use photocraft_doc::{Layer, LayerContent};
|
||||||
|
use photocraft_geom::Rect;
|
||||||
|
|
||||||
|
fn session() -> (Session, LayerId, LayerId) {
|
||||||
|
let mut s = Session::new();
|
||||||
|
s.execute("file.new", json!({"width": 80, "height": 60})).unwrap();
|
||||||
|
let (a, b) = s
|
||||||
|
.edit("setup", |doc, _| {
|
||||||
|
let fmt = doc.pixel_format();
|
||||||
|
let mut a = Layer::raster("A", fmt);
|
||||||
|
a.surface_mut().unwrap().fill_rect(Rect::new(4, 4, 24, 24), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0]));
|
||||||
|
let mut b = Layer::raster("B", fmt);
|
||||||
|
b.surface_mut().unwrap().fill_rect(Rect::new(30, 20, 50, 40), &photocraft_raster::from_rgba(&fmt, [0.0, 0.0, 1.0, 1.0]));
|
||||||
|
let (ia, ib) = (a.id, b.id);
|
||||||
|
doc.layers.push(a);
|
||||||
|
doc.layers.push(b);
|
||||||
|
Ok((ia, ib))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
(s, a, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frames_selected_layers_into_a_masked_group() {
|
||||||
|
let (mut s, a, b) = session();
|
||||||
|
s.execute("layer.select", json!({"layer": a.0, "mode": "replace"})).unwrap();
|
||||||
|
s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap();
|
||||||
|
let r = s.execute("layer.new.frameFromLayers", json!({"name": "Frame 1"})).unwrap();
|
||||||
|
let gid = LayerId(r["layer"].as_u64().unwrap());
|
||||||
|
// frame rect = union of the two layers' bounds (4,4)..(50,40).
|
||||||
|
let frame = r["frame"].as_array().unwrap();
|
||||||
|
assert_eq!(frame[2].as_i64().unwrap(), 50 - 4); // width
|
||||||
|
assert_eq!(frame[3].as_i64().unwrap(), 40 - 4); // height
|
||||||
|
let st = s.active().unwrap();
|
||||||
|
let g = st.doc.layer(gid).unwrap();
|
||||||
|
assert!(matches!(g.content, LayerContent::Group(_)), "it's a group");
|
||||||
|
assert!(g.mask.is_some(), "the group has a clip mask");
|
||||||
|
// Undoable (last step is the frame).
|
||||||
|
assert_eq!(st.history.entries().last().map(|e| e.as_str()), Some("Frame from Layers"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn needs_a_selected_layer() {
|
||||||
|
let mut s = Session::new();
|
||||||
|
assert!(s.execute("layer.new.frameFromLayers", json!({})).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -55,6 +55,7 @@ pub mod vp_cmds;
|
||||||
pub mod artboard_cmds;
|
pub mod artboard_cmds;
|
||||||
pub mod comps_cmds;
|
pub mod comps_cmds;
|
||||||
pub mod automate_cmds;
|
pub mod automate_cmds;
|
||||||
|
mod frame_cmds;
|
||||||
mod variables_cmds;
|
mod variables_cmds;
|
||||||
pub mod pick_cmds;
|
pub mod pick_cmds;
|
||||||
pub mod print_cmds;
|
pub mod print_cmds;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ use crate::menu_catalog::CATALOG;
|
||||||
use crate::menus::is_live;
|
use crate::menus::is_live;
|
||||||
|
|
||||||
/// Minimum number of live catalog items. Raise it when parity grows; never lower it.
|
/// Minimum number of live catalog items. Raise it when parity grows; never lower it.
|
||||||
pub const FLOOR: usize = 600;
|
pub const FLOOR: usize = 601;
|
||||||
|
|
||||||
/// One top-level menu's coverage.
|
/// One top-level menu's coverage.
|
||||||
#[derive(Clone, Debug, serde::Serialize)]
|
#[derive(Clone, Debug, serde::Serialize)]
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,14 @@
|
||||||
|
|
||||||
Generated by `cargo xtask parity` from `crates/ui-egui/src/menu_catalog.rs` (Photoshop's menu tree) and the live command registry. Do not edit by hand.
|
Generated by `cargo xtask parity` from `crates/ui-egui/src/menu_catalog.rs` (Photoshop's menu tree) and the live command registry. Do not edit by hand.
|
||||||
|
|
||||||
**605 of 625 menu items live (96.8%).**
|
**606 of 625 menu items live (97.0%).**
|
||||||
|
|
||||||
| Menu | Live | Total | % |
|
| Menu | Live | Total | % |
|
||||||
|---|---:|---:|---:|
|
|---|---:|---:|---:|
|
||||||
| File | 47 | 50 | 94% |
|
| File | 47 | 50 | 94% |
|
||||||
| Edit | 74 | 75 | 99% |
|
| Edit | 74 | 75 | 99% |
|
||||||
| Image | 60 | 61 | 98% |
|
| Image | 60 | 61 | 98% |
|
||||||
| Layer | 146 | 160 | 91% |
|
| Layer | 147 | 160 | 92% |
|
||||||
| Type | 44 | 44 | 100% |
|
| Type | 44 | 44 | 100% |
|
||||||
| Select | 25 | 25 | 100% |
|
| Select | 25 | 25 | 100% |
|
||||||
| Filter | 75 | 75 | 100% |
|
| Filter | 75 | 75 | 100% |
|
||||||
|
|
@ -32,9 +32,8 @@ Generated by `cargo xtask parity` from `crates/ui-egui/src/menu_catalog.rs` (Pho
|
||||||
|
|
||||||
- Trap… (`image.trap`)
|
- Trap… (`image.trap`)
|
||||||
|
|
||||||
### Layer (14)
|
### Layer (13)
|
||||||
|
|
||||||
- New › Frame from Layers (`layer.new.frameFromLayers`)
|
|
||||||
- Video Layers › New Blank Video Layer (`layer.videoLayers.newBlankVideoLayer`)
|
- Video Layers › New Blank Video Layer (`layer.videoLayers.newBlankVideoLayer`)
|
||||||
- Video Layers › New Video Layer from File… (`layer.videoLayers.newVideoLayerFromFile`)
|
- Video Layers › New Video Layer from File… (`layer.videoLayers.newVideoLayerFromFile`)
|
||||||
- Video Layers › Insert Blank Frame (`layer.videoLayers.insertBlankFrame`)
|
- Video Layers › Insert Blank Frame (`layer.videoLayers.insertBlankFrame`)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue