Files
photocraft/crates/engine/src/render_cmds.rs
T
e8850ac53a Filters respect layer locks; render filters and Fill Path refuse a pixel-locked layer (#1102) (#1195)
* Filters refuse a pixel-locked layer (#1102)

run_filter never consulted layer locks. It now refuses a pixel- or
fully-locked layer with the same message painting uses. Channel, Quick
Mask and mask targets are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Filters keep alpha under lock transparency; render filters and Fill Path refuse a pixel-locked layer (#1102)

- run_filter puts the layer's alpha back in the tiles a filter changed when
  transparency is locked (the same tile walk as restrict_to_color).
- Flame / Tree / Picture Frame take the surface through paint_surface.
- path.fill refuses a pixel- or fully-locked layer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: prekabreki <prekabreki@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:38:59 -04:00

331 lines
15 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.
//! Filter › Render › Flame…, Picture Frame… and Tree…: procedural renderers
//! (`photocraft_algo::render2`). They draw onto the active pixel layer (or a
//! new layer with `newLayer: true`, or when the active layer has no pixels),
//! inside the selection, in any colour mode and depth, as one undo step.
//! Results report what was drawn (`layer`, `bounds`, `primitives`) so agents
//! can verify them without screenshots.
use photocraft_algo::render2::{self, FlameSpec, FrameSpec, Prim, TreeSpec};
use photocraft_doc::{Layer, LayerContent, LayerId};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde_json::{Map, Value, json};
use crate::commands::CommandSpec;
use crate::{EngineError, Result, Session};
/// `[r, g, b(, a)]` (0–1) or `#rrggbb` → `[r, g, b, a]`.
fn parse_colour(v: &Value) -> Option<[f32; 4]> {
match v {
Value::Array(a) if a.len() >= 3 => {
let c = |j: usize, d: f64| a.get(j).and_then(Value::as_f64).unwrap_or(d) as f32;
Some([c(0, 0.0), c(1, 0.0), c(2, 0.0), c(3, 1.0)])
}
Value::String(h) if h.len() == 7 && h.starts_with('#') => {
let ch = |j: usize| h.get(j..j + 2).and_then(|s| u8::from_str_radix(s, 16).ok()).map_or(0.0, |v| v as f32 / 255.0);
Some([ch(1), ch(3), ch(5), 1.0])
}
_ => None,
}
}
/// Params as an object with the named colour keys normalized to arrays (invalid ones dropped).
fn normalized(p: &Value, colours: &[&str]) -> Map<String, Value> {
let mut m = p.as_object().cloned().unwrap_or_default();
m.remove("layer");
m.remove("newLayer");
m.remove("path");
for k in colours {
if let Some(v) = m.remove(*k)
&& let Some(c) = parse_colour(&v)
{
m.insert((*k).to_string(), json!(c));
}
}
m
}
fn spec_from<T: serde::de::DeserializeOwned>(m: Map<String, Value>) -> Result<T> {
serde_json::from_value(Value::Object(m)).map_err(|e| EngineError::Other(format!("invalid parameters: {e}")))
}
/// Accepts a 1-based number or a name (camelCase) for an enumerated option.
fn index_param(m: &mut Map<String, Value>, k: &str, names: &[&str]) {
if let Some(Value::String(n)) = m.get(k) {
let lower = n.to_ascii_lowercase().replace([' ', '-', '_'], "");
let i = names.iter().position(|s| s.to_ascii_lowercase().replace([' ', '-', '_'], "") == lower).map_or(1, |i| i + 1);
m.insert(k.to_string(), json!(i));
}
}
/// Flattened polylines of the path Flame follows: `path` (a saved path name, `"work"`), else the
/// work path, else the first saved path. `None` when the document has no path.
fn flame_paths(s: &Session, p: &Value) -> Option<Vec<Vec<(f32, f32)>>> {
let d = s.active()?;
let doc = &d.doc;
let path = match p.get("path").and_then(Value::as_str) {
Some(n) if !n.is_empty() && !n.eq_ignore_ascii_case("work") => doc.paths.iter().find(|sp| sp.name == n).map(|sp| &sp.path),
_ => doc.work_path.as_ref().or_else(|| doc.paths.first().map(|sp| &sp.path)),
}?;
let polys: Vec<Vec<(f32, f32)>> = photocraft_vector::flatten_path(path, 0.5)
.into_iter()
.map(|pl| {
let mut v: Vec<(f32, f32)> = pl.pts.iter().map(|&(x, y)| (x as f32, y as f32)).collect();
if pl.closed
&& let Some(&f) = v.first()
{
v.push(f);
}
v
})
.filter(|v| !v.is_empty())
.collect();
(!polys.is_empty()).then_some(polys)
}
/// Draws `prims` onto the target layer as one undo step.
fn draw(s: &mut Session, label: &str, p: &Value, prims: Vec<Prim>, extra: Value) -> Result<Value> {
let want_new = p.get("newLayer").and_then(Value::as_bool).unwrap_or(false);
let target = p.get("layer").and_then(Value::as_u64).map(LayerId);
let count = prims.len();
let (layer, created, used) = s.edit(label, |doc, active| {
let id = target.or(*active);
let raster = id.and_then(|i| doc.layer(i)).is_some_and(|l| matches!(l.content, LayerContent::Raster(_)));
let (id, created) = if want_new || !raster {
let l = Layer::new(label.trim_end_matches('…'), LayerContent::Raster(Surface::new(doc.pixel_format())));
let nid = doc.insert_above(*active, l);
*active = Some(nid);
(nid, true)
} else {
(id.ok_or(EngineError::Other("no active layer".into()))?, false)
};
let selection = doc.selection.clone();
let mut clip = doc.bounds();
if let Some(sel) = &selection {
clip = clip.intersect(&sel.content_bounds());
}
let surf = crate::commands::paint_surface(doc, id, &Value::Null)?;
let used = render2::composite(surf, &prims, clip, selection.as_ref());
surf.prune();
Ok((id, created, used))
})?;
let mut r = json!({
"layer": layer.0,
"newLayer": created,
"bounds": if used.is_empty() { Value::Null } else { json!([used.x0, used.y0, used.x1, used.y1]) },
"primitives": count,
});
if let (Value::Object(m), Value::Object(e)) = (&mut r, extra) {
m.extend(e);
}
Ok(r)
}
fn canvas(s: &Session) -> Result<Rect> {
Ok(s.active().ok_or(EngineError::NoDocument)?.doc.bounds())
}
fn run_flame(s: &mut Session, p: &Value) -> Result<Value> {
let mut m = normalized(p, &["color"]);
if !m.get("useCustomColor").and_then(Value::as_bool).unwrap_or(m.contains_key("color")) {
m.remove("color");
}
m.remove("useCustomColor");
if let Some(Value::Number(q)) = m.get("quality").cloned() {
m.insert("quality".into(), json!(q.as_f64().unwrap_or(2.0).clamp(0.0, 4.0) as u32));
} else if let Some(Value::String(q)) = m.get("quality").cloned() {
let i = ["draft", "low", "medium", "high", "fine"].iter().position(|n| *n == q).unwrap_or(2);
m.insert("quality".into(), json!(i));
}
let spec: FlameSpec = spec_from(m)?;
let found = flame_paths(s, p);
let used_path = found.is_some();
let paths = found.unwrap_or_else(|| {
let c = canvas(s).unwrap_or(Rect::new(0, 0, 1, 1));
if spec.flame_type == render2::FlameType::CandleLight {
vec![vec![(c.x0 as f32 + c.width() as f32 * 0.5, c.y0 as f32 + c.height() as f32 * 0.75)]]
} else {
vec![render2::default_flame_path(c)]
}
});
let prims = render2::flame(&spec, &paths);
draw(s, "Flame…", p, prims, json!({ "usedPath": used_path }))
}
fn run_tree(s: &mut Session, p: &Value) -> Result<Value> {
let mut m = normalized(p, &["leavesColor", "branchesColor"]);
index_param(&mut m, "baseTreeType", &render2::tree_type_names());
if m.contains_key("leavesColor") && !m.contains_key("defaultLeaves") {
m.insert("defaultLeaves".into(), json!(false));
}
if m.contains_key("branchesColor") && !m.contains_key("customBranchColor") {
m.insert("customBranchColor".into(), json!(true));
}
let spec: TreeSpec = spec_from(m)?;
let prims = render2::tree(&spec, canvas(s)?);
let name = render2::tree_type_names()[(spec.base_tree_type.clamp(1, 34) - 1) as usize];
draw(s, "Tree…", p, prims, json!({ "treeType": name }))
}
fn run_frame(s: &mut Session, p: &Value) -> Result<Value> {
let mut m = normalized(p, &["vineColor", "flowerColor", "leafColor"]);
index_param(&mut m, "frame", &render2::FRAME_STYLES);
let spec: FrameSpec = spec_from(m)?;
let prims = render2::picture_frame(&spec, canvas(s)?);
let name = render2::FRAME_STYLES[(spec.frame.clamp(1, render2::FRAME_STYLES.len() as u32) - 1) as usize];
draw(s, "Picture Frame…", p, prims, json!({ "frame": name }))
}
fn has_doc(s: &Session) -> std::result::Result<(), String> {
s.active().map(|_| ()).ok_or_else(|| "no document open".into())
}
/// The Render commands of this module.
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec {
id: "filter.render.flame",
label: "Flame…",
menu: &["Filter", "Render"],
shortcut: None,
params: r##"{"flameType":"oneFlameAlongPath|multipleFlamesAlongPath|multipleFlamesPathDirections|multipleFlamesVariousLength|candleLight|multipleFlamesOneDirection","length":1..1000=150,"randomizeLength":bool,"width":1..1000=40,"angle":-180..180=0,"interval":1..1000=60,"adjustIntervalForLoops":bool=true,"useCustomColor":bool,"color":color,"turbulent":0..100=25,"jag":0..100=25,"opacity":0..100=75,"flameLines":1..100=20,"flameBottomAlignment":0..100=20,"flameStyle":"normal|violent|flat","flameShape":"parallel|toCenter|spread|oval|pointed","randomizeShapes":bool,"seed":u32=0,"quality":"draft|low|medium|high|fine","path":text,"newLayer":bool} → {layer,newLayer,bounds,primitives,usedPath}"##,
enabled: has_doc,
run: run_flame,
journal: true,
},
CommandSpec {
id: "filter.render.pictureFrame",
label: "Picture Frame…",
menu: &["Filter", "Render"],
shortcut: None,
params: r##"{"frame":"vineWithFlowers|vineWithLeaves|ivy|roses|daisies|berries|bamboo|waves|zigzag|dots|rope|scallops|doubleLine|snowflakes|stars|hearts","margin":0..30=4,"size":1..100=50,"arrangement":1..100=50,"lines":1..5=1,"thickness":1..100=30,"fade":0..100=0,"vineColor":color,"flowerColor":color,"leafColor":color,"seed":u32=0,"newLayer":bool} → {layer,newLayer,bounds,primitives,frame}"##,
enabled: has_doc,
run: run_frame,
journal: true,
},
CommandSpec {
id: "filter.render.tree",
label: "Tree…",
menu: &["Filter", "Render"],
shortcut: None,
params: r##"{"baseTreeType":1..34=1,"lightDirection":1..5=3,"leavesAmount":0..100=50,"leavesSize":0..200=100,"branchesHeight":50..300=100,"branchesThickness":50..200=100,"defaultLeaves":bool=true,"leavesColor":color,"customBranchColor":bool,"branchesColor":color,"flatShading":bool,"seed":u32=0,"x":0..1=0.5,"y":0..1=0.95,"size":0.05..2=0.8,"newLayer":bool} → {layer,newLayer,bounds,primitives,treeType}"##,
enabled: has_doc,
run: run_tree,
journal: true,
},
]
}
#[cfg(test)]
mod tests {
use super::*;
fn session(depth: &str) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 160, "height": 120, "depth": depth})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s
}
fn pixels(s: &Session) -> Vec<f32> {
let d = s.active().unwrap();
d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().read_region(Rect::new(0, 0, 160, 120))
}
fn opaque(s: &Session) -> usize {
pixels(s).as_chunks::<4>().0.iter().filter(|p| p[3] > 0.05).count()
}
#[test]
fn each_renderer_draws_undoes_and_is_seeded_at_every_depth() {
for depth in ["8", "16", "32"] {
for (id, p) in [
("filter.render.flame", json!({})),
("filter.render.tree", json!({"baseTreeType": 7})),
("filter.render.pictureFrame", json!({"frame": "roses"})),
] {
let mut s = session(depth);
let r = s.execute(id, p.clone()).unwrap_or_else(|e| panic!("{id}@{depth}: {e}"));
assert!(r["primitives"].as_u64().unwrap() > 0, "{id}");
assert!(r["bounds"].is_array(), "{id}@{depth}: {r}");
let n = opaque(&s);
assert!(n > 50, "{id}@{depth} drew {n} px");
let first = pixels(&s);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(opaque(&s), 0, "{id} undo");
s.execute(id, p.clone()).unwrap();
assert_eq!(pixels(&s), first, "{id} deterministic");
s.execute("edit.undo", json!({})).unwrap();
let mut q = p.clone();
q["seed"] = json!(5);
s.execute(id, q).unwrap();
assert_ne!(pixels(&s), first, "{id} seed changes the result");
}
}
}
#[test]
fn flame_follows_the_work_path() {
let mut s = session("8");
let r = s.execute("filter.render.flame", json!({})).unwrap();
assert_eq!(r["usedPath"], json!(false));
s.execute("edit.undo", json!({})).unwrap();
// A vertical line path on the left edge: the flame stays near it.
s.edit("path", |doc, _| {
let mut sub = photocraft_doc::Subpath::default();
for (x, y) in [(20.0, 110.0), (20.0, 20.0)] {
sub.knots.push(photocraft_doc::Knot::corner(x, y));
}
doc.work_path = Some(photocraft_doc::Path { subpaths: vec![sub], ..Default::default() });
Ok(())
})
.unwrap();
let r = s.execute("filter.render.flame", json!({"width": 16, "length": 60})).unwrap();
assert_eq!(r["usedPath"], json!(true));
let b: Vec<i64> = r["bounds"].as_array().unwrap().iter().map(|v| v.as_i64().unwrap()).collect();
assert!(b[0] < 20 && b[2] < 60, "flame bounds {b:?} hug the path at x=20");
}
#[test]
fn selection_limits_and_new_layer() {
let mut s = session("8");
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 80, "height": 120})).unwrap();
let r = s.execute("filter.render.tree", json!({"newLayer": true, "baseTreeType": 1, "leavesAmount": 100})).unwrap();
assert_eq!(r["newLayer"], json!(true));
let px = pixels(&s);
let right = px.as_chunks::<4>().0.iter().enumerate().filter(|(i, p)| i % 160 >= 81 && p[3] > 0.0).count();
assert_eq!(right, 0, "nothing drawn outside the selection");
assert!(opaque(&s) > 50);
}
#[test]
fn works_in_cmyk_and_gray() {
for mode in ["cmyk", "grayscale", "lab"] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 96, "height": 96, "mode": mode})).unwrap();
let r = s.execute("filter.render.pictureFrame", json!({"frame": 13, "newLayer": true})).unwrap_or_else(|e| panic!("{mode}: {e}"));
assert!(r["bounds"].is_array(), "{mode}");
}
}
/// `cargo test --release -p photocraft-engine render_cmds::tests::bench_24mp -- --ignored --nocapture`
#[test]
#[ignore]
fn bench_24mp() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 6000, "height": 4000})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
for (id, p) in [
("filter.render.flame", json!({"flameType": "multipleFlamesAlongPath", "length": 600, "width": 160, "interval": 120})),
("filter.render.flame", json!({"length": 1500, "width": 400, "flameLines": 100, "quality": "fine"})),
("filter.render.tree", json!({"baseTreeType": 1, "leavesAmount": 100})),
("filter.render.tree", json!({"baseTreeType": 6, "leavesAmount": 100})),
("filter.render.pictureFrame", json!({"frame": 1})),
] {
let t = std::time::Instant::now();
let r = s.execute(id, p).unwrap();
println!("{id}: {:?} ({} prims)", t.elapsed(), r["primitives"]);
}
}
}