`cargo fmt --check` is a required step and had drifted across 45 files. Most of it arrived this week: several operations were written in parallel worktrees and merged by hand, and a hand-merge resolves conflicts without ever running the formatter over the result. No behaviour changes — this is `cargo fmt --all` and nothing else, kept as its own commit so the next reader can skip it wholesale rather than search it for one that matters. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
618 lines
19 KiB
Rust
618 lines
19 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::ParamId;
|
|
use dr_pipeline::mask::{MaskLayer, MaskSource};
|
|
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);
|
|
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.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].source, graph.masks().layers()[0].source);
|
|
assert_eq!(layers[1].source, graph.masks().layers()[1].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.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);
|
|
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(*erase, *radius, 0.5, 1.0);
|
|
for &(x, y) in path {
|
|
layer.extend_stroke(x, y);
|
|
}
|
|
layer.end_stroke();
|
|
}
|
|
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].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.feather = 0.02;
|
|
subject.falloff = dr_pipeline::mask::Falloff::Gaussian;
|
|
subject.morphology = dr_pipeline::mask::Morphology::Dilate;
|
|
subject.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());
|
|
}
|