mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-07 10:54:10 +00:00
Integration: Trace levels clamp to 0..255, dialog Tiles/Pointillize save (#10618)
Trace Contour read any i32 level, but a recorded step carries a whole number 0..255 that the reference application clamps; import now clamps (Lvl -1 -> 0, 300 -> 255) and export refuses levels outside 0..255. Save Actions refused every Tiles/Pointillize step recorded through the filter dialog, because the dialog adds foreground/background colours the recorded event does not carry. Export now drops them (playback uses the current colours, as the reference application does). Tiles count is rounded on export like the native command rounds it (17.4 -> TlNm 17), and null params export the defaults instead of being refused. Co-authored-by: yoriswind <164435053+yoriswind@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7e0bf9ab6c
commit
52cc86351b
2 files changed
+79
-13
No files matched your search
@@ -23,8 +23,8 @@ const EXTRUDE: &[Field] = &[
|
||||
Field("ExtR", "depthMode", Kind::Enum("ExtR", &[("Rndm", "random"), ("LvlB", "levelBased")], "random")),
|
||||
];
|
||||
const TRACE: &[Field] = &[
|
||||
// Recorded levels stay intact; playback alone clamps to the measured boundary image.
|
||||
Field("Lvl ", "level", Kind::Long(128, i32::MIN, i32::MAX)),
|
||||
// Whole levels 0..255; the reference application's dialog clamps to the nearest.
|
||||
Field("Lvl ", "level", Kind::Long(128, 0, 255)),
|
||||
Field("Edg ", "edge", Kind::Enum("CntE", &[("Lwr ", "lower"), ("Upr ", "upper")], "upper")),
|
||||
];
|
||||
const WIND: &[Field] = &[
|
||||
@@ -164,12 +164,19 @@ pub(super) fn from(id: &str, d: &Descriptor) -> Mapped {
|
||||
/// Write every measured key, preserving native defaults and rejecting native-only settings.
|
||||
pub(super) fn to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
let fields = fields(id)?;
|
||||
let params = p.as_object()?;
|
||||
// No params mean the native defaults, as for the other mapped events.
|
||||
let empty = Map::new();
|
||||
let params = if p.is_null() { &empty } else { p.as_object()? };
|
||||
// The filter dialog freezes the current colours; the recorded event has none and plays with the current ones.
|
||||
let colours = matches!(id, "filter.stylize.tiles" | "filter.pixelate.pointillize");
|
||||
let seeded =
|
||||
matches!(id, "filter.stylize.extrude" | "filter.stylize.wind" | "filter.stylize.tiles" | "filter.pixelate.mezzotint" | "filter.pixelate.pointillize");
|
||||
if !super::events::pixel_target(p)
|
||||
|| params.iter().any(|(key, value)| {
|
||||
!fields.iter().any(|f| f.1 == key.as_str()) && key != "target" && !(key == "seed" && seeded && (value.is_null() || value.as_f64() == Some(0.0)))
|
||||
!fields.iter().any(|f| f.1 == key.as_str())
|
||||
&& key != "target"
|
||||
&& !(colours && matches!(key.as_str(), "foreground" | "background"))
|
||||
&& !(key == "seed" && seeded && (value.is_null() || value.as_f64() == Some(0.0)))
|
||||
})
|
||||
{
|
||||
return None;
|
||||
@@ -182,7 +189,14 @@ pub(super) fn to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
("filter.stylize.traceContour", "edge") => json!("lower"),
|
||||
_ => kind.default(),
|
||||
};
|
||||
d = d.with(key, kind.write(p.get(param).filter(|v| !v.is_null()).unwrap_or(&default))?);
|
||||
let mut value = p.get(param).filter(|v| !v.is_null()).unwrap_or(&default).clone();
|
||||
// The native command rounds the tile count, so a fractional dialog value plays as the whole one.
|
||||
if (id, *param) == ("filter.stylize.tiles", "count")
|
||||
&& let Some(n) = value.as_f64().filter(|n| n.is_finite())
|
||||
{
|
||||
value = json!(n.round());
|
||||
}
|
||||
d = d.with(key, kind.write(&value)?);
|
||||
}
|
||||
Some(d)
|
||||
}
|
||||
@@ -184,6 +184,7 @@ fn numeric_imports_clamp_measured_ranges() {
|
||||
for (code, key, param, min, max) in [
|
||||
("Extr", "ExtS", "size", 2, 255),
|
||||
("Extr", "ExtD", "depth", 1, 255),
|
||||
("TrcC", "Lvl ", "level", 0, 255),
|
||||
("Tls ", "TlNm", "count", 1, 99),
|
||||
("Tls ", "TlOf", "maxOffset", 1, 99),
|
||||
("ClrH", "Rds ", "maxRadius", 4, 127),
|
||||
@@ -208,21 +209,22 @@ fn numeric_imports_clamp_measured_ranges() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Trace levels retain their recorded integers; only playback uses the measured boundary image.
|
||||
/// Trace levels are whole numbers 0..255: imports clamp to that range and play like the clamped direct command.
|
||||
#[test]
|
||||
fn trace_levels_round_trip_and_play_at_the_boundary() {
|
||||
fn trace_levels_clamp_on_import_and_play_at_the_boundary() {
|
||||
for level in [i32::MIN, -1, 0, 128, 255, 256, 300, i32::MAX] {
|
||||
let clamped = level.clamp(0, 255);
|
||||
for (edge, name) in [("Lwr ", "lower"), ("Upr ", "upper")] {
|
||||
let descriptor = desc("null").with("Lvl ", D::Integer(level)).with("Edg ", enm("CntE", edge));
|
||||
let mut item = code_item("TrcC", descriptor.clone());
|
||||
let mut item = code_item("TrcC", descriptor);
|
||||
item.with_dialog = true;
|
||||
let mut imported = load_items(vec![item]);
|
||||
assert_eq!(imported.actions.list[0].steps[0].0, "filter.stylize.traceContour", "{level}/{name}");
|
||||
assert_eq!(imported.actions.list[0].steps[0].1["level"], level);
|
||||
assert_eq!(imported.actions.list[0].steps[0].1["level"], clamped);
|
||||
let saved = saved_items(&mut imported);
|
||||
assert_eq!(saved[0].descriptor.as_ref().unwrap().to_bytes(), descriptor.to_bytes());
|
||||
let expected = desc("null").with("Lvl ", D::Integer(clamped)).with("Edg ", enm("CntE", edge));
|
||||
assert_eq!(saved[0].descriptor.as_ref().unwrap().to_bytes(), expected.to_bytes());
|
||||
assert!(saved[0].with_dialog);
|
||||
assert!(export_item("filter.stylize.traceContour", &json!({"level":level,"edge":name}), "Group D").is_some());
|
||||
for depth in [8, 16, 32] {
|
||||
let mut direct = Session::new();
|
||||
prepare_pattern(&mut imported, depth, "rgb");
|
||||
@@ -234,8 +236,11 @@ fn trace_levels_round_trip_and_play_at_the_boundary() {
|
||||
}
|
||||
}
|
||||
}
|
||||
for level in [255.5, 2147483648.0] {
|
||||
assert!(export_item("filter.stylize.traceContour", &json!({"level":level}), "Group D").is_none());
|
||||
for level in [0, 128, 255] {
|
||||
assert!(export_item("filter.stylize.traceContour", &json!({"level":level}), "Group D").is_some(), "{level}");
|
||||
}
|
||||
for level in [json!(-1), json!(256), json!(255.5), json!(2147483648.0)] {
|
||||
assert!(export_item("filter.stylize.traceContour", &json!({"level":level}), "Group D").is_none(), "{level}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,6 +329,10 @@ fn exports_materialize_native_defaults_and_reject_unrepresentable_settings() {
|
||||
("target", json!("channel:red")),
|
||||
("unknown", json!(true)),
|
||||
] {
|
||||
// Tiles and Pointillize drop the dialog's frozen colours (tested below).
|
||||
if matches!(key, "foreground" | "background") && matches!(fixture.code, "Tls " | "Pntl") {
|
||||
continue;
|
||||
}
|
||||
let mut params = fixture.defaults.clone();
|
||||
params[key] = value;
|
||||
let mut s = Session::new();
|
||||
@@ -429,3 +438,46 @@ fn native_and_measured_defaults_stay_distinct_through_playback() {
|
||||
assert_eq!(pixels(&imported), pixels(&native), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Dialog recordings of Tiles and Pointillize carry the current colours; the recorded event has none.
|
||||
#[test]
|
||||
fn tiles_and_pointillize_exports_drop_dialog_colours() {
|
||||
for (id, params) in
|
||||
[("filter.stylize.tiles", json!({"count":17,"maxOffset":23,"fill":"foreground"})), ("filter.pixelate.pointillize", json!({"cellSize":19}))]
|
||||
{
|
||||
let plain = export_item(id, ¶ms, "Group D").unwrap().descriptor;
|
||||
let mut coloured = params.clone();
|
||||
coloured["foreground"] = json!([0.2, 0.4, 0.6, 1.0]);
|
||||
coloured["background"] = json!("#ffffff");
|
||||
assert_eq!(export_item(id, &coloured, "Group D").unwrap().descriptor, plain, "{id}");
|
||||
let mut s = Session::new();
|
||||
s.actions.list.push(Action::new("A", vec![(id.into(), coloured)]));
|
||||
assert!(s.execute("actions.save", json!({"action":"A"})).is_ok(), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The native Tiles command rounds its count, so a fractional dialog value exports as the whole count it plays.
|
||||
#[test]
|
||||
fn tiles_count_rounds_like_the_native_command() {
|
||||
for (count, expected) in [(17.4, 17), (17.5, 18), (1.2, 1), (98.6, 99)] {
|
||||
let d = export_item("filter.stylize.tiles", &json!({"count":count}), "Group D").unwrap().descriptor.unwrap();
|
||||
assert_eq!(d.get("TlNm"), Some(&D::Integer(expected)), "{count}");
|
||||
}
|
||||
for count in [0.4, 99.5] {
|
||||
assert!(export_item("filter.stylize.tiles", &json!({"count":count}), "Group D").is_none(), "{count}");
|
||||
}
|
||||
assert!(export_item("filter.stylize.tiles", &json!({"maxOffset":10.5}), "Group D").is_none());
|
||||
}
|
||||
|
||||
/// Null params mean the native defaults, as for the other mapped events.
|
||||
#[test]
|
||||
fn null_params_export_native_defaults() {
|
||||
for id in ["filter.stylize.findEdges", "filter.stylize.tiles", "filter.stylize.traceContour"] {
|
||||
assert_eq!(
|
||||
export_item(id, &Value::Null, "Group D").map(|item| item.descriptor),
|
||||
export_item(id, &json!({}), "Group D").map(|item| item.descriptor),
|
||||
"{id}"
|
||||
);
|
||||
assert!(export_item(id, &Value::Null, "Group D").is_some(), "{id}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user