A layer's parts could be added to the mask or taken out of it, and nothing else. The selections that need composing most are the ones that are neither: the sky that is also bright, the subject that is also skin. With union and subtract alone, "this and that" had to be spelled as "this minus everything that is not that", which needs a second part that selects the complement and rarely exists. Join gains Intersect, stored as "intersect" in the part block of a sidecar. It is the product of the two coverages, dst * src, which is one more fixed-function blend state beside union's max and subtract's dst * (1 - src) (mask-editing.md 5.2): the same scratch texture, the same three vertices, no shader arithmetic. The product equals the minimum wherever either side is fully in or out, and is the softer reading where two soft edges overlap. Join::apply spells the three operations on the CPU so the GPU tests can be held to one definition. A layer that intersects with a part covering nothing now reports that it covers nothing, so it is not rasterised as an empty slice. Old sidecars never contain the word, so they read as before; a build from before this reads "intersect" as a union, the existing unknown-join fallback, which keeps the part visible rather than dropping it. Join::ALL keeps union and subtract at indices 0 and 1 so a stored panel index still means the same join.
1573 lines
50 KiB
Rust
1573 lines
50 KiB
Rust
//! Local adjustments must survive the sidecar.
|
|
//!
|
|
//! The sidecar is the authoritative store (ARCH §6.1) — the catalog is a
|
|
//! disposable index and the RAW is never written. So a mask that does not
|
|
//! round-trip is not a persistence bug, it is lost work, and the tests here
|
|
//! are about the ways that happens quietly rather than loudly.
|
|
|
|
use dr_pipeline::descriptor::{OpId, ParamId};
|
|
use dr_pipeline::mask::{Join, MaskLayer, MaskPart, MaskSource, Morphology};
|
|
use dr_pipeline::{EditGraph, Sidecar, Version};
|
|
|
|
fn regions(ids: &[u32]) -> MaskSource {
|
|
MaskSource::Regions {
|
|
signature: 0xdead_beef,
|
|
level: 300,
|
|
ids: ids.to_vec(),
|
|
}
|
|
}
|
|
|
|
/// A graph with one masked adjustment on it.
|
|
fn graph_with_mask() -> EditGraph {
|
|
let mut graph = EditGraph::default_chain();
|
|
let mut layer = MaskLayer::new("m1", regions(&[3, 7, 12]));
|
|
layer.name = "Subject".into();
|
|
layer.set_param("exposure", ParamId("exposure"), 0.75);
|
|
graph.masks_mut().push(layer);
|
|
graph
|
|
}
|
|
|
|
fn round_trip(graph: &EditGraph) -> EditGraph {
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(Version::from_graph("default", "Default", graph));
|
|
|
|
let text = sidecar.to_text();
|
|
let parsed = Sidecar::parse(&text).expect("reparse");
|
|
|
|
let mut restored = EditGraph::default_chain();
|
|
parsed
|
|
.versions
|
|
.get("default")
|
|
.expect("version survived")
|
|
.apply(&mut restored)
|
|
.expect_no_film();
|
|
restored
|
|
}
|
|
|
|
#[test]
|
|
fn a_region_mask_survives_a_round_trip() {
|
|
let graph = graph_with_mask();
|
|
let restored = round_trip(&graph);
|
|
|
|
let layers = restored.masks().layers();
|
|
assert_eq!(layers.len(), 1, "the layer came back");
|
|
|
|
let layer = &layers[0];
|
|
assert_eq!(layer.id, "m1");
|
|
assert_eq!(layer.name, "Subject");
|
|
assert_eq!(layer.base().source, regions(&[3, 7, 12]));
|
|
assert_eq!(
|
|
layer
|
|
.ops
|
|
.iter()
|
|
.find(|o| o.descriptor().id.0 == "exposure")
|
|
.map(|o| o.param(ParamId("exposure"))),
|
|
Some(0.75)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn writing_the_same_state_twice_is_byte_identical() {
|
|
// What lets a caller skip an upload by comparing content rather than
|
|
// trusting a dirty flag — the property the whole `versions` ordering
|
|
// exists for, now that masks are in the file too.
|
|
let graph = graph_with_mask();
|
|
let mut a = Sidecar::new();
|
|
a.put(Version::from_graph("default", "Default", &graph));
|
|
|
|
let once = a.to_text();
|
|
let twice = Sidecar::parse(&once).expect("reparse").to_text();
|
|
assert_eq!(once, twice);
|
|
}
|
|
|
|
#[test]
|
|
fn gradients_keep_their_geometry() {
|
|
let mut graph = EditGraph::default_chain();
|
|
|
|
let mut linear = MaskLayer::new(
|
|
"m1",
|
|
MaskSource::Linear {
|
|
centre: (0.25, 0.75),
|
|
angle: 1.25,
|
|
width: 0.4,
|
|
},
|
|
);
|
|
linear.set_param("exposure", ParamId("exposure"), -1.0);
|
|
graph.masks_mut().push(linear);
|
|
|
|
let mut radial = MaskLayer::new(
|
|
"m2",
|
|
MaskSource::Radial {
|
|
centre: (0.6, 0.4),
|
|
radii: (0.3, 0.15),
|
|
angle: -0.5,
|
|
feather: 0.65,
|
|
},
|
|
);
|
|
radial.set_param("exposure", ParamId("exposure"), 0.5);
|
|
graph.masks_mut().push(radial);
|
|
|
|
let restored = round_trip(&graph);
|
|
let layers = restored.masks().layers();
|
|
assert_eq!(layers.len(), 2);
|
|
assert_eq!(
|
|
layers[0].base().source,
|
|
graph.masks().layers()[0].base().source
|
|
);
|
|
assert_eq!(
|
|
layers[1].base().source,
|
|
graph.masks().layers()[1].base().source
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn flags_and_opacity_survive() {
|
|
let mut graph = EditGraph::default_chain();
|
|
let mut layer = MaskLayer::new("m1", regions(&[1]));
|
|
layer.set_param("exposure", ParamId("exposure"), 1.0);
|
|
layer.invert = true;
|
|
layer.opacity = 0.35;
|
|
layer.enabled = false;
|
|
graph.masks_mut().push(layer);
|
|
|
|
let restored = round_trip(&graph);
|
|
let layer = &restored.masks().layers()[0];
|
|
assert!(layer.invert);
|
|
assert!((layer.opacity - 0.35).abs() < 1e-6);
|
|
assert!(
|
|
!layer.enabled,
|
|
"a disabled layer must stay disabled, not vanish"
|
|
);
|
|
}
|
|
|
|
/// A selection's identity is the *set*. Two files naming the same regions in
|
|
/// different orders describe one mask and must compare equal, or the two
|
|
/// devices that wrote them will fight forever over a difference that is not
|
|
/// one.
|
|
#[test]
|
|
fn region_order_is_normalised_on_read() {
|
|
let text = "drsc 1\n\
|
|
\n[version default]\n\
|
|
name = Default\n\
|
|
revision = 1\n\
|
|
modified = 0\n\
|
|
\n[mask default m1]\n\
|
|
source = regions\n\
|
|
signature = 5\n\
|
|
level = 10\n\
|
|
regions = 12 3 7 3\n\
|
|
exposure.exposure = 1\n";
|
|
|
|
let parsed = Sidecar::parse(text).expect("parse");
|
|
let layer = &parsed.versions["default"].masks.layers()[0];
|
|
assert_eq!(
|
|
layer.base().source,
|
|
MaskSource::Regions {
|
|
signature: 5,
|
|
level: 10,
|
|
ids: vec![3, 7, 12],
|
|
},
|
|
"ids sort and deduplicate on the way in"
|
|
);
|
|
}
|
|
|
|
/// The version skew case. An older build must not delete a mask type it has
|
|
/// never heard of *silently* — but neither may it apply one it cannot read.
|
|
#[test]
|
|
fn an_unknown_mask_source_is_skipped_not_guessed() {
|
|
let text = "drsc 1\n\
|
|
\n[version default]\n\
|
|
name = Default\n\
|
|
revision = 1\n\
|
|
modified = 0\n\
|
|
\n[mask default m1]\n\
|
|
source = luminosity\n\
|
|
threshold = 0.5\n\
|
|
exposure.exposure = 1\n";
|
|
|
|
let parsed = Sidecar::parse(text).expect("parse");
|
|
assert!(
|
|
parsed.versions["default"].masks.is_empty(),
|
|
"an unreadable mask must not become a wrong one"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_mask_naming_no_known_version_is_dropped() {
|
|
let text = "drsc 1\n\
|
|
\n[version default]\n\
|
|
name = Default\n\
|
|
revision = 1\n\
|
|
modified = 0\n\
|
|
\n[mask ghost m1]\n\
|
|
source = regions\n\
|
|
signature = 1\n\
|
|
level = 1\n\
|
|
regions = 1\n";
|
|
|
|
let parsed = Sidecar::parse(text).expect("parse");
|
|
assert!(parsed.versions["default"].masks.is_empty());
|
|
}
|
|
|
|
/// Masks name their version rather than relying on file order, so a block
|
|
/// that appears before its version still lands on it.
|
|
#[test]
|
|
fn a_mask_block_need_not_follow_its_version() {
|
|
let text = "drsc 1\n\
|
|
\n[mask second m1]\n\
|
|
source = regions\n\
|
|
signature = 1\n\
|
|
level = 1\n\
|
|
regions = 4 5\n\
|
|
exposure.exposure = 1\n\
|
|
\n[version first]\n\
|
|
name = First\n\
|
|
revision = 1\n\
|
|
modified = 0\n\
|
|
\n[version second]\n\
|
|
name = Second\n\
|
|
revision = 1\n\
|
|
modified = 0\n";
|
|
|
|
let parsed = Sidecar::parse(text).expect("parse");
|
|
assert!(parsed.versions["first"].masks.is_empty());
|
|
assert_eq!(
|
|
parsed.versions["second"].masks.len(),
|
|
1,
|
|
"the block named its version and should have reached it"
|
|
);
|
|
}
|
|
|
|
/// Loading is a replacement, not an overlay: opening an unedited image after
|
|
/// an edited one must not leave the previous image's masks on screen.
|
|
#[test]
|
|
fn applying_a_maskless_version_clears_existing_masks() {
|
|
let mut graph = graph_with_mask();
|
|
assert_eq!(graph.masks().len(), 1);
|
|
|
|
let plain = Version::from_graph("clean", "Clean", &EditGraph::default_chain());
|
|
plain.apply(&mut graph).expect_no_film();
|
|
assert!(graph.masks().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_graph_with_only_a_masked_edit_is_not_neutral() {
|
|
let graph = graph_with_mask();
|
|
assert!(!graph.is_neutral(), "a local adjustment is still an edit");
|
|
}
|
|
|
|
/// A bare selection with nothing applied to it changes no pixel, so the image
|
|
/// is unedited — but the layer must still be written, or the selection the
|
|
/// user made is lost on reload.
|
|
#[test]
|
|
fn a_selection_with_no_adjustment_still_persists() {
|
|
let mut graph = EditGraph::default_chain();
|
|
graph.masks_mut().push(MaskLayer::new("m1", regions(&[9])));
|
|
assert!(graph.is_neutral(), "no adjustment means no pixel changes");
|
|
|
|
let restored = round_trip(&graph);
|
|
assert_eq!(
|
|
restored.masks().len(),
|
|
1,
|
|
"the selection is work and must survive even though it renders nothing"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_top_level_keys_still_round_trip_alongside_masks() {
|
|
let text = "drsc 1\n\
|
|
\n[version default]\n\
|
|
name = Default\n\
|
|
revision = 1\n\
|
|
modified = 0\n\
|
|
future.thing = 3\n\
|
|
\n[mask default m1]\n\
|
|
source = regions\n\
|
|
signature = 1\n\
|
|
level = 1\n\
|
|
regions = 2\n\
|
|
exposure.exposure = 1\n";
|
|
|
|
let out = Sidecar::parse(text).expect("parse").to_text();
|
|
assert!(
|
|
out.contains("future.thing"),
|
|
"unknown keys are still preserved"
|
|
);
|
|
assert!(out.contains("[mask default m1]"));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Brush strokes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// One gesture to paint: whether it erases, its radius, and its path.
|
|
type Gesture = (bool, f32, Vec<(f32, f32)>);
|
|
|
|
/// Paint gestures onto a fresh brush layer.
|
|
fn brushed(id: &str, gestures: &[Gesture]) -> MaskLayer {
|
|
let mut layer = MaskLayer::new(id, MaskSource::brush());
|
|
layer.set_param("exposure", ParamId("exposure"), 1.0);
|
|
for (erase, radius, path) in gestures {
|
|
layer.begin_stroke(0, *erase, *radius, 0.5, 1.0);
|
|
for &(x, y) in path {
|
|
layer.extend_stroke(0, x, y);
|
|
}
|
|
layer.end_stroke(0);
|
|
}
|
|
layer
|
|
}
|
|
|
|
/// The whole point of storing strokes as parameters: they must come back as
|
|
/// the *same numbers*, not as numbers that render similarly. A coordinate that
|
|
/// drifts in the sixth decimal on every save is a file that never stops
|
|
/// changing, and under per-field merge that is a conflict a day.
|
|
#[test]
|
|
fn strokes_survive_a_round_trip_exactly() {
|
|
let mut graph = EditGraph::default_chain();
|
|
let painted = brushed(
|
|
"m1",
|
|
&[(
|
|
false,
|
|
0.0625,
|
|
vec![(0.1234, 0.5), (0.4, 0.2), (0.8, 0.75), (0.9, 0.1)],
|
|
)],
|
|
);
|
|
let expected = painted.strokes().to_vec();
|
|
graph.masks_mut().push(painted);
|
|
|
|
let restored = round_trip(&graph);
|
|
let layer = &restored.masks().layers()[0];
|
|
assert_eq!(layer.strokes(), expected.as_slice());
|
|
}
|
|
|
|
/// Order is the mask. An erase written before the add it was meant to cut into
|
|
/// would silently repaint what the user removed — the mask still looks like a
|
|
/// mask, so nothing announces it.
|
|
#[test]
|
|
fn stroke_order_and_direction_survive() {
|
|
let mut graph = EditGraph::default_chain();
|
|
graph.masks_mut().push(brushed(
|
|
"m1",
|
|
&[
|
|
(false, 0.2, vec![(0.2, 0.5), (0.8, 0.5)]),
|
|
(true, 0.1, vec![(0.5, 0.5)]),
|
|
],
|
|
));
|
|
|
|
let restored = round_trip(&graph);
|
|
let strokes = restored.masks().layers()[0].strokes();
|
|
assert_eq!(strokes.len(), 2);
|
|
assert!(!strokes[0].erase, "the add must still come first");
|
|
assert!(strokes[1].erase, "and the erase second");
|
|
assert_eq!(strokes[1].radius, 0.1, "each stroke keeps its own brush");
|
|
}
|
|
|
|
#[test]
|
|
fn a_brush_layer_writes_one_line_per_stroke() {
|
|
let mut graph = EditGraph::default_chain();
|
|
graph.masks_mut().push(brushed(
|
|
"m1",
|
|
&[
|
|
(false, 0.05, vec![(0.2, 0.5), (0.8, 0.5)]),
|
|
(true, 0.05, vec![(0.5, 0.5)]),
|
|
],
|
|
));
|
|
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(Version::from_graph("default", "Default", &graph));
|
|
let text = sidecar.to_text();
|
|
|
|
let lines: Vec<&str> = text
|
|
.lines()
|
|
.filter(|l| l.starts_with("stroke = "))
|
|
.collect();
|
|
assert_eq!(
|
|
lines,
|
|
[
|
|
"stroke = add 0.05 0.5 1 0.2,0.5 0.8,0.5",
|
|
"stroke = erase 0.05 0.5 1 0.5,0.5"
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn writing_a_painted_mask_twice_is_byte_identical() {
|
|
let mut graph = EditGraph::default_chain();
|
|
graph.masks_mut().push(brushed(
|
|
"m1",
|
|
&[(false, 0.05, vec![(0.2, 0.5), (0.4, 0.6), (0.8, 0.5)])],
|
|
));
|
|
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(Version::from_graph("default", "Default", &graph));
|
|
let once = sidecar.to_text();
|
|
let twice = Sidecar::parse(&once).expect("reparse").to_text();
|
|
assert_eq!(once, twice);
|
|
}
|
|
|
|
/// One damaged line must not cost the strokes either side of it. Refusing the
|
|
/// whole layer would throw away a mask over a typo, and guessing at the missing
|
|
/// half would put paint where nobody touched.
|
|
#[test]
|
|
fn a_malformed_stroke_costs_only_that_stroke() {
|
|
let text = "drsc 1\n\
|
|
\n[version default]\n\
|
|
name = Default\n\
|
|
revision = 1\n\
|
|
modified = 0\n\
|
|
\n[mask default m1]\n\
|
|
source = brush\n\
|
|
stroke = add 0.05 0.5 1 0.2,0.5\n\
|
|
stroke = sideways 0.05 0.5 1 0.3,0.5\n\
|
|
stroke = add 0.05 0.5 1 0.4 0.5\n\
|
|
stroke = add 0.05 0.5 1 0.6,0.5\n\
|
|
exposure.exposure = 1\n";
|
|
|
|
let parsed = Sidecar::parse(text).expect("parse");
|
|
let strokes = parsed.versions["default"].masks.layers()[0].strokes();
|
|
assert_eq!(strokes.len(), 2, "the two readable strokes survived");
|
|
assert_eq!(strokes[0].points, vec![(0.2, 0.5)]);
|
|
assert_eq!(
|
|
strokes[1].points,
|
|
vec![(0.6, 0.5)],
|
|
"a point without its comma is refused rather than read as one number"
|
|
);
|
|
}
|
|
|
|
/// A brush layer with nothing painted on it is still work — the user made the
|
|
/// layer and set its adjustment — and must not disappear because it happens to
|
|
/// render nothing yet.
|
|
#[test]
|
|
fn an_unpainted_brush_layer_still_persists() {
|
|
let mut graph = EditGraph::default_chain();
|
|
let mut layer = MaskLayer::new("m1", MaskSource::brush());
|
|
layer.set_param("exposure", ParamId("exposure"), 1.0);
|
|
graph.masks_mut().push(layer);
|
|
|
|
let restored = round_trip(&graph);
|
|
assert_eq!(restored.masks().len(), 1);
|
|
assert_eq!(
|
|
restored.masks().layers()[0].base().source,
|
|
MaskSource::brush()
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sync merge (FR-NC-9)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn version_with(uuid: &str, revision: u64, build: impl FnOnce(&mut EditGraph)) -> Version {
|
|
let mut graph = EditGraph::default_chain();
|
|
build(&mut graph);
|
|
let mut v = Version::from_graph(uuid, "Default", &graph);
|
|
v.revision = revision;
|
|
v
|
|
}
|
|
|
|
fn lit(id: &str, ids: &[u32], ev: f32) -> MaskLayer {
|
|
let mut layer = MaskLayer::new(id, regions(ids));
|
|
layer.set_param("exposure", ParamId("exposure"), ev);
|
|
layer
|
|
}
|
|
|
|
/// The case the whole key-wise merge exists for, one level up: a layer added
|
|
/// on the phone and a layer added on the desktop are not a conflict.
|
|
#[test]
|
|
fn disjoint_layers_from_two_devices_both_survive() {
|
|
let base = version_with("default", 1, |_| {});
|
|
|
|
let mut ours = version_with("default", 2, |g| {
|
|
g.masks_mut().push(lit("m1", &[1], 1.0));
|
|
});
|
|
let theirs = version_with("default", 2, |g| {
|
|
g.masks_mut().push(lit("m2", &[2], -1.0));
|
|
});
|
|
|
|
let conflicts = ours.merge(&theirs, Some(&base));
|
|
assert!(conflicts.is_empty(), "different layers are not a conflict");
|
|
assert_eq!(ours.masks.len(), 2);
|
|
assert!(ours.masks.get("m1").is_some());
|
|
assert!(ours.masks.get("m2").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn a_layer_only_the_remote_added_arrives() {
|
|
let base = version_with("default", 1, |_| {});
|
|
let mut ours = version_with("default", 2, |_| {});
|
|
let theirs = version_with("default", 3, |g| {
|
|
g.masks_mut().push(lit("m1", &[5], 2.0));
|
|
});
|
|
|
|
assert!(ours.merge(&theirs, Some(&base)).is_empty());
|
|
assert_eq!(ours.masks.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn both_editing_one_layer_is_a_conflict_resolved_by_revision() {
|
|
let base = version_with("default", 1, |g| {
|
|
g.masks_mut().push(lit("m1", &[1], 0.5));
|
|
});
|
|
|
|
let mut ours = version_with("default", 2, |g| {
|
|
g.masks_mut().push(lit("m1", &[1], 1.0));
|
|
});
|
|
let theirs = version_with("default", 9, |g| {
|
|
g.masks_mut().push(lit("m1", &[1], -1.0));
|
|
});
|
|
|
|
let conflicts = ours.merge(&theirs, Some(&base));
|
|
assert_eq!(conflicts, vec![("mask".to_string(), "m1".to_string())]);
|
|
|
|
let layer = ours.masks.get("m1").expect("layer survived");
|
|
let ev = layer
|
|
.ops
|
|
.iter()
|
|
.find(|o| o.descriptor().id.0 == "exposure")
|
|
.map(|o| o.param(ParamId("exposure")));
|
|
assert_eq!(ev, Some(-1.0), "the higher revision wins the layer whole");
|
|
}
|
|
|
|
/// A layer deleted on one device and untouched on the other must stay
|
|
/// deleted, or a mask the user removed reappears on every sync.
|
|
#[test]
|
|
fn a_remote_deletion_is_honoured() {
|
|
let base = version_with("default", 1, |g| {
|
|
g.masks_mut().push(lit("m1", &[1], 1.0));
|
|
});
|
|
let mut ours = version_with("default", 2, |g| {
|
|
g.masks_mut().push(lit("m1", &[1], 1.0));
|
|
});
|
|
let theirs = version_with("default", 3, |_| {});
|
|
|
|
assert!(ours.merge(&theirs, Some(&base)).is_empty());
|
|
assert!(ours.masks.is_empty(), "the deletion should not be undone");
|
|
}
|
|
|
|
#[test]
|
|
fn an_untouched_layer_is_left_alone() {
|
|
let base = version_with("default", 1, |g| {
|
|
g.masks_mut().push(lit("m1", &[1], 1.0));
|
|
});
|
|
let mut ours = version_with("default", 2, |g| {
|
|
g.masks_mut().push(lit("m1", &[1], 1.0));
|
|
g.masks_mut().push(lit("m2", &[2], 0.5));
|
|
});
|
|
let theirs = version_with("default", 3, |g| {
|
|
g.masks_mut().push(lit("m1", &[1], 1.0));
|
|
});
|
|
|
|
assert!(ours.merge(&theirs, Some(&base)).is_empty());
|
|
assert_eq!(ours.masks.len(), 2, "our new layer is not a conflict");
|
|
}
|
|
|
|
/// Print a real sidecar carrying a subject mask, for eyeballing the format.
|
|
///
|
|
/// Ignored because it asserts nothing — it exists so the on-disk shape can be
|
|
/// looked at without reverse-engineering it from the writer.
|
|
///
|
|
/// ```sh
|
|
/// cargo test -p dr-pipeline --test mask_sidecar show_a_sidecar -- --ignored --nocapture
|
|
/// ```
|
|
#[test]
|
|
#[ignore = "prints the format rather than checking it"]
|
|
fn show_a_sidecar() {
|
|
let mut graph = EditGraph::default_chain();
|
|
graph.set_param(dr_pipeline::OpId("exposure"), ParamId("exposure"), 0.35);
|
|
|
|
let mut subject = MaskLayer::new(
|
|
"m1",
|
|
MaskSource::Subject {
|
|
signature: 0x9f2c_41aa,
|
|
index: 0,
|
|
class: "person".into(),
|
|
score: 0.94,
|
|
},
|
|
);
|
|
subject.name = "person".into();
|
|
subject.invert = true;
|
|
subject.base_mut().feather = 0.02;
|
|
subject.base_mut().falloff = dr_pipeline::mask::Falloff::Gaussian;
|
|
subject.base_mut().morphology = dr_pipeline::mask::Morphology::Dilate;
|
|
subject.base_mut().morph_radius = 0.012;
|
|
subject.set_param("saturation", ParamId("saturation"), -100.0);
|
|
subject.set_param("exposure", ParamId("exposure"), -0.4);
|
|
graph.masks_mut().push(subject);
|
|
|
|
let mut grad = MaskLayer::new(
|
|
"m2",
|
|
MaskSource::Linear {
|
|
centre: (0.5, 0.25),
|
|
// The constant rather than four digits of it: clippy rejects the
|
|
// literal, and a quarter turn written as a number is a quarter
|
|
// turn nobody can see at a glance.
|
|
angle: std::f32::consts::FRAC_PI_2,
|
|
width: 0.4,
|
|
},
|
|
);
|
|
grad.set_param("exposure", ParamId("exposure"), -0.6);
|
|
graph.masks_mut().push(grad);
|
|
|
|
let mut brush = brushed(
|
|
"m3",
|
|
&[
|
|
(false, 0.06, vec![(0.31, 0.44), (0.35, 0.46), (0.4, 0.52)]),
|
|
(true, 0.03, vec![(0.36, 0.47)]),
|
|
],
|
|
);
|
|
brush.name = "dodge".into();
|
|
graph.masks_mut().push(brush);
|
|
|
|
let mut sidecar = Sidecar::new();
|
|
let mut v = Version::from_graph("default", "Default", &graph);
|
|
v.is_default = true;
|
|
v.rating = 4;
|
|
sidecar.put(v);
|
|
|
|
println!("\n{}", sidecar.to_text());
|
|
}
|
|
|
|
/// A category mask is a name plus a signature, and both have to survive.
|
|
///
|
|
/// The name especially. `MaskSource::Category` stores one rather than an index
|
|
/// precisely so that editing `models/scene/categories.txt` cannot repoint a
|
|
/// stored layer — and that reasoning is worth nothing if the name is what the
|
|
/// sidecar drops.
|
|
#[test]
|
|
fn category_masks_keep_their_name() {
|
|
let mut graph = EditGraph::default_chain();
|
|
|
|
let mut sky = MaskLayer::new(
|
|
"m1",
|
|
MaskSource::Category {
|
|
signature: 0x5EED_1234,
|
|
name: "sky".into(),
|
|
},
|
|
);
|
|
sky.name = "sky".into();
|
|
sky.base_mut().feather = 0.03;
|
|
sky.set_param("exposure", ParamId("exposure"), 0.5);
|
|
graph.masks_mut().push(sky);
|
|
|
|
// A multi-word name too: the writer emits `category = swimming pool` on one
|
|
// line, and a reader that split on whitespace would silently truncate it to
|
|
// a category no model has.
|
|
let mut pool = MaskLayer::new(
|
|
"m2",
|
|
MaskSource::Category {
|
|
signature: 0x5EED_1234,
|
|
name: "swimming pool".into(),
|
|
},
|
|
);
|
|
pool.set_param("saturation", ParamId("saturation"), -30.0);
|
|
graph.masks_mut().push(pool);
|
|
|
|
let restored = round_trip(&graph);
|
|
let layers = restored.masks().layers();
|
|
assert_eq!(layers.len(), 2);
|
|
assert_eq!(
|
|
layers[0].base().source,
|
|
graph.masks().layers()[0].base().source
|
|
);
|
|
assert_eq!(
|
|
layers[1].base().source,
|
|
graph.masks().layers()[1].base().source
|
|
);
|
|
assert!((layers[0].base().feather - 0.03).abs() < 1e-6);
|
|
}
|
|
|
|
/// The refine strictness survives, and its absence means zero.
|
|
///
|
|
/// Both halves matter and they are different claims. A dropped strictness
|
|
/// would reopen a sky the photographer had closed, silently, on the next
|
|
/// launch — the mask would still be there and would simply have got the
|
|
/// chimneys back.
|
|
///
|
|
/// And a *missing* key has to read as zero rather than as the default the
|
|
/// panel starts a new layer at, because that is what makes a file written
|
|
/// before this control existed render exactly as it did then. A default of 4
|
|
/// on absence would quietly re-grade every stored category mask in the
|
|
/// catalogue.
|
|
#[test]
|
|
fn a_categorys_refine_strictness_survives_and_defaults_off() {
|
|
let mut graph = EditGraph::default_chain();
|
|
|
|
let mut sky = MaskLayer::new(
|
|
"m1",
|
|
MaskSource::Category {
|
|
signature: 0x5EED_1234,
|
|
name: "sky".into(),
|
|
},
|
|
);
|
|
sky.base_mut().refine = 3.7;
|
|
graph.masks_mut().push(sky);
|
|
|
|
// Left at zero, so the writer omits the key entirely — which is the case
|
|
// the second half of this test is really about.
|
|
let plain = MaskLayer::new(
|
|
"m2",
|
|
MaskSource::Category {
|
|
signature: 0x5EED_1234,
|
|
name: "water".into(),
|
|
},
|
|
);
|
|
graph.masks_mut().push(plain);
|
|
|
|
let text = {
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(Version::from_graph("default", "Default", &graph));
|
|
sidecar.to_text()
|
|
};
|
|
assert_eq!(
|
|
text.matches("refine =").count(),
|
|
1,
|
|
"only the layer that set one should write the key:\n{text}"
|
|
);
|
|
|
|
let layers = round_trip(&graph);
|
|
let layers = layers.masks().layers();
|
|
assert!(
|
|
(layers[0].base().refine - 3.7).abs() < 1e-6,
|
|
"got {}",
|
|
layers[0].base().refine
|
|
);
|
|
assert_eq!(
|
|
layers[1].base().refine,
|
|
0.0,
|
|
"an absent key is the model's own mask"
|
|
);
|
|
}
|
|
|
|
/// A file is not trusted to ask for a strictness off the end of the scale.
|
|
///
|
|
/// Past the top of the range every colour fails the test, so the mask deletes
|
|
/// itself — which reads as a lost edit rather than as a bad file.
|
|
#[test]
|
|
fn an_out_of_range_refine_is_clamped() {
|
|
let mut graph = EditGraph::default_chain();
|
|
let mut sky = MaskLayer::new(
|
|
"m1",
|
|
MaskSource::Category {
|
|
signature: 0x5EED_1234,
|
|
name: "sky".into(),
|
|
},
|
|
);
|
|
sky.base_mut().refine = 3.0;
|
|
graph.masks_mut().push(sky);
|
|
|
|
// Edited in the written text rather than assembled by hand, so this test
|
|
// cannot drift out of step with the format the writer actually emits.
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(Version::from_graph("default", "Default", &graph));
|
|
let text = sidecar.to_text().replace("refine = 3", "refine = 900");
|
|
assert!(text.contains("refine = 900"), "the edit must have applied");
|
|
|
|
let parsed = Sidecar::parse(&text).expect("reparse");
|
|
let mut restored = EditGraph::default_chain();
|
|
parsed
|
|
.versions
|
|
.get("default")
|
|
.expect("version survived")
|
|
.apply(&mut restored)
|
|
.expect_no_film();
|
|
|
|
let refine = restored.masks().layers()[0].base().refine;
|
|
assert!(
|
|
refine <= dr_pipeline::mask::MAX_REFINE,
|
|
"a file asked for 900 and got {refine}"
|
|
);
|
|
}
|
|
|
|
/// A category is stale when its run is, exactly as a subject is.
|
|
///
|
|
/// The coverage buffer lives in the session, not the sidecar, so a layer whose
|
|
/// signature no longer matches is pointing at pixels that were never computed.
|
|
/// Rendering it anyway would mask nothing and read as a broken adjustment.
|
|
#[test]
|
|
fn a_category_from_another_run_is_stale() {
|
|
let layer = MaskLayer::new(
|
|
"m1",
|
|
MaskSource::Category {
|
|
signature: 1,
|
|
name: "sky".into(),
|
|
},
|
|
);
|
|
assert!(layer.is_stale(2), "a different run must invalidate it");
|
|
assert!(!layer.is_stale(1), "the run it was built against must not");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Stored coverage (dr_pipeline::coverage)
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// A subject or a category is stored as *identity* — which run, which instance,
|
|
// which category — and identity alone is only enough while the run is still in
|
|
// memory. These pin down the raster that goes beside it, which is what lets a
|
|
// reopened photograph and a batch export render the layer without a model.
|
|
|
|
use dr_pipeline::coverage::{Coverage, RENDERED_LEVELS};
|
|
use std::sync::Arc;
|
|
|
|
const PROXY: (usize, usize) = (96, 64);
|
|
|
|
fn subject(signature: u64, index: u32) -> MaskSource {
|
|
MaskSource::Subject {
|
|
signature,
|
|
index,
|
|
class: "dog".into(),
|
|
score: 0.94,
|
|
}
|
|
}
|
|
|
|
/// A soft-edged disc, which is the shape a model actually hands out: a coarse
|
|
/// sigmoid with a shoulder several pixels wide.
|
|
fn a_disc() -> Vec<u8> {
|
|
let (w, h) = PROXY;
|
|
let mut values = vec![0u8; w * h];
|
|
for y in 0..h {
|
|
for x in 0..w {
|
|
let dx = (x as f32 - 40.0) / 20.0;
|
|
let dy = (y as f32 - 30.0) / 20.0;
|
|
let r = (dx * dx + dy * dy).sqrt();
|
|
values[y * w + x] = ((1.0 / (1.0 + ((r - 1.0) * 4.0).exp())) * 255.0) as u8;
|
|
}
|
|
}
|
|
values
|
|
}
|
|
|
|
/// What the renderer would make of a coverage: the distance transform reads
|
|
/// `>= 128` and nothing else, so this is the whole of the information a stored
|
|
/// mask has to preserve.
|
|
fn inside(values: &[u8]) -> Vec<bool> {
|
|
values.iter().map(|&v| v >= 128).collect()
|
|
}
|
|
|
|
fn with_coverage(id: &str, source: MaskSource, values: &[u8]) -> MaskLayer {
|
|
let mut layer = MaskLayer::new(id, source);
|
|
layer.set_param("exposure", ParamId("exposure"), 0.75);
|
|
layer.base_mut().coverage = Some(Arc::new(
|
|
Coverage::encode(values, PROXY.0, PROXY.1, RENDERED_LEVELS).expect("a disc should encode"),
|
|
));
|
|
layer
|
|
}
|
|
|
|
/// The bug this whole thing exists for: without the raster, reopening the
|
|
/// photograph gave the layer nothing to be, and the local adjustment was
|
|
/// silently absent until somebody pressed "find subjects".
|
|
#[test]
|
|
fn a_subjects_coverage_survives_a_round_trip() {
|
|
let values = a_disc();
|
|
let mut graph = EditGraph::default_chain();
|
|
graph
|
|
.masks_mut()
|
|
.push(with_coverage("m1", subject(0x1234, 2), &values));
|
|
|
|
let restored = round_trip(&graph);
|
|
let layer = &restored.masks().layers()[0];
|
|
|
|
assert_eq!(
|
|
layer.base().source,
|
|
subject(0x1234, 2),
|
|
"the identity is still there"
|
|
);
|
|
let coverage = layer
|
|
.base()
|
|
.coverage
|
|
.as_ref()
|
|
.expect("the pixels came back too");
|
|
assert_eq!((coverage.width(), coverage.height()), PROXY);
|
|
assert_eq!(
|
|
inside(&coverage.decode()),
|
|
inside(&values),
|
|
"every pixel must fall on the same side of the threshold"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_categorys_coverage_survives_a_round_trip() {
|
|
let values = a_disc();
|
|
let source = MaskSource::Category {
|
|
signature: 0x5678,
|
|
name: "sky".into(),
|
|
};
|
|
let mut graph = EditGraph::default_chain();
|
|
graph
|
|
.masks_mut()
|
|
.push(with_coverage("m1", source.clone(), &values));
|
|
|
|
let restored = round_trip(&graph);
|
|
let layer = &restored.masks().layers()[0];
|
|
assert_eq!(layer.base().source, source);
|
|
assert_eq!(
|
|
inside(&layer.base().coverage.as_ref().expect("coverage").decode()),
|
|
inside(&values)
|
|
);
|
|
}
|
|
|
|
/// The property that lets a caller skip an upload by comparing content — now
|
|
/// with several kilobytes of run-length payload in the file. An encoder that
|
|
/// re-coded the same raster differently on the way out would put a spurious
|
|
/// upload on every save.
|
|
#[test]
|
|
fn a_stored_coverage_writes_the_same_bytes_every_time() {
|
|
let mut graph = EditGraph::default_chain();
|
|
graph
|
|
.masks_mut()
|
|
.push(with_coverage("m1", subject(1, 0), &a_disc()));
|
|
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(Version::from_graph("default", "Default", &graph));
|
|
let once = sidecar.to_text();
|
|
let twice = Sidecar::parse(&once).expect("reparse").to_text();
|
|
assert_eq!(once, twice);
|
|
}
|
|
|
|
/// The line goes last in its block, and that is a claim about reading the file
|
|
/// by hand: it is thousands of characters against a dozen everywhere else, and
|
|
/// a sidecar is what somebody opens when an edit has gone wrong (ARCH §6.12).
|
|
#[test]
|
|
fn the_coverage_line_comes_after_everything_a_human_is_looking_for() {
|
|
let mut graph = EditGraph::default_chain();
|
|
graph
|
|
.masks_mut()
|
|
.push(with_coverage("m1", subject(1, 0), &a_disc()));
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(Version::from_graph("default", "Default", &graph));
|
|
let text = sidecar.to_text();
|
|
|
|
let block = text.split("[mask ").nth(1).expect("a mask block");
|
|
let keys: Vec<&str> = block
|
|
.lines()
|
|
.filter_map(|l| l.split_once(" = "))
|
|
.map(|(k, _)| k)
|
|
.collect();
|
|
assert_eq!(
|
|
keys.last(),
|
|
Some(&"coverage"),
|
|
"coverage should be the last key in the block: {keys:?}"
|
|
);
|
|
assert!(
|
|
keys.contains(&"class") && keys.contains(&"exposure.exposure"),
|
|
"and everything else should still be above it: {keys:?}"
|
|
);
|
|
assert_eq!(
|
|
block.lines().filter(|l| l.starts_with("coverage")).count(),
|
|
1,
|
|
"one line, because a node is a line and the merge is key-wise"
|
|
);
|
|
}
|
|
|
|
/// Version skew, backwards: a file written before any of this existed.
|
|
///
|
|
/// The layer must load and behave exactly as it did then — which is to say it
|
|
/// needs the model run — rather than reading as a mask with no pixels.
|
|
#[test]
|
|
fn a_sidecar_written_before_coverage_existed_still_loads() {
|
|
let text = "\
|
|
drsc 1
|
|
|
|
[version default]
|
|
name = Default
|
|
default = 1
|
|
revision = 3
|
|
|
|
[mask default m1]
|
|
name = Dog
|
|
source = subject
|
|
signature = 4660
|
|
index = 2
|
|
class = dog
|
|
score = 0.94
|
|
exposure.exposure = 0.75
|
|
";
|
|
let sidecar = Sidecar::parse(text).expect("an old file must still parse");
|
|
let mut graph = EditGraph::default_chain();
|
|
sidecar
|
|
.versions
|
|
.get("default")
|
|
.expect("version")
|
|
.apply(&mut graph)
|
|
.expect_no_film();
|
|
|
|
let layer = &graph.masks().layers()[0];
|
|
assert_eq!(layer.base().source, subject(4660, 2));
|
|
assert_eq!(layer.name, "Dog");
|
|
assert!(
|
|
layer.base().coverage.is_none(),
|
|
"absent means absent, not an empty mask"
|
|
);
|
|
}
|
|
|
|
/// Version skew, forwards: a coverage this build cannot make sense of.
|
|
///
|
|
/// The stand-in for a payload written by a build that means something else by
|
|
/// the key. It costs the pixels — a model run — and must not cost the layer,
|
|
/// because the layer is the edit and the raster is a cache of it.
|
|
#[test]
|
|
fn an_unreadable_coverage_costs_the_pixels_and_not_the_layer() {
|
|
let text = "\
|
|
drsc 1
|
|
|
|
[version default]
|
|
name = Default
|
|
default = 1
|
|
|
|
[mask default m1]
|
|
name = Dog
|
|
source = subject
|
|
signature = 4660
|
|
index = 2
|
|
class = dog
|
|
score = 0.94
|
|
exposure.exposure = 0.75
|
|
coverage = 96 64 999 not-a-payload
|
|
";
|
|
let sidecar = Sidecar::parse(text).expect("parse");
|
|
let mut graph = EditGraph::default_chain();
|
|
sidecar
|
|
.versions
|
|
.get("default")
|
|
.expect("version")
|
|
.apply(&mut graph)
|
|
.expect_no_film();
|
|
|
|
let layer = &graph.masks().layers()[0];
|
|
assert_eq!(layer.base().source, subject(4660, 2));
|
|
assert_eq!(layer.name, "Dog");
|
|
assert!(layer.base().coverage.is_none());
|
|
assert_eq!(
|
|
layer
|
|
.ops
|
|
.iter()
|
|
.find(|o| o.descriptor().id.0 == "exposure")
|
|
.map(|o| o.param(ParamId("exposure"))),
|
|
Some(0.75),
|
|
"the adjustment is the thing that must not be lost"
|
|
);
|
|
}
|
|
|
|
/// A raster only means anything against a source a model produced. A gradient
|
|
/// carrying one is a hand-edited or mis-written file, and honouring it would
|
|
/// upload a buffer nothing samples.
|
|
#[test]
|
|
fn coverage_is_not_loaded_onto_a_source_with_no_model_behind_it() {
|
|
let payload = Coverage::encode(&a_disc(), PROXY.0, PROXY.1, RENDERED_LEVELS)
|
|
.expect("encode")
|
|
.to_text();
|
|
let text = format!(
|
|
"drsc 1\n\n[version default]\nname = Default\ndefault = 1\n\n\
|
|
[mask default m1]\nsource = radial\ncentre = 0.5 0.5\nradii = 0.25 0.25\n\
|
|
coverage = {payload}\n"
|
|
);
|
|
let sidecar = Sidecar::parse(&text).expect("parse");
|
|
let mut graph = EditGraph::default_chain();
|
|
sidecar
|
|
.versions
|
|
.get("default")
|
|
.expect("version")
|
|
.apply(&mut graph)
|
|
.expect_no_film();
|
|
|
|
let layer = &graph.masks().layers()[0];
|
|
assert!(matches!(layer.base().source, MaskSource::Radial { .. }));
|
|
assert!(layer.base().coverage.is_none());
|
|
}
|
|
|
|
/// TRACES: FR-NC-9
|
|
/// One device has run the model and the other has not. That is the same edit.
|
|
///
|
|
/// The merge decides "did this device change the layer" by comparing layers,
|
|
/// so a cached raster taking part would make a photograph opened on the phone
|
|
/// conflict with itself on the desktop — and, with `remote_wins`, resolve the
|
|
/// conflict by discarding the only copy of the pixels.
|
|
#[test]
|
|
fn a_coverage_one_device_has_and_the_other_lacks_is_not_a_conflict() {
|
|
let layer = |with: bool| {
|
|
let mut l = lit("m1", &[1], 1.0);
|
|
l.base_mut().source = subject(7, 0);
|
|
if with {
|
|
l.base_mut().coverage = Some(Arc::new(
|
|
Coverage::encode(&a_disc(), PROXY.0, PROXY.1, RENDERED_LEVELS).expect("encode"),
|
|
));
|
|
}
|
|
l
|
|
};
|
|
|
|
let base = version_with("default", 1, |g| {
|
|
g.masks_mut().push(layer(false));
|
|
});
|
|
let mut ours = version_with("default", 2, |g| {
|
|
g.masks_mut().push(layer(true));
|
|
});
|
|
let theirs = version_with("default", 9, |g| {
|
|
g.masks_mut().push(layer(false));
|
|
});
|
|
|
|
let conflicts = ours.merge(&theirs, Some(&base));
|
|
assert!(
|
|
conflicts.is_empty(),
|
|
"running the model is not an edit: {conflicts:?}"
|
|
);
|
|
assert!(
|
|
ours.masks
|
|
.get("m1")
|
|
.expect("layer")
|
|
.base()
|
|
.coverage
|
|
.is_some(),
|
|
"and the side that has the pixels keeps them"
|
|
);
|
|
}
|
|
|
|
/// The other half of the same claim: a real edit is still a conflict when both
|
|
/// sides also happen to hold coverage.
|
|
#[test]
|
|
fn a_real_edit_is_still_a_conflict_with_coverage_present() {
|
|
let stored = || {
|
|
Some(Arc::new(
|
|
Coverage::encode(&a_disc(), PROXY.0, PROXY.1, RENDERED_LEVELS).expect("encode"),
|
|
))
|
|
};
|
|
let layer = |ev: f32| {
|
|
let mut l = lit("m1", &[1], ev);
|
|
l.base_mut().source = subject(7, 0);
|
|
l.base_mut().coverage = stored();
|
|
l
|
|
};
|
|
|
|
let base = version_with("default", 1, |g| {
|
|
g.masks_mut().push(layer(0.5));
|
|
});
|
|
let mut ours = version_with("default", 2, |g| {
|
|
g.masks_mut().push(layer(1.0));
|
|
});
|
|
let theirs = version_with("default", 9, |g| {
|
|
g.masks_mut().push(layer(-1.0));
|
|
});
|
|
|
|
assert_eq!(
|
|
ours.merge(&theirs, Some(&base)),
|
|
vec![("mask".to_string(), "m1".to_string())]
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Range masks (FR-DEV-10)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// TRACES: FR-DEV-10
|
|
/// A band is the whole of what a range mask is, so losing one edge of it is
|
|
/// losing the mask — and losing it silently, because the layer still names a
|
|
/// source and still draws something.
|
|
#[test]
|
|
fn range_masks_keep_their_bands() {
|
|
let mut graph = EditGraph::default_chain();
|
|
|
|
let mut tone = MaskLayer::new("m1", MaskSource::luminance_range(0.62, 0.91, 0.2));
|
|
tone.set_param("exposure", ParamId("exposure"), -0.75);
|
|
graph.masks_mut().push(tone);
|
|
|
|
let mut colour = MaskLayer::new("m2", MaskSource::colour_range(0.07, 0.04, 0.2, 0.85, 0.11));
|
|
colour.set_param("exposure", ParamId("exposure"), 0.25);
|
|
graph.masks_mut().push(colour);
|
|
|
|
let restored = round_trip(&graph);
|
|
let layers = restored.masks().layers();
|
|
assert_eq!(layers.len(), 2);
|
|
assert_eq!(
|
|
layers[0].base().source,
|
|
MaskSource::luminance_range(0.62, 0.91, 0.2)
|
|
);
|
|
assert_eq!(
|
|
layers[1].base().source,
|
|
MaskSource::colour_range(0.07, 0.04, 0.2, 0.85, 0.11)
|
|
);
|
|
}
|
|
|
|
/// TRACES: FR-DEV-10
|
|
/// Two devices that made the same range mask must write the same bytes, or
|
|
/// per-field merge (FR-NC-9) has a difference to arbitrate that is not one.
|
|
#[test]
|
|
fn writing_a_range_mask_twice_is_byte_identical() {
|
|
let mut graph = EditGraph::default_chain();
|
|
let mut layer = MaskLayer::new("m1", MaskSource::skin_tones());
|
|
layer.set_param("exposure", ParamId("exposure"), 0.4);
|
|
graph.masks_mut().push(layer);
|
|
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(Version::from_graph("default", "Default", &graph));
|
|
|
|
let once = sidecar.to_text();
|
|
let twice = Sidecar::parse(&once).expect("reparse").to_text();
|
|
assert_eq!(once, twice);
|
|
}
|
|
|
|
/// TRACES: FR-DEV-10
|
|
/// A band written the wrong way round — by hand, or by a build that let a
|
|
/// drag cross the handles — is a mask that selects nothing. Ordering it on
|
|
/// read is what makes that a recoverable file rather than a lost edit.
|
|
#[test]
|
|
fn a_crossed_band_is_ordered_on_read() {
|
|
let text = "drsc 1\n\
|
|
\n[version default]\n\
|
|
name = Default\n\
|
|
\n[mask default m1]\n\
|
|
source = luminance\n\
|
|
band = 0.9 0.2 0.1\n\
|
|
exposure.exposure = 0.5\n";
|
|
|
|
let parsed = Sidecar::parse(text).expect("parse");
|
|
let mut graph = EditGraph::default_chain();
|
|
parsed
|
|
.versions
|
|
.get("default")
|
|
.expect("version")
|
|
.apply(&mut graph)
|
|
.expect_no_film();
|
|
|
|
assert_eq!(
|
|
graph.masks().layers()[0].base().source,
|
|
MaskSource::luminance_range(0.2, 0.9, 0.1),
|
|
"the band came back as the one the numbers describe"
|
|
);
|
|
}
|
|
|
|
/// TRACES: FR-DEV-10
|
|
/// A range block with no band at all is a file from a build that spelled the
|
|
/// key differently, or a hand edit that deleted a line. It reads back as the
|
|
/// mask the panel would have made rather than as an empty selection, because
|
|
/// an empty selection looks exactly like a mask that works and does nothing.
|
|
#[test]
|
|
fn a_range_mask_with_no_band_falls_back_to_the_default_one() {
|
|
let text = "drsc 1\n\
|
|
\n[version default]\n\
|
|
name = Default\n\
|
|
\n[mask default m1]\n\
|
|
source = luminance\n\
|
|
exposure.exposure = 0.5\n";
|
|
|
|
let parsed = Sidecar::parse(text).expect("parse");
|
|
let mut graph = EditGraph::default_chain();
|
|
parsed
|
|
.versions
|
|
.get("default")
|
|
.expect("version")
|
|
.apply(&mut graph)
|
|
.expect_no_film();
|
|
|
|
assert_eq!(
|
|
graph.masks().layers()[0].base().source,
|
|
MaskSource::highlights()
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Parts
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A subject with a correction painted on top of it — the ordinary shape of a
|
|
/// mask that has been edited by hand.
|
|
fn corrected(id: &str, join: Join) -> MaskLayer {
|
|
let mut layer = MaskLayer::new(
|
|
id,
|
|
MaskSource::Subject {
|
|
signature: 0xdead_beef,
|
|
index: 0,
|
|
class: "dog".into(),
|
|
score: 0.9,
|
|
},
|
|
);
|
|
layer.set_param("exposure", ParamId("exposure"), 0.5);
|
|
layer.push_part(MaskPart::painted(layer.next_part_id(), join));
|
|
layer.begin_stroke(1, false, 0.05, 0.5, 1.0);
|
|
for &(x, y) in &[(0.2, 0.2), (0.3, 0.25), (0.4, 0.3)] {
|
|
layer.extend_stroke(1, x, y);
|
|
}
|
|
layer.end_stroke(1);
|
|
layer
|
|
}
|
|
|
|
/// The property that makes this format change safe: a mask nobody has
|
|
/// corrected writes what it has always written, so every sidecar in every
|
|
/// library keeps parsing to the same edit and no file grows a block it does
|
|
/// not need.
|
|
#[test]
|
|
fn a_mask_of_one_part_writes_no_part_block() {
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(Version::from_graph(
|
|
"default",
|
|
"Default",
|
|
&graph_with_mask(),
|
|
));
|
|
let text = sidecar.to_text();
|
|
|
|
assert!(text.contains("[mask default m1]"), "{text}");
|
|
assert!(
|
|
!text.contains("[part "),
|
|
"a layer of one part invented a block for it:\n{text}"
|
|
);
|
|
assert!(
|
|
!text.contains("join ="),
|
|
"a layer of one part wrote a join nothing joins to:\n{text}"
|
|
);
|
|
}
|
|
|
|
/// And the other half of it: a file written before parts existed reads back as
|
|
/// a layer of exactly one, rather than as one with an empty correction on it.
|
|
#[test]
|
|
fn a_mask_block_with_no_part_blocks_is_one_part() {
|
|
let layers = round_trip(&graph_with_mask());
|
|
let layers = layers.masks().layers();
|
|
|
|
assert_eq!(layers[0].parts().len(), 1);
|
|
assert_eq!(layers[0].parts()[0].id, "p1", "the implied part is named");
|
|
assert_eq!(layers[0].parts()[0].join, Join::Union);
|
|
}
|
|
|
|
#[test]
|
|
fn a_correction_painted_onto_a_subject_survives_a_round_trip() {
|
|
let mut graph = EditGraph::default_chain();
|
|
let layer = corrected("m1", Join::Subtract);
|
|
let expected = layer.parts()[1].strokes().to_vec();
|
|
graph.masks_mut().push(layer);
|
|
|
|
let restored = round_trip(&graph);
|
|
let layer = &restored.masks().layers()[0];
|
|
|
|
assert_eq!(layer.parts().len(), 2, "both parts came back");
|
|
assert_eq!(layer.parts()[1].id, "p2");
|
|
assert_eq!(layer.parts()[1].join, Join::Subtract);
|
|
assert_eq!(layer.parts()[1].strokes(), expected.as_slice());
|
|
assert_eq!(
|
|
layer.parts()[0].source.kind(),
|
|
"subject",
|
|
"and the base is still what it was"
|
|
);
|
|
}
|
|
|
|
/// TRACES: FR-DEV-19a
|
|
/// An intersecting part is written under its own word and reads back as an
|
|
/// intersection. A build from before intersection read that word as a union
|
|
/// (see `an_unknown_join_adds_the_part`), which keeps the part visible and
|
|
/// fixable rather than silently cutting the mask down.
|
|
#[test]
|
|
fn an_intersecting_part_survives_a_round_trip() {
|
|
let mut graph = EditGraph::default_chain();
|
|
graph.masks_mut().push(corrected("m1", Join::Intersect));
|
|
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(Version::from_graph("default", "Default", &graph));
|
|
let text = sidecar.to_text();
|
|
assert!(text.contains("join = intersect"), "{text}");
|
|
|
|
let restored = round_trip(&graph);
|
|
let layer = &restored.masks().layers()[0];
|
|
assert_eq!(layer.parts().len(), 2);
|
|
assert_eq!(layer.parts()[1].join, Join::Intersect);
|
|
assert_eq!(layer.parts()[0].join, Join::Union, "the base is untouched");
|
|
}
|
|
|
|
/// TRACES: FR-DEV-19a
|
|
/// A part left out of the build comes back left out, and the file says so
|
|
/// under a word that cannot be confused with the layer's own switch.
|
|
#[test]
|
|
fn a_hidden_part_stays_hidden_across_a_round_trip() {
|
|
let mut graph = EditGraph::default_chain();
|
|
let mut layer = corrected("m1", Join::Subtract);
|
|
layer.part_mut(1).expect("the correction").hidden = true;
|
|
graph.masks_mut().push(layer);
|
|
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(Version::from_graph("default", "Default", &graph));
|
|
let text = sidecar.to_text();
|
|
assert!(text.contains("hidden = 1"), "{text}");
|
|
assert!(
|
|
!text.contains("enabled = 0"),
|
|
"a hidden part must not read as a disabled layer:\n{text}"
|
|
);
|
|
|
|
let restored = round_trip(&graph);
|
|
let layer = &restored.masks().layers()[0];
|
|
assert!(layer.parts()[1].hidden, "the correction is still out");
|
|
assert!(!layer.parts()[0].hidden, "and the base is still in");
|
|
assert!(layer.enabled, "and the layer itself was never switched off");
|
|
}
|
|
|
|
/// TRACES: FR-DEV-19a
|
|
/// The base is a part like any other for this: it can be left out, and the
|
|
/// key lands in the mask block since that is where the base is written.
|
|
#[test]
|
|
fn a_hidden_base_is_written_into_the_mask_block() {
|
|
let mut graph = EditGraph::default_chain();
|
|
let mut layer = corrected("m1", Join::Union);
|
|
layer.base_mut().hidden = true;
|
|
graph.masks_mut().push(layer);
|
|
|
|
let restored = round_trip(&graph);
|
|
let layer = &restored.masks().layers()[0];
|
|
assert!(layer.parts()[0].hidden, "the base came back hidden");
|
|
assert!(!layer.parts()[1].hidden, "and only the base");
|
|
}
|
|
|
|
/// A part carries its own edge, which is the whole reason it is a part rather
|
|
/// than a second source on the layer: a model's soft coverage and a stroke
|
|
/// painted where it stopped short want different boundaries.
|
|
#[test]
|
|
fn each_part_keeps_its_own_shaping() {
|
|
let mut graph = EditGraph::default_chain();
|
|
let mut layer = corrected("m1", Join::Union);
|
|
layer.base_mut().feather = 0.02;
|
|
layer.base_mut().morphology = Morphology::Dilate;
|
|
layer.base_mut().morph_radius = 0.01;
|
|
layer.parts_mut()[1].feather = 0.0;
|
|
layer.parts_mut()[1].invert = true;
|
|
graph.masks_mut().push(layer);
|
|
|
|
let restored = round_trip(&graph);
|
|
let layer = &restored.masks().layers()[0];
|
|
|
|
assert_eq!(layer.parts()[0].feather, 0.02);
|
|
assert_eq!(layer.parts()[0].morphology, Morphology::Dilate);
|
|
assert_eq!(layer.parts()[1].feather, 0.0);
|
|
assert!(layer.parts()[1].invert);
|
|
assert!(
|
|
!layer.invert,
|
|
"the layer's own inversion is a different one"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn writing_a_corrected_mask_twice_is_byte_identical() {
|
|
let mut graph = EditGraph::default_chain();
|
|
graph.masks_mut().push(corrected("m1", Join::Subtract));
|
|
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(Version::from_graph("default", "Default", &graph));
|
|
let first = sidecar.to_text();
|
|
let second = Sidecar::parse(&first).expect("reparse").to_text();
|
|
|
|
assert_eq!(first, second);
|
|
}
|
|
|
|
/// A part block that names another mask is somebody's correction attached to
|
|
/// the wrong photograph if it is honoured. Dropped — and, more importantly,
|
|
/// its keys must not fall through to the version, where a `stroke` line would
|
|
/// be a global edit nobody made.
|
|
#[test]
|
|
fn a_part_naming_a_different_mask_is_dropped() {
|
|
let text = "drsc 1\n\
|
|
\n[version default]\n\
|
|
name = Default\n\
|
|
exposure.exposure = 0.25\n\
|
|
\n[mask default m1]\n\
|
|
source = brush\n\
|
|
exposure.exposure = 1\n\
|
|
\n[part default m9 p2]\n\
|
|
join = subtract\n\
|
|
source = brush\n\
|
|
stroke = add 0.05 0.5 1 0.2,0.2 0.3,0.3\n";
|
|
|
|
let parsed = Sidecar::parse(text).expect("parse");
|
|
let mut graph = EditGraph::default_chain();
|
|
parsed
|
|
.versions
|
|
.get("default")
|
|
.expect("version")
|
|
.apply(&mut graph)
|
|
.expect_no_film();
|
|
|
|
let layer = &graph.masks().layers()[0];
|
|
assert_eq!(layer.parts().len(), 1, "the orphan part was not attached");
|
|
assert_eq!(
|
|
graph.param(OpId("exposure"), ParamId("exposure")),
|
|
Some(0.25),
|
|
"and its keys did not land on the version"
|
|
);
|
|
}
|
|
|
|
/// The rule a malformed stroke already follows, one level up: a part this
|
|
/// build cannot read costs that part, and the rest of the mask — which is work
|
|
/// somebody did — survives.
|
|
#[test]
|
|
fn a_part_with_an_unknown_source_costs_only_that_part() {
|
|
let text = "drsc 1\n\
|
|
\n[version default]\n\
|
|
name = Default\n\
|
|
\n[mask default m1]\n\
|
|
source = brush\n\
|
|
stroke = add 0.05 0.5 1 0.1,0.1 0.2,0.2\n\
|
|
exposure.exposure = 1\n\
|
|
\n[part default m1 p2]\n\
|
|
join = union\n\
|
|
source = hologram\n";
|
|
|
|
let parsed = Sidecar::parse(text).expect("parse");
|
|
let mut graph = EditGraph::default_chain();
|
|
parsed
|
|
.versions
|
|
.get("default")
|
|
.expect("version")
|
|
.apply(&mut graph)
|
|
.expect_no_film();
|
|
|
|
let layer = &graph.masks().layers()[0];
|
|
assert_eq!(layer.parts().len(), 1);
|
|
assert_eq!(layer.strokes().len(), 1, "the base kept its stroke");
|
|
}
|
|
|
|
/// An unknown join adds, rather than dropping the part. A selection that is
|
|
/// visible can be fixed by the photographer; one that quietly took pixels away
|
|
/// under a name this build could not read cannot be.
|
|
#[test]
|
|
fn an_unknown_join_adds_the_part() {
|
|
let text = "drsc 1\n\
|
|
\n[version default]\n\
|
|
name = Default\n\
|
|
\n[mask default m1]\n\
|
|
source = brush\n\
|
|
exposure.exposure = 1\n\
|
|
\n[part default m1 p2]\n\
|
|
join = xor\n\
|
|
source = brush\n\
|
|
stroke = add 0.05 0.5 1 0.2,0.2 0.3,0.3\n";
|
|
|
|
let parsed = Sidecar::parse(text).expect("parse");
|
|
let mut graph = EditGraph::default_chain();
|
|
parsed
|
|
.versions
|
|
.get("default")
|
|
.expect("version")
|
|
.apply(&mut graph)
|
|
.expect_no_film();
|
|
|
|
let layer = &graph.masks().layers()[0];
|
|
assert_eq!(layer.parts().len(), 2);
|
|
assert_eq!(layer.parts()[1].join, Join::Union);
|
|
}
|
|
|
|
/// Two devices, one correcting a mask and one leaving it alone: the correction
|
|
/// arrives, because a layer merges as a whole and the part travelled with it.
|
|
#[test]
|
|
fn a_correction_made_on_one_device_reaches_the_other() {
|
|
let base = version_with("default", 1, |g| {
|
|
g.masks_mut().push(lit("m1", &[1, 2], 0.5));
|
|
});
|
|
let mut ours = base.clone();
|
|
ours.revision = 2;
|
|
let mut theirs = base.clone();
|
|
theirs.revision = 3;
|
|
if let Some(layer) = theirs.masks.get_mut("m1") {
|
|
layer.push_part(MaskPart::painted("p2", Join::Subtract));
|
|
layer.begin_stroke(1, false, 0.05, 0.5, 1.0);
|
|
layer.extend_stroke(1, 0.2, 0.2);
|
|
layer.extend_stroke(1, 0.4, 0.4);
|
|
layer.end_stroke(1);
|
|
}
|
|
|
|
let conflicts = ours.merge(&theirs, Some(&base));
|
|
|
|
assert!(conflicts.is_empty(), "only one side changed the layer");
|
|
let layer = ours.masks.get("m1").expect("layer");
|
|
assert_eq!(layer.parts().len(), 2);
|
|
assert_eq!(layer.parts()[1].join, Join::Subtract);
|
|
}
|