From df741a8a49941455a00c1b649303c7e3e7db235b Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 6 Sep 2026 19:52:32 +0200 Subject: [PATCH] Let one mask be built from more than one selection, and paint into it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mask the model draws arrives approximately right — stopping inside a shoulder, leaking into the hair — and FR-DEV-3's edge controls move the *whole* boundary, so no value of feather or dilation fixes two errors that go opposite ways. What fixes them is a second selection joined to the first, and a layer that held exactly one source had nowhere to put one. The brush the core has had all along was reachable from no control in the application. A layer is now an ordered list of parts. Each names a source and how it joins the mask before it — added to it, or taken out of it — and carries its own edge treatment, because a model's soft coverage and a stroke painted where it stopped short do not want the same feather. Invert and opacity stay on the layer, where the composed shader already reads them. The sidecar grows `[part]` blocks and nothing else. A layer of one part writes exactly the bytes it always did; a mask block with no part blocks after it reads back as one part; and a stroke, a join or a source this build cannot read costs that part rather than the layer. So every sidecar in every library still parses to the edit it always was. On the device the parts fold into the layer's one slice, so eight layers still cost eight channels: union is a `max` blend and subtraction is the erase blend the brush already used. A part is drawn into a scratch texture before it is joined, and that is not incidental — an erase stroke means a hole in *that part*, not a hole in the mask, and drawn straight onto the accumulator it would punch through the subject underneath. A layer of one part skips all of it and takes the path it always took. In the interface: a part list under the selected layer with a chip saying which way each joins, Add and Subtract beside it, a Select/Paint/Erase strip with the brush's size, hardness and flow, and a drag on the photograph that paints. Pressing Paint on a mask that cannot hold a stroke joins a part that can, rather than explaining that a subject is not a brush. A whole stroke is one step in the history. The edge controls now shape the part that is selected rather than the layer, which is the one behaviour change to an existing control: with a correction selected, the feather slider softens the correction and leaves the model's mask alone. --- core/dr-gpu/examples/local.rs | 16 +- core/dr-gpu/src/mask.rs | 499 +++++++++++--- core/dr-gpu/src/shaders/mask.wgsl | 42 ++ core/dr-gpu/tests/local_adjustments.rs | 195 +++++- core/dr-gpu/tests/masked_outputs.rs | 2 +- core/dr-pipeline/src/graph.rs | 24 +- core/dr-pipeline/src/mask.rs | 858 +++++++++++++++++++------ core/dr-pipeline/src/sidecar.rs | 366 ++++++++--- core/dr-pipeline/tests/mask_sidecar.rs | 363 +++++++++-- docs/gestures.md | 96 +-- docs/mask-editing.md | 30 +- docs/requirements.md | 22 + docs/traceability.md | 121 ++-- ui/dr-ui/src/develop.rs | 423 ++++++++++-- ui/dr-ui/src/gesture_book.rs | 7 + ui/dr-ui/src/labels.rs | 22 + ui/dr-ui/src/masks_ui.rs | 266 +++++++- ui/dr-ui/src/presets.rs | 4 +- ui/dr-ui/ui/app.slint | 102 ++- ui/dr-ui/ui/masks.slint | 194 ++++++ 20 files changed, 2998 insertions(+), 654 deletions(-) diff --git a/core/dr-gpu/examples/local.rs b/core/dr-gpu/examples/local.rs index 98a0592..64a53c3 100644 --- a/core/dr-gpu/examples/local.rs +++ b/core/dr-gpu/examples/local.rs @@ -161,7 +161,7 @@ fn main() { drain.set_param("saturation", ParamId("saturation"), -100.0); // A touch of feather, or the colour stops dead on the model's outline and // the eye goes straight to the edge instead of to the subject. - drain.feather = 0.02; + drain.base_mut().feather = 0.02; pop.push(drain); render_stack( @@ -183,14 +183,14 @@ fn main() { let mut brighter = subject_layer("m1", index, subject); brighter.set_param("exposure", ParamId("exposure"), 0.45); - brighter.feather = 0.015; + brighter.base_mut().feather = 0.015; lift.push(brighter); let mut darker = subject_layer("m2", index, subject); darker.invert = true; darker.set_param("exposure", ParamId("exposure"), -0.55); darker.set_param("saturation", ParamId("saturation"), -25.0); - darker.feather = 0.03; + darker.base_mut().feather = 0.03; lift.push(darker); render_stack( @@ -221,9 +221,9 @@ fn main() { let mut layer = subject_layer("m1", index, subject); layer.invert = true; layer.set_param("saturation", ParamId("saturation"), -100.0); - layer.feather = 0.004; - layer.morphology = morphology; - layer.morph_radius = radius; + layer.base_mut().feather = 0.004; + layer.base_mut().morphology = morphology; + layer.base_mut().morph_radius = radius; stack.push(layer); render_stack( @@ -307,14 +307,14 @@ fn render_stack( pw as usize, ph as usize, 128, - match layer.morphology { + match layer.base().morphology { Morphology::None => dr_segment::Morphology::None, Morphology::Dilate => dr_segment::Morphology::Dilate, Morphology::Erode => dr_segment::Morphology::Erode, Morphology::Close => dr_segment::Morphology::Close, Morphology::Open => dr_segment::Morphology::Open, }, - layer.morph_radius * pw.min(ph) as f32, + layer.base().morph_radius * pw.min(ph) as f32, ) .distance }) diff --git a/core/dr-gpu/src/mask.rs b/core/dr-gpu/src/mask.rs index 09884c6..7e0e001 100644 --- a/core/dr-gpu/src/mask.rs +++ b/core/dr-gpu/src/mask.rs @@ -41,7 +41,7 @@ //! `region_count`. The compaction is CPU-side and once per image, which is the //! same place and cadence the region adjacency graph is already built at. -use dr_pipeline::mask::{MaskSource, MaskStack, Stroke, MAX_LAYERS}; +use dr_pipeline::mask::{Join, MaskSource, MaskStack, Stroke, MAX_LAYERS}; use wgpu::util::DeviceExt; @@ -95,6 +95,12 @@ struct MaskParams { /// The same packing the generated adjust shader uses, so the two agree by /// construction rather than by inspection. as_shot_wb: [f32; 4], + /// Whether this part is turned over before it joins the mask. Read by the + /// combine pass and by nothing else — see `fs_combine`. + invert: u32, + /// A uniform buffer is a multiple of sixteen bytes, and the flag above + /// takes four of them. + _pad: [u32; 3], } /// One stroke, as `mask.wgsl`'s `StrokeHeader` expects it. @@ -383,6 +389,19 @@ pub struct MaskPass { /// `dst(1 - a)` to erase. brush_add: wgpu::RenderPipeline, brush_erase: wgpu::RenderPipeline, + /// Reads a part back out of [`Self::scratch`] and blends it into the + /// layer's slice. The set operation is the blend state, so these two are + /// one shader as well. + combine_layout: wgpu::BindGroupLayout, + combine_union: wgpu::RenderPipeline, + combine_subtract: wgpu::RenderPipeline, + /// Where a part is drawn before it is joined. + /// + /// One texture for the whole stack rather than one per layer, because + /// layers rasterise in sequence and a part is read back immediately after + /// it is drawn. Allocated the first time a layer has more than one part, + /// so a library of unedited masks never pays for it. + scratch: Option, array: Option, /// How many times the array texture has been (re)allocated. /// @@ -551,6 +570,88 @@ impl MaskPass { "mask-brush-add", blend_state(wgpu::BlendFactor::One, wgpu::BlendFactor::OneMinusSrc), ); + // The pipelines that join one part to the mask so far. The blend + // state is the set operation and the shader is the same three + // vertices either way — which is why adding a way to combine masks + // cost no shader arithmetic at all. + let combine_layout = + ctx.device + .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("mask-combine-bgl"), + entries: &[ + uniform_entry(0), + wgpu::BindGroupLayoutEntry { + binding: 7, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + // Loaded texel by texel at matching size, so + // there is nothing to filter and no sampler. + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + ], + }); + + let combine_pipeline_layout = + ctx.device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("mask-combine-layout"), + bind_group_layouts: &[Some(&combine_layout)], + immediate_size: 0, + }); + + let combine = |label, blend| { + ctx.device + .create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(&combine_pipeline_layout), + vertex: wgpu::VertexState { + module: &module, + entry_point: Some("vs"), + compilation_options: Default::default(), + buffers: &[], + }, + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some("fs_combine"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format: MaskArray::FORMAT, + blend: Some(blend), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }) + }; + + // `max`, not source-over: a union must not build up where two parts + // overlap. Two selections that both half-cover a pixel select it half + // — adding them would make the overlap of two soft edges harder than + // either, which is a seam exactly where a photographer joined two + // things to avoid one. + let combine_union = combine( + "mask-combine-union", + wgpu::BlendState { + color: MAX_BLEND, + alpha: MAX_BLEND, + }, + ); + // `dst * (1 - src)`, which is the erase blend one level up: what the + // mask had, minus what this part covers, in proportion to how much of + // it the part covers. + let combine_subtract = combine( + "mask-combine-subtract", + blend_state(wgpu::BlendFactor::Zero, wgpu::BlendFactor::OneMinusSrc), + ); + // The same, with the deposit thrown away: coverage is only ever taken // off what earlier strokes on this layer put down. There is no negative // coverage to accumulate, so erasing an unpainted layer is a no-op @@ -577,6 +678,10 @@ impl MaskPass { brush_layout, brush_add, brush_erase, + combine_layout, + combine_union, + combine_subtract, + scratch: None, array: None, allocations: 0, placeholder, @@ -617,77 +722,130 @@ impl MaskPass { }); for (slot, layer) in stack.active().enumerate().take(MAX_LAYERS) { - let field = match (&layer.source, labels) { - (MaskSource::Regions { .. }, None) => { - log::warn!( - "mask layer {} is a region mask with no segmentation loaded; skipping", - layer.id - ); - continue; - } - (MaskSource::Regions { .. }, Some(f)) => f, - (_, _) => &self.placeholder, - }; + // **The path a mask with one part takes is the path every mask + // took before parts existed**: drawn straight into the layer's + // slice, cleared by the draw itself. Nothing about an unedited + // library's rendering changes, and the scratch texture is never + // allocated for it. + // + // An inverted base is the exception, because turning a part over + // is done where it is read back rather than where it is drawn — + // a brush deposits dabs and cannot know what the rest of the + // frame is. See `fs_combine`. + let direct = layer.parts().len() == 1 && !layer.base().invert; + if !direct { + self.ensure_scratch(width, height)?; + } - // A subject layer whose instance is missing is skipped for the - // same reason a region layer without a segmentation is: an absent - // mask that defaults to "everything" would apply the adjustment to - // the whole photograph, which is a much louder failure than none. - // Indexed by *slot*, not by the instance the layer names: the - // fields are built per layer, in this same order, because two - // layers over one subject can carry different morphology. - let subject = match &layer.source { - // Category alongside Subject: both are model coverage turned - // into a distance field, both are built per layer in this same - // order, and leaving a category out of here is precisely the - // failure the comment above warns about — it binds the 1x1 - // placeholder, so the mask covers everything and the - // adjustment silently goes global. - MaskSource::Subject { .. } | MaskSource::Category { .. } => { - match subjects.filter(|s| slot < s.len()) { - Some(s) => (s, slot), - None => { - log::warn!("mask layer {} has no distance field; skipping", layer.id); - continue; + for (index, part) in layer.parts().iter().enumerate() { + let base = index == 0; + let field = match (&part.source, labels) { + (MaskSource::Regions { .. }, None) => { + log::warn!( + "mask layer {} is a region mask with no segmentation loaded; skipping", + layer.id + ); + if base { + break; + } + continue; + } + (MaskSource::Regions { .. }, Some(f)) => f, + (_, _) => &self.placeholder, + }; + + // A subject part whose instance is missing is skipped for the + // same reason a region part without a segmentation is: an + // absent mask that defaults to "everything" would apply the + // adjustment to the whole photograph, which is a much louder + // failure than none. + // + // Indexed by *slot*, not by the instance the part names: the + // fields are built per layer, in this same order, because two + // layers over one subject can carry different morphology. + // Which is also why only a base part can have one — a model + // part joined to a mask has no field built for it yet, and it + // is skipped rather than drawn against a placeholder that + // would cover the frame. + let subject = match &part.source { + // Category alongside Subject: both are model coverage + // turned into a distance field, both are built per layer + // in this same order, and leaving a category out of here + // is precisely the failure the comment above warns about — + // it binds the 1x1 placeholder, so the mask covers + // everything and the adjustment silently goes global. + MaskSource::Subject { .. } | MaskSource::Category { .. } => { + match subjects.filter(|s| base && slot < s.len()) { + Some(s) => (s, slot), + None => { + log::warn!( + "part {} of mask layer {} has no distance field; skipping", + part.id, + layer.id + ); + if base { + break; + } + continue; + } } } - } - _ => (&self.empty_subject, 0), - }; + _ => (&self.empty_subject, 0), + }; - // TRACES: FR-DEV-10 - // A range layer with no photograph bound is skipped rather than - // drawn against the placeholder, on exactly the rule the two - // cases above follow: an absent mask that defaults to "everything" - // takes a local adjustment global, which is a far quieter failure - // than a layer that visibly did not render. - let image = match (&layer.source, source) { - (s, None) if s.is_range() => { - log::warn!( - "mask layer {} selects a range with no image loaded; skipping", - layer.id - ); - continue; - } - (_, image) => image, - }; + // TRACES: FR-DEV-10 + // A range part with no photograph bound is skipped rather than + // drawn against the placeholder, on exactly the rule the two + // cases above follow: an absent mask that defaults to + // "everything" takes a local adjustment global, which is a far + // quieter failure than a part that visibly did not render. + let image = match (&part.source, source) { + (s, None) if s.is_range() => { + log::warn!( + "mask layer {} selects a range with no image loaded; skipping", + layer.id + ); + if base { + break; + } + continue; + } + (_, image) => image, + }; - let params = self.params(layer, field, image, width, height); - match &layer.source { - MaskSource::Brush { strokes } => { - self.draw_brush(&mut encoder, slot as u32, ¶ms, strokes, width, height) + let params = self.params(part, field, image, width, height); + let target = if direct { + self.slice_view(slot as u32) + } else { + self.scratch_view() + }; + + match &part.source { + MaskSource::Brush { strokes } => { + self.draw_brush(&mut encoder, &target, ¶ms, strokes, width, height) + } + _ => { + let selected = self.selection_buffer(part, field); + self.draw( + &mut encoder, + &target, + ¶ms, + field, + &selected, + subject, + image, + ); + } } - _ => { - let selected = self.selection_buffer(layer, field); - self.draw( - &mut encoder, - slot as u32, - ¶ms, - field, - &selected, - subject, - image, - ); + + if !direct { + // The first part joins a cleared slice, so it lands + // exactly as it was drawn whichever way it says it joins — + // there is nothing yet for a subtraction to take away + // from, and a mask that began by subtracting from nothing + // would render as empty however it was painted afterwards. + let join = if base { Join::Union } else { part.join }; + self.combine(&mut encoder, slot as u32, join, base, ¶ms); } } } @@ -708,7 +866,7 @@ impl MaskPass { fn params( &self, - layer: &dr_pipeline::mask::MaskLayer, + part: &dr_pipeline::mask::MaskPart, field: &LabelField, source: Option<&DemosaicedImage>, width: u32, @@ -758,9 +916,11 @@ impl MaskPass { [m[6], m[7], m[8], 0.0], ], as_shot_wb: [wb[0], wb[1], wb[2], if non_linear { 1.0 } else { 0.0 }], + invert: u32::from(part.invert), + _pad: [0; 3], }; - match &layer.source { + match &part.source { // `softness` carries the layer's feather. The model's coverage is // already a soft sigmoid, so zero means "use the edge the model // drew" rather than "hard edge" — the one place in this shader @@ -780,12 +940,12 @@ impl MaskPass { MaskParams { mode: MODE_SUBJECT, // `softness` is the feather half-width in pixels. - softness: (layer.feather * short).max(0.0), + softness: (part.feather * short).max(0.0), // `angle` carries the morphology offset — reused rather // than padded, since a subject layer has no ellipse to // rotate. - angle: morph_offset(layer) * short, - falloff: falloff_code(layer.falloff), + angle: morph_offset(part) * short, + falloff: falloff_code(part.falloff), ..base } } @@ -871,7 +1031,7 @@ impl MaskPass { fn draw_brush( &self, encoder: &mut wgpu::CommandEncoder, - slot: u32, + target: &wgpu::TextureView, params: &MaskParams, strokes: &[Stroke], width: u32, @@ -933,19 +1093,10 @@ impl MaskPass { }) }); - let array = self.array.as_ref().expect("array ensured by caller"); - let view = array.texture.create_view(&wgpu::TextureViewDescriptor { - label: Some("mask-slice"), - dimension: Some(wgpu::TextureViewDimension::D2), - base_array_layer: slot, - array_layer_count: Some(1), - ..Default::default() - }); - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("mask-brush-pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &view, + view: target, depth_slice: None, resolve_target: None, ops: wgpu::Operations { @@ -991,11 +1142,11 @@ impl MaskPass { /// One byte-flag per region, or a single zero for a non-region layer. fn selection_buffer( &self, - layer: &dr_pipeline::mask::MaskLayer, + part: &dr_pipeline::mask::MaskPart, field: &LabelField, ) -> wgpu::Buffer { let mut flags = vec![0u32; field.region_count.max(1) as usize]; - if let MaskSource::Regions { ids, .. } = &layer.source { + if let MaskSource::Regions { ids, .. } = &part.source { for &id in ids { if let Some(slot) = flags.get_mut(id as usize) { *slot = 1; @@ -1016,7 +1167,7 @@ impl MaskPass { fn draw( &self, encoder: &mut wgpu::CommandEncoder, - slot: u32, + target: &wgpu::TextureView, params: &MaskParams, field: &LabelField, selected: &wgpu::Buffer, @@ -1072,19 +1223,10 @@ impl MaskPass { // The array slice is selected by the attachment rather than by a // uniform the shader reads — one fewer value that can disagree with // where the pass actually writes. - let array = self.array.as_ref().expect("array ensured by caller"); - let view = array.texture.create_view(&wgpu::TextureViewDescriptor { - label: Some("mask-slice"), - dimension: Some(wgpu::TextureViewDimension::D2), - base_array_layer: slot, - array_layer_count: Some(1), - ..Default::default() - }); - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("mask-pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &view, + view: target, depth_slice: None, resolve_target: None, ops: wgpu::Operations { @@ -1105,6 +1247,140 @@ impl MaskPass { pass.draw(0..3, 0..1); } + /// A view of one layer's slice of the array. + fn slice_view(&self, slot: u32) -> wgpu::TextureView { + // The array slice is selected by the attachment rather than by a + // uniform the shader reads — one fewer value that can disagree with + // where the pass actually writes. + let array = self.array.as_ref().expect("array ensured by caller"); + array.texture.create_view(&wgpu::TextureViewDescriptor { + label: Some("mask-slice"), + dimension: Some(wgpu::TextureViewDimension::D2), + base_array_layer: slot, + array_layer_count: Some(1), + ..Default::default() + }) + } + + fn scratch_view(&self) -> wgpu::TextureView { + self.scratch + .as_ref() + .expect("scratch ensured by caller") + .texture + .create_view(&wgpu::TextureViewDescriptor { + label: Some("mask-part"), + ..Default::default() + }) + } + + /// Blend the part sitting in [`Self::scratch`] into a layer's slice. + /// + /// `first` clears the slice instead of loading it, which is both cheaper + /// on a tiler and the only thing that makes the fold start from nothing + /// covered rather than from whatever the last rasterisation left. + fn combine( + &self, + encoder: &mut wgpu::CommandEncoder, + slot: u32, + join: Join, + first: bool, + params: &MaskParams, + ) { + let params_buf = self + .ctx + .device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("mask-combine-params"), + contents: bytemuck::bytes_of(params), + usage: wgpu::BufferUsages::UNIFORM, + }); + + let bind_group = self + .ctx + .device + .create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("mask-combine-bind"), + layout: &self.combine_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: params_buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::TextureView(&self.scratch_view()), + }, + ], + }); + + let target = self.slice_view(slot); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("mask-combine-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &target, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: if first { + wgpu::LoadOp::Clear(wgpu::Color::BLACK) + } else { + wgpu::LoadOp::Load + }, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + + pass.set_pipeline(match join { + Join::Union => &self.combine_union, + Join::Subtract => &self.combine_subtract, + }); + pass.set_bind_group(0, &bind_group, &[]); + pass.draw(0..3, 0..1); + } + + /// The texture a part is drawn in before it is joined. + /// + /// Allocated on the first mask that has more than one part and kept at the + /// rasterisation size, which is the same size the array is: a part and the + /// slice it joins are compared texel for texel, so there is nothing to + /// scale and nothing to sample between. + fn ensure_scratch(&mut self, width: u32, height: u32) -> Result<(), GpuError> { + if self + .scratch + .as_ref() + .is_some_and(|s| s.width == width && s.height == height) + { + return Ok(()); + } + + let texture = self.ctx.device.create_texture(&wgpu::TextureDescriptor { + label: Some("mask-scratch"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: MaskArray::FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + + self.scratch = Some(Scratch { + texture, + width, + height, + }); + Ok(()) + } + fn ensure_array(&mut self, width: u32, height: u32, layers: u32) -> Result<(), GpuError> { if self .array @@ -1147,6 +1423,25 @@ impl MaskPass { } } +/// The texture one part is drawn into on its way into a layer's slice. +struct Scratch { + texture: wgpu::Texture, + width: u32, + height: u32, +} + +/// `max(dst, src)` — the union of two parts. +/// +/// Not source-over, which would build up: two parts that each half-cover a +/// pixel select it half, and adding them would make the overlap of two soft +/// edges harder than either of them, drawing a seam exactly where a +/// photographer joined two selections to avoid one. +const MAX_BLEND: wgpu::BlendComponent = wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Max, +}; + /// TRACES: FR-DEV-10 /// A single black texel, bound at the image slot for a mask that is not a /// range. @@ -1192,11 +1487,11 @@ fn field_short_edge(width: u32, height: u32) -> f32 { /// Zero for closing and opening: those are folded into the field itself when /// it is built, because their second half acts on a shape the original field /// does not describe. -fn morph_offset(layer: &dr_pipeline::mask::MaskLayer) -> f32 { +fn morph_offset(part: &dr_pipeline::mask::MaskPart) -> f32 { use dr_pipeline::mask::Morphology; - match layer.morphology { - Morphology::Dilate => layer.morph_radius, - Morphology::Erode => -layer.morph_radius, + match part.morphology { + Morphology::Dilate => part.morph_radius, + Morphology::Erode => -part.morph_radius, Morphology::None | Morphology::Close | Morphology::Open => 0.0, } } @@ -1362,11 +1657,11 @@ mod tests { fn painted(gestures: &[Gesture]) -> MaskLayer { let mut layer = lit(MaskSource::brush()); for (erase, radius, path) in gestures { - layer.begin_stroke(*erase, *radius, 0.5, 1.0); + layer.begin_stroke(0, *erase, *radius, 0.5, 1.0); for &(x, y) in path { - layer.extend_stroke(x, y); + layer.extend_stroke(0, x, y); } - layer.end_stroke(); + layer.end_stroke(0); } layer } diff --git a/core/dr-gpu/src/shaders/mask.wgsl b/core/dr-gpu/src/shaders/mask.wgsl index 3cfc202..fee7b20 100644 --- a/core/dr-gpu/src/shaders/mask.wgsl +++ b/core/dr-gpu/src/shaders/mask.wgsl @@ -78,6 +78,22 @@ struct MaskParams { // rgb: as-shot white balance. w: non-zero when the source arrived // gamma-encoded rather than linear. as_shot_wb: vec4, + + // Whether this part is turned over before it joins the mask. + // + // Read by `fs_combine` and by nothing else, deliberately. A brush deposits + // dabs onto an empty field and has no idea what the rest of the frame is, + // so a stroke shader cannot invert anything; doing it where the finished + // part is read back is the one place that works for every kind of source. + invert: u32, + // Three scalars rather than a `vec3`: a three-component vector is + // aligned to sixteen bytes in the uniform address space, so it would sit + // at offset 144 and make this struct 160 bytes against the Rust side's + // 144 — a mismatch wgpu reports as a binding too small for the shader, + // several layers away from the padding that caused it. + _pad0: u32, + _pad1: u32, + _pad2: u32, } @group(0) @binding(0) var p: MaskParams; @@ -597,3 +613,29 @@ fn fs_brush(in: BrushVertex) -> @location(0) vec4 { return vec4(clamp(coverage * s.flow, 0.0, 1.0), 0.0, 0.0, 1.0); } + +// --------------------------------------------------------------------------- +// Joining one part to the mask so far +// --------------------------------------------------------------------------- +// +// A layer's mask is a fold over its parts, and the set operation is the *blend +// state* rather than arithmetic here: union is `max(dst, src)`, subtraction is +// `dst * (1 - src)`. Both are fixed-function, so joining a part costs one +// full-screen draw and no second texture beyond the one being read. +// +// # Why a part is drawn aside first, rather than straight onto the mask +// +// Because an erase stroke inside a part means "a hole in *this* part", not "a +// hole in the mask". Painted straight onto the accumulator it would take away +// whatever the parts before it had put there — so a correction that tidied its +// own edge would punch through the subject underneath, and the failure would +// look like the model's mask had holes in it. + +@group(0) @binding(7) var part_mask: texture_2d; + +@fragment +fn fs_combine(@builtin(position) pos: vec4) -> @location(0) vec4 { + let v = textureLoad(part_mask, vec2(i32(pos.x), i32(pos.y)), 0).r; + let m = select(v, 1.0 - v, p.invert != 0u); + return vec4(clamp(m, 0.0, 1.0), 0.0, 0.0, 1.0); +} diff --git a/core/dr-gpu/tests/local_adjustments.rs b/core/dr-gpu/tests/local_adjustments.rs index ec9fa39..923452b 100644 --- a/core/dr-gpu/tests/local_adjustments.rs +++ b/core/dr-gpu/tests/local_adjustments.rs @@ -12,7 +12,7 @@ use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, LabelField, MaskPass}; use dr_pipeline::descriptor::ParamId; -use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack}; +use dr_pipeline::mask::{Join, MaskLayer, MaskPart, MaskSource, MaskStack}; use dr_pipeline::operation::compose_full; use dr_pipeline::spot::SpotSet; use dr_pipeline::{ops, EditGraph, Framing}; @@ -286,11 +286,11 @@ type Gesture = (bool, f32, f32, Vec<(f32, f32)>); fn painted(gestures: &[Gesture]) -> MaskLayer { let mut layer = brighten(MaskSource::brush()); for (erase, radius, flow, path) in gestures { - layer.begin_stroke(*erase, *radius, 0.9, *flow); + layer.begin_stroke(0, *erase, *radius, 0.9, *flow); for &(x, y) in path { - layer.extend_stroke(x, y); + layer.extend_stroke(0, x, y); } - layer.end_stroke(); + layer.end_stroke(0); } layer } @@ -505,9 +505,9 @@ fn hardness_decides_how_quickly_the_edge_falls_away() { let edge = |hardness: f32| { let mut layer = brighten(MaskSource::brush()); - layer.begin_stroke(false, 0.4, hardness, 1.0); - layer.extend_stroke(0.5, 0.5); - layer.end_stroke(); + layer.begin_stroke(0, false, 0.4, hardness, 1.0); + layer.extend_stroke(0, 0.5, 0.5); + layer.end_stroke(0); let pixels = render(&ctx, &stack_of(layer), None); // How many pixels along the centre row are neither fully painted nor @@ -608,3 +608,184 @@ fn an_empty_stack_renders_exactly_as_the_unmasked_path() { let masked = render(&ctx, &MaskStack::new(), None); assert_eq!(plain, masked, "an empty mask stack must be a no-op"); } + +// --------------------------------------------------------------------------- +// Parts — a mask built from more than one selection +// --------------------------------------------------------------------------- + +/// Everything, so a part joined to it has something to change. +fn whole_frame() -> MaskSource { + MaskSource::Regions { + signature: 1, + level: 2, + ids: vec![0, 1], + } +} + +/// Paint one gesture into a part of a layer, at a radius large enough that a +/// 32-pixel frame can tell where it landed. +fn paint(layer: &mut MaskLayer, part: usize, erase: bool, path: &[(f32, f32)]) { + assert!( + layer.begin_stroke(part, erase, 0.2, 1.0, 1.0), + "the layer had no room for a stroke" + ); + for &(x, y) in path { + layer.extend_stroke(part, x, y); + } + layer.end_stroke(part); +} + +#[test] +fn a_union_part_adds_what_the_base_did_not_cover() { + let Some(ctx) = ctx() else { + eprintln!("no adapter; skipping"); + return; + }; + + let mut layer = brighten(MaskSource::Regions { + signature: 1, + level: 2, + ids: vec![0], + }); + assert!(layer.push_part(MaskPart::painted("p2", Join::Union))); + paint(&mut layer, 1, false, &[(0.85, 0.5)]); + + let mut stack = MaskStack::new(); + stack.push(layer); + let pixels = render(&ctx, &stack, Some(&split_field(&ctx))); + + assert!( + luma_at(&pixels, 4, 16) > 200, + "the base region is still masked" + ); + assert!( + luma_at(&pixels, 27, 16) > 200, + "and the painted part joined the other half in" + ); + assert_eq!( + luma_at(&pixels, 20, 2), + 128, + "while what neither covers is untouched" + ); +} + +#[test] +fn a_subtract_part_takes_a_bite_out_of_the_mask() { + let Some(ctx) = ctx() else { + eprintln!("no adapter; skipping"); + return; + }; + + let mut layer = brighten(whole_frame()); + assert!(layer.push_part(MaskPart::painted("p2", Join::Subtract))); + paint(&mut layer, 1, false, &[(0.5, 0.5)]); + + let mut stack = MaskStack::new(); + stack.push(layer); + let pixels = render(&ctx, &stack, Some(&split_field(&ctx))); + + assert_eq!( + luma_at(&pixels, 16, 16), + 128, + "the middle was taken back out of the mask" + ); + assert!( + luma_at(&pixels, 1, 1) > 200, + "and the corner the stroke never reached is still in it" + ); +} + +/// **The test the scratch texture exists for.** An erase stroke means a hole in +/// the part it was painted into, not a hole in the mask: drawn straight onto +/// the accumulator it would take away whatever the parts before it had put +/// there, so tidying the edge of a correction would punch through the subject +/// underneath — and the failure would read as the model's mask having holes. +#[test] +fn an_erase_stroke_holes_its_own_part_and_not_the_mask() { + let Some(ctx) = ctx() else { + eprintln!("no adapter; skipping"); + return; + }; + + let mut layer = brighten(whole_frame()); + assert!(layer.push_part(MaskPart::painted("p2", Join::Union))); + paint(&mut layer, 1, false, &[(0.5, 0.5)]); + paint(&mut layer, 1, true, &[(0.5, 0.5)]); + + let mut stack = MaskStack::new(); + stack.push(layer); + let pixels = render(&ctx, &stack, Some(&split_field(&ctx))); + + assert!( + luma_at(&pixels, 16, 16) > 200, + "the base still covers where the correction erased itself" + ); +} + +#[test] +fn an_inverted_part_joins_everything_it_did_not_paint() { + let Some(ctx) = ctx() else { + eprintln!("no adapter; skipping"); + return; + }; + + // A base that covers nothing, so what shows is the part alone. + let mut layer = brighten(MaskSource::brush()); + paint(&mut layer, 0, false, &[(0.1, 0.1)]); + assert!(layer.push_part(MaskPart::painted("p2", Join::Union))); + layer.parts_mut()[1].invert = true; + paint(&mut layer, 1, false, &[(0.5, 0.5)]); + + let mut stack = MaskStack::new(); + stack.push(layer); + let pixels = render(&ctx, &stack, Some(&split_field(&ctx))); + + assert_eq!( + luma_at(&pixels, 16, 16), + 128, + "where the inverted part was painted is outside the mask" + ); + assert!( + luma_at(&pixels, 30, 30) > 200, + "and everywhere it was not is inside it" + ); +} + +/// Parts fold in order, so the same two selections joined the other way round +/// are a different mask. A subtraction that lands before the part it was meant +/// to cut into would take away nothing at all. +#[test] +fn the_order_parts_are_joined_in_is_the_mask() { + let Some(ctx) = ctx() else { + eprintln!("no adapter; skipping"); + return; + }; + + let render_pair = |cut_first: bool| { + let mut layer = brighten(MaskSource::brush()); + if cut_first { + layer.push_part(MaskPart::painted("p2", Join::Subtract)); + layer.push_part(MaskPart::painted("p3", Join::Union)); + paint(&mut layer, 1, false, &[(0.5, 0.5)]); + paint(&mut layer, 2, false, &[(0.5, 0.5)]); + } else { + layer.push_part(MaskPart::painted("p2", Join::Union)); + layer.push_part(MaskPart::painted("p3", Join::Subtract)); + paint(&mut layer, 1, false, &[(0.5, 0.5)]); + paint(&mut layer, 2, false, &[(0.5, 0.5)]); + } + let mut stack = MaskStack::new(); + stack.push(layer); + render(&ctx, &stack, Some(&split_field(&ctx))) + }; + + assert!( + luma_at(&render_pair(true), 16, 16) > 200, + "adding after a subtraction leaves the addition standing" + ); + assert_eq!( + luma_at(&render_pair(false), 16, 16), + 128, + "and subtracting after an addition takes it away again" + ); +} diff --git a/core/dr-gpu/tests/masked_outputs.rs b/core/dr-gpu/tests/masked_outputs.rs index 41093bb..62695ce 100644 --- a/core/dr-gpu/tests/masked_outputs.rs +++ b/core/dr-gpu/tests/masked_outputs.rs @@ -51,7 +51,7 @@ fn brightening_stack() -> MaskStack { }, ); layer.set_param("exposure", ParamId("exposure"), 2.0); - layer.feather = 0.0; + layer.base_mut().feather = 0.0; stack.push(layer); stack } diff --git a/core/dr-pipeline/src/graph.rs b/core/dr-pipeline/src/graph.rs index 2343224..3f5659c 100644 --- a/core/dr-pipeline/src/graph.rs +++ b/core/dr-pipeline/src/graph.rs @@ -989,12 +989,26 @@ impl EditGraph { // out of step the first time a variant gains a field — silently, // and showing as a mask that stops updating. `Debug` cannot fall // out of step, because it is derived from the definition itself. - colour = hash_bytes(colour, format!("{:?}", layer.source).as_bytes()); colour = mix(colour, u64::from(layer.enabled)); colour = mix(colour, u64::from(layer.invert)); - colour = hash_bytes(colour, layer.falloff.name().as_bytes()); - for v in [layer.opacity, layer.feather, layer.morph_radius] { - colour = mix(colour, u64::from(crate::operation::canonical_bits(v))); + colour = mix( + colour, + u64::from(crate::operation::canonical_bits(layer.opacity)), + ); + // Every part, in order, because the mask is the fold over them: a + // part added, removed, reshaped or joined the other way round is a + // different shape even when nothing else moved. The order is + // folded in by construction — the same parts joined the other way + // round hash differently because they arrive in the other order. + for part in layer.parts() { + colour = hash_bytes(colour, part.id.as_bytes()); + colour = hash_bytes(colour, part.join.name().as_bytes()); + colour = hash_bytes(colour, format!("{:?}", part.source).as_bytes()); + colour = mix(colour, u64::from(part.invert)); + colour = hash_bytes(colour, part.falloff.name().as_bytes()); + for v in [part.feather, part.morph_radius] { + colour = mix(colour, u64::from(crate::operation::canonical_bits(v))); + } } for (op_id, param_id, value) in layer.params() { colour = hash_bytes(colour, op_id.as_bytes()); @@ -1795,7 +1809,7 @@ mod tests { // Moving the gradient is a different mask, so a different result. if let Some(layer) = g.masks_mut().get_mut("l1") { - layer.source = MaskSource::Linear { + layer.base_mut().source = MaskSource::Linear { centre: (0.2, 0.7), angle: 0.4, width: 0.2, diff --git a/core/dr-pipeline/src/mask.rs b/core/dr-pipeline/src/mask.rs index 454bbd7..fef91bb 100644 --- a/core/dr-pipeline/src/mask.rs +++ b/core/dr-pipeline/src/mask.rs @@ -890,19 +890,100 @@ impl MaskSource { } } -/// One local adjustment: a rule about *where*, plus a chain saying *what*. -pub struct MaskLayer { - /// Stable identity, for the sidecar and for merge (FR-NC-9). +/// The most parts one layer's mask may be built from. +/// +/// Not for the reason [`MAX_LAYERS`] exists: parts cost no VRAM, since they +/// all fold into the one slice their layer owns. What bounds them is that each +/// is a draw over the frame, and that a selection which took nine steps to +/// describe cannot be read back off a panel by the person who made it. +pub const MAX_PARTS: usize = 8; + +/// The id a layer's first part is created with. +/// +/// Named because two places have to agree about it: a layer built here, and a +/// sidecar block that carries no part of its own and is therefore read as one +/// (see [`crate::sidecar`]). A literal in both would eventually be a literal +/// in one. +pub const FIRST_PART_ID: &str = "p1"; + +/// How a part joins the mask built before it. +/// +/// The first part of a layer has nothing to join to, so its value is ignored +/// rather than special-cased away: a part is one shape everywhere, and the +/// fold that builds a mask starts from nothing covered. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Join { + /// Everything either covers. "Add to this mask" — which is what a hand + /// correction over a model's guess nearly always is. + #[default] + Union, + /// What the mask had, minus this. "Take this out of it." + /// + /// Not the same control as an erase stroke, though the two overlap where + /// both are painted. A subtracted part can be switched off, reshaped or + /// removed a week later, and it says on the panel what it took away; an + /// erase stroke is a hole in the part it was painted into and reads as + /// nothing at all. + Subtract, +} + +impl Join { + pub fn name(self) -> &'static str { + match self { + Self::Union => "union", + Self::Subtract => "subtract", + } + } + + pub fn from_name(name: &str) -> Option { + Some(match name { + "union" => Self::Union, + "subtract" => Self::Subtract, + _ => return None, + }) + } + + /// Every variant, for a UI building a choice control. + pub const ALL: [Join; 2] = [Join::Union, Join::Subtract]; +} + +/// One selection inside a layer's mask. +/// +/// # Why a layer is a list of these and not a source +/// +/// A mask made of exactly one selection is the case a model never quite +/// produces. Its coverage arrives approximately right — stopping inside a +/// shoulder, leaking into the hair — and the controls that shape it move the +/// *whole* boundary, so no value of [`Self::feather`] or [`Self::morphology`] +/// fixes two errors that go opposite ways. What fixes them is a second +/// selection joined to the first: a stroke painted where the model stopped +/// short, a shape cut out where it leaked. +/// +/// The shaping therefore lives here rather than on the layer. Two parts of one +/// mask routinely want different edges — a model's soft coverage joined to a +/// correction that must be exactly where the finger went — and one feather +/// over both is the same failure one boundary further out. +/// +/// [`MaskLayer::invert`] and [`MaskLayer::opacity`] stay on the layer. They +/// are the two uniforms the composed shader reads and they apply to the +/// mask every part has finished building, which is a different question from +/// how one part enters it. +#[derive(Clone)] +pub struct MaskPart { + /// Stable identity **within its layer**, for the sidecar and for merge + /// (FR-NC-9). Two devices that each add a part to the same layer choose + /// different ids, so the merge keeps both rather than arbitrating between + /// them. pub id: String, - /// What the user called it. Empty means "name me after my source". - pub name: String, + /// How this part joins the mask built so far. Ignored on the first part. + pub join: Join, pub source: MaskSource, - /// Swap inside for outside. + /// Swap this part's inside for its outside, before it is joined. + /// + /// Distinct from [`MaskLayer::invert`], and both are wanted: inverting a + /// subtracted gradient keeps everything to one side of a line, where + /// inverting the layer keeps everything the layer did not select. pub invert: bool, - /// Global strength of the layer, `0.0..=1.0`. - pub opacity: f32, - /// Off without being deleted — the A/B a local edit is always wanting. - pub enabled: bool, /// Half-width of the edge transition, as a fraction of the frame's /// **shorter edge**. @@ -915,7 +996,7 @@ pub struct MaskLayer { pub feather: f32, pub falloff: Falloff, - /// Grow, shrink or tidy the mask before the feather is applied. + /// Grow, shrink or tidy this part before its feather is applied. /// /// Before, and it matters: dilating a *feathered* mask would push the /// half-way point outward and soften it further, so the two controls would @@ -937,13 +1018,13 @@ pub struct MaskLayer { /// `dr_segment::refine` can state where a flag falls on it and where a /// cloud does. Bounded by [`MAX_REFINE`]. /// - /// This is shaping, like [`Self::feather`], and it lives on the layer for - /// the same reason: two layers may sit on the same category and want + /// This is shaping, like [`Self::feather`], and it lives on the part for + /// the same reason: two parts may sit on the same category and want /// different amounts of it, and the model ran once for both. pub refine: f32, - /// The pixels this layer covered, when a model produced them and they - /// were worth storing. + /// The pixels this part covered, when a model produced them and they were + /// worth storing. /// /// # Beside the source, not inside it /// @@ -956,25 +1037,277 @@ pub struct MaskLayer { /// /// So this sits alongside as what it actually is: a **materialisation** of /// the source, produced by a run of a model this crate has never heard of - /// and knows nothing about. `MaskSource` still says what the layer means; + /// and knows nothing about. `MaskSource` still says what the part means; /// this says what that meant last time anybody worked it out. The - /// distinction is why it takes no part in [`PartialEq`] — a layer with the + /// distinction is why it takes no part in [`PartialEq`] — a part with the /// pixels cached and one without are the same edit, and a merge that /// called them different would raise a conflict over a cache. /// /// # Why it exists at all /// - /// Without it a stored subject or category layer renders as nothing until + /// Without it a stored subject or category renders as nothing until /// somebody presses "find subjects" — so reopening a photograph dropped /// its local adjustments, and a batch export, which never runs a model, /// could not have them at any point. See [`crate::coverage`]. /// /// Shared rather than owned because the undo stack holds a snapshot per - /// step and a layer is cloned by value; an `Arc` makes recording a slider + /// step and a part is cloned by value; an `Arc` makes recording a slider /// drag cost a refcount rather than a copy of every mask in the stack. /// Only ever set for the two model sources — nothing else has a model /// behind it to cache. pub coverage: Option>, +} + +impl MaskPart { + /// A new part over `source`, shaped the way a new layer always was. + pub fn new(id: impl Into, join: Join, source: MaskSource) -> Self { + Self { + id: id.into(), + join, + source, + invert: false, + // A small default rather than zero. A watershed boundary is exact + // to the pixel, and an adjustment that stops dead on one looks + // pasted on — the first thing anyone would reach for, so it is + // where the control starts. + feather: DEFAULT_FEATHER, + falloff: Falloff::default(), + morphology: Morphology::default(), + morph_radius: 0.0, + // Off, so a part built here is the model's own weighting. The + // useful starting point for a *category* is not zero, but it is + // `dr_segment`'s number to state and this crate does not depend on + // it — `Session::add_category_mask` sets it on the way in. + refine: 0.0, + // Nothing has run yet. Filled in the first time a model's coverage + // is turned into a distance field — see [`Self::coverage`]. + coverage: None, + } + } + + /// An empty painted part — a hand correction waiting for its first stroke. + pub fn painted(id: impl Into, join: Join) -> Self { + Self::new(id, join, MaskSource::brush()) + } + + /// Whether this part could cover any pixel at all. + /// + /// Only a brush can answer no, and it matters more than the draw it saves. + /// An unpainted part is empty, [`Self::invert`] turns empty into + /// everything, and a part created with invert already set would apply its + /// layer's adjustment to the whole photograph before a single stroke was + /// made — the loud, wrong-looking failure this codebase avoids everywhere + /// else a mask can go missing. + /// + /// A range answers yes, and it is worth saying why rather than leaving it + /// to the catch-all. A band *can* be empty on a particular photograph — a + /// highlight band over a picture with no highlights in it — but nothing + /// on this side could know that without rasterising the mask, which is the + /// one thing this module promises never to do. So the honest answer is + /// that the part selects whatever the picture has, and an empty result is + /// the picture's answer rather than the part's. + pub fn covers(&self) -> bool { + match &self.source { + MaskSource::Brush { strokes } => strokes.iter().any(|s| !s.erase && !s.is_empty()), + _ => true, + } + } + + /// Whether this part's ids belong to a different segmentation. + /// + /// Applying it anyway would produce a confidently wrong mask, so callers + /// should offer to recompute rather than render it. + pub fn is_stale(&self, current: u64) -> bool { + match self.source { + MaskSource::Regions { signature, .. } + | MaskSource::Subject { signature, .. } + | MaskSource::Category { signature, .. } => signature != current, + // A gradient is geometry in normalised coordinates. It means the + // same thing whatever was or was not detected, so nothing about a + // new run can invalidate it. Painted strokes are the same: they are + // where the user put them, not where a model thought something was. + // + // A range is the strongest case of all: it is a band over the + // photograph's own values, so it does not merely survive a new + // segmentation — there is nothing a model could ever say that + // would change which pixels it names. + MaskSource::Linear { .. } + | MaskSource::Radial { .. } + | MaskSource::Brush { .. } + | MaskSource::Luminance { .. } + | MaskSource::Colour { .. } => false, + } + } + + /// The strokes on this part, empty for any other kind of source. + pub fn strokes(&self) -> &[Stroke] { + self.source.strokes() + } + + /// How many stroke points this part is holding. + pub fn stroke_points(&self) -> usize { + self.strokes().iter().map(Stroke::len).sum() + } + + /// Start a stroke, returning whether there was room for it. + /// + /// `room` is how many points the *layer* has left ([`MAX_LAYER_POINTS`]), + /// passed in rather than counted here because the budget belongs to the + /// layer: a photographer who has painted four thousand points has painted + /// them across the whole mask, and a part that counted only its own would + /// let the layer past its limit one part at a time. + pub fn begin_stroke( + &mut self, + room: usize, + erase: bool, + radius: f32, + hardness: f32, + flow: f32, + ) -> bool { + let MaskSource::Brush { strokes } = &mut self.source else { + log::warn!( + "part {} is a {} mask, not a brush", + self.id, + self.source.kind() + ); + return false; + }; + if room == 0 { + log::warn!( + "this mask is full ({MAX_LAYER_POINTS} points); refusing to start another stroke" + ); + return false; + } + strokes.push(Stroke::new(erase, radius, hardness, flow)); + true + } + + /// Add a position to the stroke in progress, returning whether it was kept. + /// + /// A position may be dropped for being too close to the last one, which is + /// ordinary and not a failure. When the stroke in progress fills up the + /// gesture continues in a new one starting at the same point, so the swept + /// path has no gap in it — see [`MAX_STROKE_POINTS`]. + pub fn extend_stroke(&mut self, room: usize, x: f32, y: f32) -> bool { + let MaskSource::Brush { strokes } = &mut self.source else { + return false; + }; + let Some(current) = strokes.last_mut() else { + return false; + }; + if room == 0 { + return false; + } + + if current.is_full() { + // Simplified here rather than in `end_stroke`, which only ever sees + // the last stroke of a gesture: a continuation closes the one + // before it for good, and an unsimplified stroke would reach the + // sidecar at full sampling — the one place the saving matters most, + // since a gesture long enough to split is a long line of text. + current.simplify(); + let mut next = Stroke::new( + current.erase, + current.radius, + current.hardness, + current.flow, + ); + if let Some(&joint) = current.points.last() { + next.points.push(joint); + } + strokes.push(next); + } + + strokes + .last_mut() + .expect("a stroke was just ensured") + .push_point(x, y) + } + + /// Finish the stroke in progress, simplifying it. + /// + /// A stroke that recorded nothing is dropped rather than kept as an empty + /// one: a press with no movement still records its first point, so an empty + /// stroke can only be a press that never reached the model, and leaving it + /// would put a stroke in the sidecar that draws nothing. + pub fn end_stroke(&mut self) { + let MaskSource::Brush { strokes } = &mut self.source else { + return; + }; + match strokes.last_mut() { + Some(s) if s.is_empty() => { + strokes.pop(); + } + Some(s) => s.simplify(), + None => {} + } + } + + /// Remove the most recent stroke, returning it. + /// + /// The undo history already snapshots the whole graph, so this is not how + /// undo works — it is for the interaction layer to abandon a stroke it has + /// begun, when a gesture turns out to be a pinch or is cancelled. + pub fn drop_last_stroke(&mut self) -> Option { + match &mut self.source { + MaskSource::Brush { strokes } => strokes.pop(), + _ => None, + } + } +} + +impl std::fmt::Debug for MaskPart { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MaskPart") + .field("id", &self.id) + .field("join", &self.join) + .field("source", &self.source) + .field("invert", &self.invert) + .field("feather", &self.feather) + .field("falloff", &self.falloff) + .field("morphology", &self.morphology) + .field("coverage", &self.coverage.as_ref().map(|c| c.encoded_len())) + .finish() + } +} + +impl PartialEq for MaskPart { + /// Every field that is the *edit*, and deliberately not + /// [`Self::coverage`] — see the field for why a cached raster must not + /// count as a difference between two devices. + fn eq(&self, other: &Self) -> bool { + self.id == other.id + && self.join == other.join + && self.source == other.source + && self.invert == other.invert + && self.feather == other.feather + && self.falloff == other.falloff + && self.morphology == other.morphology + && self.morph_radius == other.morph_radius + } +} + +/// One local adjustment: a rule about *where*, plus a chain saying *what*. +pub struct MaskLayer { + /// Stable identity, for the sidecar and for merge (FR-NC-9). + pub id: String, + /// What the user called it. Empty means "name me after my source". + pub name: String, + /// Swap inside for outside, once every part has been joined. + pub invert: bool, + /// Global strength of the layer, `0.0..=1.0`. + pub opacity: f32, + /// Off without being deleted — the A/B a local edit is always wanting. + pub enabled: bool, + + /// What the mask is made of, in the order it is built. + /// + /// **Never empty.** `parts[0]` is the selection the layer began as, and + /// removing it is removing the layer — which is why this is private and + /// [`Self::remove_part`] refuses to. Everything else reaches it through + /// [`Self::base`], [`Self::parts`] and [`Self::push_part`], none of which + /// can leave a layer with nothing selected. + parts: Vec, /// This layer's adjustments. /// @@ -1040,17 +1373,12 @@ impl Clone for MaskLayer { Self { id: self.id.clone(), name: self.name.clone(), - source: self.source.clone(), invert: self.invert, opacity: self.opacity, enabled: self.enabled, - feather: self.feather, - falloff: self.falloff, - morphology: self.morphology, - morph_radius: self.morph_radius, - refine: self.refine, - // A refcount, not a raster. See the field. - coverage: self.coverage.clone(), + // Deep, but the only deep thing in it is a refcount: a part's + // coverage is an `Arc`. See the field. + parts: self.parts.clone(), ops, } } @@ -1061,22 +1389,18 @@ impl std::fmt::Debug for MaskLayer { f.debug_struct("MaskLayer") .field("id", &self.id) .field("name", &self.name) - .field("source", &self.source) .field("invert", &self.invert) .field("opacity", &self.opacity) .field("enabled", &self.enabled) - .field("feather", &self.feather) - .field("falloff", &self.falloff) - .field("morphology", &self.morphology) - .field("coverage", &self.coverage.as_ref().map(|c| c.encoded_len())) + .field("parts", &self.parts) .field("active_ops", &self.active_ops().count()) .finish() } } impl PartialEq for MaskLayer { - /// Every field that is the *edit*, and deliberately not - /// [`Self::coverage`]. + /// Every field that is the *edit*, which for the parts means everything + /// except their cached coverage — see [`MaskPart::coverage`]. /// /// This comparison is what [`crate::sidecar::Version::merge`] uses to /// decide whether a device changed a layer (FR-NC-9). A cached raster is @@ -1087,14 +1411,10 @@ impl PartialEq for MaskLayer { fn eq(&self, other: &Self) -> bool { self.id == other.id && self.name == other.name - && self.source == other.source && self.invert == other.invert && self.opacity == other.opacity && self.enabled == other.enabled - && self.feather == other.feather - && self.falloff == other.falloff - && self.morphology == other.morphology - && self.morph_radius == other.morph_radius + && self.parts == other.parts && self.params().eq(other.params()) } } @@ -1105,34 +1425,115 @@ impl MaskLayer { Self { id: id.into(), name: String::new(), - source, invert: false, opacity: 1.0, enabled: true, - // A small default rather than zero. A watershed boundary is exact - // to the pixel, and an adjustment that stops dead on one looks - // pasted on — the first thing anyone would reach for, so it is - // where the control starts. - feather: DEFAULT_FEATHER, - falloff: Falloff::default(), - morphology: Morphology::default(), - morph_radius: 0.0, - // Off, so a layer built here is the model's own weighting. The - // useful starting point for a *category* is not zero, but it is - // `dr_segment`'s number to state and this crate does not depend on - // it — `Session::add_category_mask` sets it on the way in. - refine: 0.0, - // Nothing has run yet. Filled in the first time a model's coverage - // is turned into a distance field — see [`Self::coverage`]. - coverage: None, + parts: vec![MaskPart::new(FIRST_PART_ID, Join::Union, source)], ops: layer_chain(), } } - /// The name to show, falling back to the source kind. + /// A layer built from parts that already exist — how a sidecar arrives. + /// + /// `None` for an empty list, because a layer with nothing selected is not + /// a mask: everything downstream assumes a base part exists, and inventing + /// one here would put an empty brush on the panel under the name of + /// whatever the file said. + pub fn from_parts(id: impl Into, parts: Vec) -> Option { + if parts.is_empty() { + return None; + } + Some(Self { + id: id.into(), + name: String::new(), + invert: false, + opacity: 1.0, + enabled: true, + parts, + ops: layer_chain(), + }) + } + + /// The selection this layer began as — `parts[0]`. + /// + /// Named rather than indexed at every call site because the invariant it + /// relies on (a layer always has one) is this module's to keep, not its + /// callers'. + pub fn base(&self) -> &MaskPart { + self.parts.first().expect("a layer always has a base part") + } + + pub fn base_mut(&mut self) -> &mut MaskPart { + self.parts + .first_mut() + .expect("a layer always has a base part") + } + + /// Every part, in the order the mask is built from them. + pub fn parts(&self) -> &[MaskPart] { + &self.parts + } + + pub fn parts_mut(&mut self) -> &mut [MaskPart] { + &mut self.parts + } + + pub fn part(&self, index: usize) -> Option<&MaskPart> { + self.parts.get(index) + } + + pub fn part_mut(&mut self, index: usize) -> Option<&mut MaskPart> { + self.parts.get_mut(index) + } + + /// The part with this id, which is how the sidecar and a merge name one. + pub fn part_by_id(&self, id: &str) -> Option<&MaskPart> { + self.parts.iter().find(|p| p.id == id) + } + + pub fn part_by_id_mut(&mut self, id: &str) -> Option<&mut MaskPart> { + self.parts.iter_mut().find(|p| p.id == id) + } + + /// Add a part, returning whether there was room ([`MAX_PARTS`]). + /// + /// Refuses rather than dropping the oldest, the rule [`MaskStack::push`] + /// follows and for the same reason: work the user can see on screen must + /// not vanish without being told. + pub fn push_part(&mut self, part: MaskPart) -> bool { + if self.parts.len() >= MAX_PARTS { + log::warn!("layer {} already has {MAX_PARTS} parts", self.id); + return false; + } + self.parts.push(part); + true + } + + /// Remove a part, returning it. **Never the base.** + /// + /// Removing the selection a layer began as is removing the layer, and a + /// panel that offered both would be offering the same destruction twice + /// under two names — with the version that silently promoted the second + /// part to base being the one nobody could predict. + pub fn remove_part(&mut self, index: usize) -> Option { + if index == 0 || index >= self.parts.len() { + return None; + } + Some(self.parts.remove(index)) + } + + /// A part id no other part of this layer is using. + pub fn next_part_id(&self) -> String { + (1..) + .map(|n| format!("p{n}")) + .find(|id| self.part_by_id(id).is_none()) + .expect("infinite range") + } + + /// The name to show, falling back to the base part's source kind. pub fn display_name(&self) -> &str { if self.name.is_empty() { - self.source.kind() + self.base().source.kind() } else { &self.name } @@ -1149,25 +1550,15 @@ impl MaskLayer { /// Whether this mask could cover any pixel at all. /// - /// Only a brush can answer no, and it matters more than the slot it saves. - /// An unpainted mask is empty, [`Self::invert`] turns empty into - /// everything, and a layer created with invert already set would apply its - /// adjustment to the whole photograph before a single stroke was made — - /// the loud, wrong-looking failure this codebase avoids everywhere else a - /// mask can go missing. - /// - /// A range answers yes, and it is worth saying why rather than leaving it - /// to the catch-all. A band *can* be empty on a particular photograph — a - /// highlight band over a picture with no highlights in it — but nothing - /// on this side could know that without rasterising the mask, which is the - /// one thing this module promises never to do. So the honest answer is - /// that the layer selects whatever the picture has, and an empty result - /// is the picture's answer rather than the layer's. + /// Asked of the parts that *add*, because those are the only ones that can + /// put a pixel in the mask: a layer whose base is an unpainted brush and + /// whose other parts all subtract covers nothing however many of them + /// there are, and rasterising it would cost a slice to draw empty. fn covers(&self) -> bool { - match &self.source { - MaskSource::Brush { strokes } => strokes.iter().any(|s| !s.erase && !s.is_empty()), - _ => true, - } + self.parts + .iter() + .enumerate() + .any(|(i, p)| (i == 0 || p.join == Join::Union) && p.covers()) } /// The operations in this layer's chain that reach the shader. @@ -1188,140 +1579,84 @@ impl MaskLayer { .filter(|o| o.is_active() && o.detail().is_none()) } - /// Whether this layer's region ids belong to a different segmentation. + /// Whether any part of this mask belongs to a different segmentation. /// - /// Applying it anyway would produce a confidently wrong mask, so callers - /// should offer to recompute rather than render it. + /// One stale part is a stale layer: the mask is a fold over all of them, + /// so a part that would draw a confidently wrong shape makes the result + /// wrong whether it adds or subtracts. pub fn is_stale(&self, current: u64) -> bool { - match self.source { - MaskSource::Regions { signature, .. } - | MaskSource::Subject { signature, .. } - | MaskSource::Category { signature, .. } => signature != current, - // A gradient is geometry in normalised coordinates. It means the - // same thing whatever was or was not detected, so nothing about a - // new run can invalidate it. Painted strokes are the same: they are - // where the user put them, not where a model thought something was. - // - // A range is the strongest case of all: it is a band over the - // photograph's own values, so it does not merely survive a new - // segmentation — there is nothing a model could ever say that - // would change which pixels it names. - MaskSource::Linear { .. } - | MaskSource::Radial { .. } - | MaskSource::Brush { .. } - | MaskSource::Luminance { .. } - | MaskSource::Colour { .. } => false, - } + self.parts.iter().any(|p| p.is_stale(current)) } - /// The strokes on this layer, empty for any other kind of mask. + /// The strokes on the base part, empty for any other kind of mask. pub fn strokes(&self) -> &[Stroke] { - self.source.strokes() + self.base().strokes() } - /// How many stroke points this layer is holding. See [`MAX_LAYER_POINTS`]. + /// How many stroke points this layer is holding, across every part. + /// See [`MAX_LAYER_POINTS`]. pub fn stroke_points(&self) -> usize { - self.strokes().iter().map(Stroke::len).sum() + self.parts.iter().map(MaskPart::stroke_points).sum() } - /// Start a stroke, returning whether there was room for it. + /// How many more points this layer may record. + /// + /// The budget is the layer's rather than the part's, which is what stops a + /// mask made of six painted corrections holding six times the points a + /// single painted layer may. + pub fn room(&self) -> usize { + MAX_LAYER_POINTS.saturating_sub(self.stroke_points()) + } + + /// Start a stroke in the given part, returning whether there was room. /// /// The interaction layer calls this on press, [`Self::extend_stroke`] for /// every position the pointer reports, and [`Self::end_stroke`] on release. /// Nothing in between needs to reach the GPU by any route other than the /// stack itself: the rasteriser reads the strokes each time it runs. - pub fn begin_stroke(&mut self, erase: bool, radius: f32, hardness: f32, flow: f32) -> bool { - let full = self.stroke_points() >= MAX_LAYER_POINTS; - let MaskSource::Brush { strokes } = &mut self.source else { - log::warn!( - "layer {} is a {} mask, not a brush", - self.id, - self.source.kind() - ); - return false; - }; - if full { - log::warn!( - "brush layer is full ({MAX_LAYER_POINTS} points); refusing to start another stroke" - ); - return false; + /// + /// Parts are addressed by **index**, not by id, because a gesture is aimed + /// at a row the panel is already showing by position, and threading an + /// owned `String` through a press-move-release would be a clone per + /// pointer report for identity nobody consults. + pub fn begin_stroke( + &mut self, + part: usize, + erase: bool, + radius: f32, + hardness: f32, + flow: f32, + ) -> bool { + let room = self.room(); + match self.parts.get_mut(part) { + Some(p) => p.begin_stroke(room, erase, radius, hardness, flow), + None => { + log::warn!("no part {part} to paint into"); + false + } } - strokes.push(Stroke::new(erase, radius, hardness, flow)); - true } /// Add a position to the stroke in progress, returning whether it was kept. - /// - /// A position may be dropped for being too close to the last one, which is - /// ordinary and not a failure. When the stroke in progress fills up the - /// gesture continues in a new one starting at the same point, so the swept - /// path has no gap in it — see [`MAX_STROKE_POINTS`]. - pub fn extend_stroke(&mut self, x: f32, y: f32) -> bool { - let room = MAX_LAYER_POINTS.saturating_sub(self.stroke_points()); - let MaskSource::Brush { strokes } = &mut self.source else { - return false; - }; - let Some(current) = strokes.last_mut() else { - return false; - }; - if room == 0 { - return false; - } - - if current.is_full() { - // Simplified here rather than in `end_stroke`, which only ever sees - // the last stroke of a gesture: a continuation closes the one - // before it for good, and an unsimplified stroke would reach the - // sidecar at full sampling — the one place the saving matters most, - // since a gesture long enough to split is a long line of text. - current.simplify(); - let mut next = Stroke::new( - current.erase, - current.radius, - current.hardness, - current.flow, - ); - if let Some(&joint) = current.points.last() { - next.points.push(joint); - } - strokes.push(next); - } - - strokes - .last_mut() - .expect("a stroke was just ensured") - .push_point(x, y) + pub fn extend_stroke(&mut self, part: usize, x: f32, y: f32) -> bool { + let room = self.room(); + self.parts + .get_mut(part) + .is_some_and(|p| p.extend_stroke(room, x, y)) } /// Finish the stroke in progress, simplifying it. - /// - /// A stroke that recorded nothing is dropped rather than kept as an empty - /// one: a press with no movement still records its first point, so an empty - /// stroke can only be a press that never reached the model, and leaving it - /// would put a stroke in the sidecar that draws nothing. - pub fn end_stroke(&mut self) { - let MaskSource::Brush { strokes } = &mut self.source else { - return; - }; - match strokes.last_mut() { - Some(s) if s.is_empty() => { - strokes.pop(); - } - Some(s) => s.simplify(), - None => {} + pub fn end_stroke(&mut self, part: usize) { + if let Some(p) = self.parts.get_mut(part) { + p.end_stroke(); } } - /// Remove the most recent stroke, returning it. - /// - /// The undo history already snapshots the whole graph, so this is not how - /// undo works — it is for the interaction layer to abandon a stroke it has - /// begun, when a gesture turns out to be a pinch or is cancelled. - pub fn drop_last_stroke(&mut self) -> Option { - match &mut self.source { - MaskSource::Brush { strokes } => strokes.pop(), - _ => None, - } + /// Remove the most recent stroke from a part, returning it. + pub fn drop_last_stroke(&mut self, part: usize) -> Option { + self.parts + .get_mut(part) + .and_then(MaskPart::drop_last_stroke) } pub fn descriptors(&self) -> Vec> { @@ -1555,7 +1890,7 @@ pub(crate) fn compose_layers(stack: &MaskStack) -> LayerShader { out.body, "\n // ======== mask {slot}: {} ({}) ========", layer.display_name(), - layer.source.kind() + layer.base().source.kind() ); let _ = writeln!(out.body, " {{"); // **`uv_src`, not `gid.xy`.** The mask array is rasterised in *source* @@ -1908,11 +2243,11 @@ mod tests { /// A gesture: press, drag along `path`, release. fn paint(layer: &mut MaskLayer, erase: bool, radius: f32, path: &[(f32, f32)]) { - layer.begin_stroke(erase, radius, 0.5, 1.0); + layer.begin_stroke(0, erase, radius, 0.5, 1.0); for &(x, y) in path { - layer.extend_stroke(x, y); + layer.extend_stroke(0, x, y); } - layer.end_stroke(); + layer.end_stroke(0); } #[test] @@ -1964,11 +2299,11 @@ mod tests { #[test] fn a_stationary_finger_does_not_fill_the_stroke() { let mut layer = painted("m1"); - layer.begin_stroke(false, 0.05, 0.5, 1.0); + layer.begin_stroke(0, false, 0.05, 0.5, 1.0); for _ in 0..200 { - layer.extend_stroke(0.5, 0.5); + layer.extend_stroke(0, 0.5, 0.5); } - layer.end_stroke(); + layer.end_stroke(0); assert_eq!(layer.strokes()[0].len(), 1); } @@ -2034,12 +2369,12 @@ mod tests { #[test] fn a_long_gesture_continues_in_another_stroke() { let mut layer = painted("m1"); - layer.begin_stroke(false, 0.001, 0.5, 1.0); + layer.begin_stroke(0, false, 0.001, 0.5, 1.0); for i in 0..(MAX_STROKE_POINTS + 40) { let t = i as f32 / (MAX_STROKE_POINTS + 40) as f32; - layer.extend_stroke(t, 0.5); + layer.extend_stroke(0, t, 0.5); } - layer.end_stroke(); + layer.end_stroke(0); let strokes = layer.strokes(); assert!(strokes.len() > 1, "the gesture should have continued"); @@ -2057,18 +2392,18 @@ mod tests { #[test] fn a_full_brush_layer_refuses_more_paint() { let mut layer = painted("m1"); - layer.begin_stroke(false, 0.0005, 0.5, 1.0); + layer.begin_stroke(0, false, 0.0005, 0.5, 1.0); // A zigzag, so simplification cannot quietly make room by throwing the // path away — this test is about the cap, not about the tolerance. for i in 0..(MAX_LAYER_POINTS * 4) { let t = i as f32 / (MAX_LAYER_POINTS * 4) as f32; - layer.extend_stroke(t, if i % 2 == 0 { 0.49 } else { 0.51 }); + layer.extend_stroke(0, t, if i % 2 == 0 { 0.49 } else { 0.51 }); } - layer.end_stroke(); + layer.end_stroke(0); assert!(layer.stroke_points() <= MAX_LAYER_POINTS); assert!( - !layer.begin_stroke(false, 0.05, 0.5, 1.0), + !layer.begin_stroke(0, false, 0.05, 0.5, 1.0), "a full layer says so rather than making room" ); } @@ -2096,8 +2431,8 @@ mod tests { width: 0.2, }, ); - assert!(!layer.begin_stroke(false, 0.05, 0.5, 1.0)); - assert!(!layer.extend_stroke(0.5, 0.5)); + assert!(!layer.begin_stroke(0, false, 0.05, 0.5, 1.0)); + assert!(!layer.extend_stroke(0, 0.5, 0.5)); assert!(layer.strokes().is_empty()); } @@ -2105,11 +2440,11 @@ mod tests { fn an_abandoned_stroke_can_be_taken_back() { let mut layer = painted("m1"); paint(&mut layer, false, 0.05, &[(0.2, 0.2)]); - layer.begin_stroke(false, 0.05, 0.5, 1.0); - layer.extend_stroke(0.8, 0.8); + layer.begin_stroke(0, false, 0.05, 0.5, 1.0); + layer.extend_stroke(0, 0.8, 0.8); assert_eq!(layer.strokes().len(), 2); - assert!(layer.drop_last_stroke().is_some()); + assert!(layer.drop_last_stroke(0).is_some()); assert_eq!(layer.strokes().len(), 1, "the first gesture is untouched"); } @@ -2228,4 +2563,119 @@ mod tests { assert_eq!(source.kind(), "colour"); assert_eq!(MaskSource::highlights().kind(), "luminance"); } + + // ----------------------------------------------------------------------- + // Parts + // ----------------------------------------------------------------------- + + /// The invariant everything else leans on: a layer always has a selection + /// to be. Removing the base is removing the layer, and the panel must not + /// be able to reach a state where a mask exists with nothing in it. + #[test] + fn the_base_part_cannot_be_removed() { + let mut layer = MaskLayer::new("m1", MaskSource::brush()); + layer.push_part(MaskPart::painted("p2", Join::Subtract)); + + assert!(layer.remove_part(0).is_none(), "the base refused"); + assert_eq!(layer.parts().len(), 2); + assert!(layer.remove_part(1).is_some(), "a correction did not"); + assert_eq!(layer.parts().len(), 1); + } + + /// The point budget belongs to the layer, not to each part. Otherwise a + /// photographer who split one correction into six could store six times + /// what the limit says, and the sidecar line the limit exists to bound + /// would grow with the number of parts rather than with the painting. + #[test] + fn the_point_budget_is_the_layers_not_each_parts() { + let mut layer = MaskLayer::new("m1", MaskSource::brush()); + layer.push_part(MaskPart::painted("p2", Join::Union)); + + // Fill the base to the limit, one long gesture at a time. + while layer.room() > 0 { + let before = layer.room(); + assert!(layer.begin_stroke(0, false, 0.01, 0.5, 1.0)); + let mut x = 0.0; + while layer.room() > 0 && x < 1.0 { + layer.extend_stroke(0, x, 0.5); + x += 0.01; + } + layer.end_stroke(0); + assert!(layer.room() < before, "a gesture recorded nothing"); + } + + assert_eq!(layer.stroke_points(), MAX_LAYER_POINTS); + assert!( + !layer.begin_stroke(1, false, 0.01, 0.5, 1.0), + "a second part let the layer past its budget" + ); + } + + #[test] + fn parts_past_the_limit_are_refused_rather_than_displacing_one() { + let mut layer = MaskLayer::new("m1", MaskSource::brush()); + for n in 2..=MAX_PARTS { + assert!(layer.push_part(MaskPart::painted(format!("p{n}"), Join::Union))); + } + assert_eq!(layer.parts().len(), MAX_PARTS); + assert!(!layer.push_part(MaskPart::painted("p99", Join::Union))); + assert_eq!(layer.parts().len(), MAX_PARTS, "nothing was displaced"); + } + + #[test] + fn a_part_id_is_never_reused_while_its_part_is_there() { + let mut layer = MaskLayer::new("m1", MaskSource::brush()); + assert_eq!(layer.next_part_id(), "p2", "p1 is the base"); + layer.push_part(MaskPart::painted(layer.next_part_id(), Join::Union)); + assert_eq!(layer.next_part_id(), "p3"); + layer.remove_part(1); + assert_eq!(layer.next_part_id(), "p2", "and is free again once gone"); + } + + /// A mask whose only painted part subtracts covers nothing, so rasterising + /// it would spend a slice to draw an empty field — and, with the layer + /// inverted, would take the adjustment global. + #[test] + fn a_layer_that_only_subtracts_covers_nothing() { + let mut layer = MaskLayer::new("m1", MaskSource::brush()); + layer.set_param("exposure", ParamId("exposure"), 1.0); + let mut cut = MaskPart::painted("p2", Join::Subtract); + cut.begin_stroke(64, false, 0.1, 0.5, 1.0); + cut.extend_stroke(64, 0.5, 0.5); + cut.end_stroke(); + layer.push_part(cut); + + assert!( + !layer.is_active(), + "an unpainted base with a subtraction on it selects nothing" + ); + + layer.begin_stroke(0, false, 0.1, 0.5, 1.0); + layer.extend_stroke(0, 0.5, 0.5); + layer.end_stroke(0); + assert!( + layer.is_active(), + "and now the base has something to cut into" + ); + } + + /// One stale part is a stale layer: the mask is the fold over all of them, + /// so a part that would draw a confidently wrong shape makes the result + /// wrong whichever way it joins. + #[test] + fn a_stale_part_makes_the_layer_stale() { + let mut layer = MaskLayer::new("m1", MaskSource::brush()); + assert!(!layer.is_stale(7)); + + layer.push_part(MaskPart::new( + "p2", + Join::Union, + MaskSource::Regions { + signature: 3, + level: 0, + ids: vec![1], + }, + )); + assert!(layer.is_stale(7)); + } } diff --git a/core/dr-pipeline/src/sidecar.rs b/core/dr-pipeline/src/sidecar.rs index af9c763..72edffd 100644 --- a/core/dr-pipeline/src/sidecar.rs +++ b/core/dr-pipeline/src/sidecar.rs @@ -69,7 +69,9 @@ use std::fmt::Write as _; use crate::coverage::Coverage; use crate::graph::EditGraph; -use crate::mask::{Falloff, MaskLayer, MaskSource, MaskStack, Morphology, Stroke, DEFAULT_FEATHER}; +use crate::mask::{ + Falloff, Join, MaskLayer, MaskPart, MaskSource, MaskStack, Morphology, Stroke, DEFAULT_FEATHER, +}; use crate::preset::Preset; use crate::spot::{Spot, SpotMode, SpotSet}; use crate::state::{EditState, FilmRebake}; @@ -833,6 +835,10 @@ impl Sidecar { // then simply dropped instead of corrupting whichever version // happened to be open. let mut mask: Option = None; + // Whether the `[part]` block being read names a mask that is not the + // one above it. Its keys are dropped rather than falling through to + // the version, where they would be read as somebody's global edit. + let mut orphan_part = false; let mut masks: Vec<(String, MaskLayer)> = Vec::new(); for raw in lines { @@ -846,6 +852,7 @@ impl Sidecar { .and_then(|s| s.strip_suffix(']')) { masks.extend(mask.take().and_then(PartialMask::finish)); + orphan_part = false; if let Some(v) = current.take() { sidecar.put(v); } @@ -861,6 +868,7 @@ impl Sidecar { .and_then(|s| s.strip_suffix(']')) { masks.extend(mask.take().and_then(PartialMask::finish)); + orphan_part = false; match head.split_once(char::is_whitespace) { Some((version, id)) => { mask = Some(PartialMask::new(version.trim(), id.trim())); @@ -874,6 +882,36 @@ impl Sidecar { continue; } + // A part of the mask block above this one. **Three names**, and + // the two it repeats are checked rather than assumed: a block + // reordered by a hand edit or by a merge would otherwise attach + // somebody's correction to whichever mask happened to precede it, + // which is a wrong mask rather than a missing one. + if let Some(head) = line + .strip_prefix("[part ") + .and_then(|s| s.strip_suffix(']')) + { + let mut names = head.split_whitespace(); + orphan_part = true; + if let (Some(version), Some(layer), Some(part)) = + (names.next(), names.next(), names.next()) + { + match mask.as_mut() { + Some(m) if m.version == version && m.id == layer => { + m.begin_part(part); + orphan_part = false; + } + _ => log::warn!( + "sidecar: part '{part}' names mask {layer} of version {version}, \ + which is not the block it follows; ignoring it" + ), + } + } else { + log::warn!("sidecar: malformed part header '{line}'; ignoring"); + } + continue; + } + let Some((key, value)) = line.split_once('=') else { // Not a key-value line and not a block header. Keep it so a // newer format's construct survives a round trip here. @@ -887,6 +925,10 @@ impl Sidecar { }; let (key, value) = (key.trim(), value.trim()); + if orphan_part { + continue; + } + if let Some(m) = mask.as_mut() { m.set(key, value); continue; @@ -1062,9 +1104,59 @@ fn write_mask(out: &mut String, version: &str, layer: &MaskLayer) { if !layer.name.is_empty() { let _ = writeln!(out, "name = {}", layer.name); } - let _ = writeln!(out, "source = {}", layer.source.kind()); - match &layer.source { + // **The base part is written into the layer's own block**, without a name + // and without a join, and that is what makes this format change no file + // that does not use it: a layer of one part is the same bytes it always + // was, and a block with no `[part]` after it reads back as one part + // (see [`PartialMask::finish`]). + write_source(out, layer.base()); + + if layer.invert { + let _ = writeln!(out, "invert = 1"); + } + if layer.opacity != 1.0 { + let _ = writeln!(out, "opacity = {}", format_value(layer.opacity)); + } + if !layer.enabled { + let _ = writeln!(out, "enabled = 0"); + } + write_shaping(out, layer.base()); + for (op, param, value) in layer.params() { + let _ = writeln!(out, "{op}.{param} = {}", format_value(value)); + } + write_coverage(out, layer.base()); + + for part in &layer.parts()[1..] { + write_part(out, version, &layer.id, part); + } +} + +/// Write one part of a layer's mask as its own block. +/// +/// Its own block rather than a nested key, because a merge compares lines and +/// a part is the granularity a photographer edits: adding a correction to a +/// mask should read, in a diff, as a correction added — not as the whole mask +/// having been rewritten. The version and the layer are both named in the +/// header for the reason [`write_mask`] names the version: "belongs to +/// whatever appeared above me" is a relationship that does not survive a hand +/// edit or a merge. +fn write_part(out: &mut String, version: &str, layer: &str, part: &MaskPart) { + let _ = write!(out, "\n[part {version} {layer} {}]\n", part.id); + let _ = writeln!(out, "join = {}", part.join.name()); + write_source(out, part); + if part.invert { + let _ = writeln!(out, "invert = 1"); + } + write_shaping(out, part); + write_coverage(out, part); +} + +/// The `source = …` line and whatever else that kind of source needs. +fn write_source(out: &mut String, part: &MaskPart) { + let _ = writeln!(out, "source = {}", part.source.kind()); + + match &part.source { MaskSource::Regions { signature, level, @@ -1170,54 +1262,46 @@ fn write_mask(out: &mut String, version: &str, layer: &MaskLayer) { ); } } +} - if layer.invert { - let _ = writeln!(out, "invert = 1"); +/// The edge treatment, written only where it is not the default — the same +/// rule the parameters follow, so a file stays readable and a mask nobody has +/// fiddled with contributes four fewer lines. +fn write_shaping(out: &mut String, part: &MaskPart) { + if part.feather != DEFAULT_FEATHER { + let _ = writeln!(out, "edge-feather = {}", format_value(part.feather)); } - if layer.opacity != 1.0 { - let _ = writeln!(out, "opacity = {}", format_value(layer.opacity)); + if part.falloff != Falloff::default() { + let _ = writeln!(out, "edge-falloff = {}", part.falloff.name()); } - if !layer.enabled { - let _ = writeln!(out, "enabled = 0"); - } - // The edge treatment, written only when it is not the default — the same - // rule the parameters follow, so a file stays readable and a mask nobody - // has fiddled with contributes four fewer lines. - if layer.feather != DEFAULT_FEATHER { - let _ = writeln!(out, "edge-feather = {}", format_value(layer.feather)); - } - if layer.falloff != Falloff::default() { - let _ = writeln!(out, "edge-falloff = {}", layer.falloff.name()); - } - if layer.morphology != Morphology::default() { - let _ = writeln!(out, "morphology = {}", layer.morphology.name()); - let _ = writeln!(out, "morph-radius = {}", format_value(layer.morph_radius)); + if part.morphology != Morphology::default() { + let _ = writeln!(out, "morphology = {}", part.morphology.name()); + let _ = writeln!(out, "morph-radius = {}", format_value(part.morph_radius)); } // Absent means zero, which is the model's own weighting — so a file // written before this control existed reads back looking exactly as it did. - if layer.refine != 0.0 { - let _ = writeln!(out, "refine = {}", format_value(layer.refine)); - } - for (op, param, value) in layer.params() { - let _ = writeln!(out, "{op}.{param} = {}", format_value(value)); + if part.refine != 0.0 { + let _ = writeln!(out, "refine = {}", format_value(part.refine)); } +} - // TRACES: FR-DEV-3 | FR-CAT-8 - // The pixels a model found, so that opening the photograph again — or - // exporting it from the grid, where no model is ever run — renders the - // layer instead of silently dropping it. See [`crate::coverage`] for the - // encoding and for why it is one line. - // - // **Last in the block, and that is on purpose.** It is thousands of - // characters against a dozen elsewhere, and a sidecar is read by hand when - // an edit has gone wrong (ARCH §6.12); everything a human is looking for - // should be above it rather than after it. - // - // Omitted, not truncated, when it will not encode: a layer with no stored - // coverage behaves exactly as every layer did before this existed, which - // is a mask that needs the model run — where a *partial* one would be a - // mask that is confidently wrong. - if let Some(coverage) = layer.coverage.as_ref() { +/// TRACES: FR-DEV-3 | FR-CAT-8 +/// The pixels a model found, so that opening the photograph again — or +/// exporting it from the grid, where no model is ever run — renders the part +/// instead of silently dropping it. See [`crate::coverage`] for the encoding +/// and for why it is one line. +/// +/// **Last in the block, and that is on purpose.** It is thousands of +/// characters against a dozen elsewhere, and a sidecar is read by hand when an +/// edit has gone wrong (ARCH §6.12); everything a human is looking for should +/// be above it rather than after it. +/// +/// Omitted, not truncated, when it will not encode: a part with no stored +/// coverage behaves exactly as every layer did before this existed, which is a +/// mask that needs the model run — where a *partial* one would be a mask that +/// is confidently wrong. +fn write_coverage(out: &mut String, part: &MaskPart) { + if let Some(coverage) = part.coverage.as_ref() { let _ = writeln!(out, "coverage = {}", coverage.to_text()); } } @@ -1295,15 +1379,14 @@ fn parse_stroke(value: &str) -> Option { (!stroke.is_empty()).then_some(stroke) } -/// A mask block being read, before it is complete enough to be a layer. +/// One part of a mask being read, before it is complete enough to be a part. /// -/// Separate from [`MaskLayer`] because the source cannot be built until every +/// Separate from [`MaskPart`] because the source cannot be built until every /// one of its fields has been seen, and the fields arrive one line at a time /// in whatever order the writer chose. -struct PartialMask { - version: String, +struct PartialPart { id: String, - name: String, + join: Join, kind: String, signature: u64, level: u32, @@ -1321,16 +1404,14 @@ struct PartialMask { /// A range source's band: its two bounds and the fade at each edge. /// /// One triple for both range kinds — tone positions for a luminance - /// layer, chroma for a colour one — because they are the same line in the + /// part, chroma for a colour one — because they are the same line in the /// file and reading them into two sets of fields would be two ways to /// spell one thing. band: (f32, f32, f32), /// A colour range's arc: centre and half-width, in turns. hue: (f32, f32), invert: bool, - opacity: f32, - enabled: bool, - /// The *layer's* edge transition, distinct from the radial source's own + /// The *part's* edge transition, distinct from the radial source's own /// `feather` above — different quantity, different units, different key. edge_feather: f32, falloff: Falloff, @@ -1339,15 +1420,13 @@ struct PartialMask { refine: f32, coverage: Option, strokes: Vec, - params: Vec<(String, String, f32)>, } -impl PartialMask { - fn new(version: &str, id: &str) -> Self { +impl PartialPart { + fn new(id: &str) -> Self { Self { - version: version.to_string(), id: id.to_string(), - name: String::new(), + join: Join::default(), kind: String::new(), signature: 0, level: 0, @@ -1367,8 +1446,6 @@ impl PartialMask { band: (0.5, 1.0, crate::mask::DEFAULT_RANGE_SOFTNESS), hue: (0.06, 0.05), invert: false, - opacity: 1.0, - enabled: true, edge_feather: DEFAULT_FEATHER, falloff: Falloff::default(), morphology: Morphology::default(), @@ -1376,13 +1453,12 @@ impl PartialMask { refine: 0.0, coverage: None, strokes: Vec::new(), - params: Vec::new(), } } - fn set(&mut self, key: &str, value: &str) { + /// Take one key, returning whether it was one of this part's. + fn set(&mut self, key: &str, value: &str) -> bool { match key { - "name" => self.name = value.to_string(), "source" => self.kind = value.to_string(), "signature" => self.signature = value.parse().unwrap_or(0), "level" => self.level = value.parse().unwrap_or(0), @@ -1434,8 +1510,6 @@ impl PartialMask { // and the file's line order is the order they were painted in. "stroke" => self.strokes.extend(parse_stroke(value)), "invert" => self.invert = value != "0", - "opacity" => self.opacity = value.parse::().unwrap_or(1.0).clamp(0.0, 1.0), - "enabled" => self.enabled = value != "0", // Clamped, not trusted: a feather wider than the frame is not a // mask, and a negative one is a distance field read backwards. "edge-feather" => { @@ -1472,19 +1546,18 @@ impl PartialMask { Morphology::default() }) } - _ => match (key.split_once('.'), value.parse::()) { - (Some((op, param)), Ok(v)) if v.is_finite() => { - self.params.push((op.to_string(), param.to_string(), v)); - } - _ => log::warn!("sidecar: unreadable mask key {key}; ignoring"), - }, + // Not one of ours. The caller decides what that means: an + // adjustment's parameter inside the mask block, or a key nothing + // in this build understands. + _ => return false, } + true } - /// Build the layer, or `None` if the source kind is one this build has + /// Build the part, or `None` if the source kind is one this build has /// never heard of — a newer format's mask type, which is skipped rather /// than guessed at. - fn finish(self) -> Option<(String, MaskLayer)> { + fn finish(self) -> Option { let source = match self.kind.as_str() { "regions" => MaskSource::Regions { signature: self.signature, @@ -1529,33 +1602,156 @@ impl PartialMask { ), other => { log::warn!( - "sidecar: unknown mask source '{other}'; skipping layer {}", + "sidecar: unknown mask source '{other}'; skipping part {}", self.id ); return None; } }; - let mut layer = MaskLayer::new(self.id, source); - layer.name = self.name; - layer.invert = self.invert; - layer.opacity = self.opacity; - layer.enabled = self.enabled; - layer.feather = self.edge_feather; - layer.falloff = self.falloff; - layer.morphology = self.morphology; - layer.morph_radius = self.morph_radius; - layer.refine = self.refine; - // Only where there is a model behind the layer to have produced it. A + let mut part = MaskPart::new(self.id, self.join, source); + part.invert = self.invert; + part.feather = self.edge_feather; + part.falloff = self.falloff; + part.morphology = self.morphology; + part.morph_radius = self.morph_radius; + part.refine = self.refine; + // Only where there is a model behind the part to have produced it. A // gradient or a brush that arrived carrying one is a file that has // been hand-edited or written by a build that means something else by // the key, and honouring it would upload a raster nothing samples. - layer.coverage = match layer.source { + part.coverage = match part.source { MaskSource::Subject { .. } | MaskSource::Category { .. } => { self.coverage.map(std::sync::Arc::new) } _ => None, }; + Some(part) + } +} + +/// A mask block being read, before it is complete enough to be a layer. +/// +/// Holds what belongs to the *layer* — its name, its inversion, its opacity, +/// its adjustments — and one [`PartialPart`] per selection the mask is built +/// from. `parts[0]` is filled by the mask block's own keys, which is what +/// makes a file written before parts existed read back as a layer of one. +struct PartialMask { + version: String, + id: String, + name: String, + invert: bool, + opacity: f32, + enabled: bool, + params: Vec<(String, String, f32)>, + parts: Vec, + /// Whether a `[part]` block is open, so `invert` can be told apart: in the + /// mask block it flips the finished mask, and in a part block it flips + /// that part before it is joined. Same word, two controls, and the block + /// is the only thing that distinguishes them. + in_part: bool, +} + +impl PartialMask { + fn new(version: &str, id: &str) -> Self { + Self { + version: version.to_string(), + id: id.to_string(), + name: String::new(), + invert: false, + opacity: 1.0, + enabled: true, + params: Vec::new(), + parts: vec![PartialPart::new(crate::mask::FIRST_PART_ID)], + in_part: false, + } + } + + /// Open a `[part]` block. Every key from here to the next block header + /// belongs to it. + fn begin_part(&mut self, id: &str) { + self.parts.push(PartialPart::new(id)); + self.in_part = true; + } + + fn set(&mut self, key: &str, value: &str) { + // The layer's own keys, and only while no part block is open. A part + // has an `invert` of its own and no opacity at all, so which of the + // two an `invert` line means is decided by the block it is in. + if !self.in_part { + match key { + "name" => { + self.name = value.to_string(); + return; + } + "invert" => { + self.invert = value != "0"; + return; + } + "opacity" => { + self.opacity = value.parse::().unwrap_or(1.0).clamp(0.0, 1.0); + return; + } + "enabled" => { + self.enabled = value != "0"; + return; + } + _ => {} + } + } else if key == "join" { + // Unknown falls back to a union rather than dropping the part: a + // join this build does not have is a part that joins some other + // way, and adding it is the reading that keeps the selection + // visible and therefore fixable. Silently subtracting under a name + // nobody could see would not be. + if let Some(part) = self.parts.last_mut() { + part.join = Join::from_name(value).unwrap_or_else(|| { + log::warn!("sidecar: unknown join '{value}'; adding the part instead"); + Join::Union + }); + } + return; + } + + if let Some(part) = self.parts.last_mut() { + if part.set(key, value) { + return; + } + } + + // Whatever is left is an adjustment, which belongs to the layer + // however deep in the block it was written. + match (key.split_once('.'), value.parse::()) { + (Some((op, param)), Ok(v)) if v.is_finite() => { + self.params.push((op.to_string(), param.to_string(), v)); + } + _ => log::warn!("sidecar: unreadable mask key {key}; ignoring"), + } + } + + /// Build the layer, or `None` when its base part cannot be built — a + /// newer format's mask type, which is skipped rather than guessed at. + /// + /// A *later* part that cannot be built costs that part and not the layer, + /// on the rule [`parse_stroke`] already follows: the rest of the mask is + /// work the photographer did, and throwing it away to protect the + /// consistency of one correction loses more than it saves. + fn finish(self) -> Option<(String, MaskLayer)> { + let id = self.id; + let mut parts = Vec::new(); + for (i, part) in self.parts.into_iter().enumerate() { + match (part.finish(), i) { + (Some(p), _) => parts.push(p), + (None, 0) => return None, + (None, _) => {} + } + } + + let mut layer = MaskLayer::from_parts(id, parts)?; + layer.name = self.name; + layer.invert = self.invert; + layer.opacity = self.opacity; + layer.enabled = self.enabled; for (op, param, value) in &self.params { // `ParamId` holds a `&'static str` and this one came off disk, so // it is matched against the descriptors and the *static* id is diff --git a/core/dr-pipeline/tests/mask_sidecar.rs b/core/dr-pipeline/tests/mask_sidecar.rs index 0204f8d..ba1d141 100644 --- a/core/dr-pipeline/tests/mask_sidecar.rs +++ b/core/dr-pipeline/tests/mask_sidecar.rs @@ -5,8 +5,8 @@ //! 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::descriptor::{OpId, ParamId}; +use dr_pipeline::mask::{Join, MaskLayer, MaskPart, MaskSource, Morphology}; use dr_pipeline::{EditGraph, Sidecar, Version}; fn regions(ids: &[u32]) -> MaskSource { @@ -55,7 +55,7 @@ fn a_region_mask_survives_a_round_trip() { 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.base().source, regions(&[3, 7, 12])); assert_eq!( layer .ops @@ -110,8 +110,14 @@ fn gradients_keep_their_geometry() { 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); + 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] @@ -155,7 +161,7 @@ fn region_order_is_normalised_on_read() { let parsed = Sidecar::parse(text).expect("parse"); let layer = &parsed.versions["default"].masks.layers()[0]; assert_eq!( - layer.source, + layer.base().source, MaskSource::Regions { signature: 5, level: 10, @@ -302,11 +308,11 @@ 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); + layer.begin_stroke(0, *erase, *radius, 0.5, 1.0); for &(x, y) in path { - layer.extend_stroke(x, y); + layer.extend_stroke(0, x, y); } - layer.end_stroke(); + layer.end_stroke(0); } layer } @@ -440,7 +446,10 @@ fn an_unpainted_brush_layer_still_persists() { let restored = round_trip(&graph); assert_eq!(restored.masks().len(), 1); - assert_eq!(restored.masks().layers()[0].source, MaskSource::brush()); + assert_eq!( + restored.masks().layers()[0].base().source, + MaskSource::brush() + ); } // --------------------------------------------------------------------------- @@ -576,10 +585,10 @@ fn show_a_sidecar() { ); 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.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); @@ -635,7 +644,7 @@ fn category_masks_keep_their_name() { }, ); sky.name = "sky".into(); - sky.feather = 0.03; + sky.base_mut().feather = 0.03; sky.set_param("exposure", ParamId("exposure"), 0.5); graph.masks_mut().push(sky); @@ -655,9 +664,15 @@ fn category_masks_keep_their_name() { 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); - assert!((layers[0].feather - 0.03).abs() < 1e-6); + 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. @@ -683,7 +698,7 @@ fn a_categorys_refine_strictness_survives_and_defaults_off() { name: "sky".into(), }, ); - sky.refine = 3.7; + 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 @@ -711,12 +726,13 @@ fn a_categorys_refine_strictness_survives_and_defaults_off() { let layers = round_trip(&graph); let layers = layers.masks().layers(); assert!( - (layers[0].refine - 3.7).abs() < 1e-6, + (layers[0].base().refine - 3.7).abs() < 1e-6, "got {}", - layers[0].refine + layers[0].base().refine ); assert_eq!( - layers[1].refine, 0.0, + layers[1].base().refine, + 0.0, "an absent key is the model's own mask" ); } @@ -735,7 +751,7 @@ fn an_out_of_range_refine_is_clamped() { name: "sky".into(), }, ); - sky.refine = 3.0; + sky.base_mut().refine = 3.0; graph.masks_mut().push(sky); // Edited in the written text rather than assembled by hand, so this test @@ -754,7 +770,7 @@ fn an_out_of_range_refine_is_clamped() { .apply(&mut restored) .expect_no_film(); - let refine = restored.masks().layers()[0].refine; + let refine = restored.masks().layers()[0].base().refine; assert!( refine <= dr_pipeline::mask::MAX_REFINE, "a file asked for 900 and got {refine}" @@ -828,7 +844,7 @@ fn inside(values: &[u8]) -> Vec { 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.coverage = Some(Arc::new( + layer.base_mut().coverage = Some(Arc::new( Coverage::encode(values, PROXY.0, PROXY.1, RENDERED_LEVELS).expect("a disc should encode"), )); layer @@ -849,11 +865,15 @@ fn a_subjects_coverage_survives_a_round_trip() { let layer = &restored.masks().layers()[0]; assert_eq!( - layer.source, + layer.base().source, subject(0x1234, 2), "the identity is still there" ); - let coverage = layer.coverage.as_ref().expect("the pixels came back too"); + 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()), @@ -876,9 +896,9 @@ fn a_categorys_coverage_survives_a_round_trip() { let restored = round_trip(&graph); let layer = &restored.masks().layers()[0]; - assert_eq!(layer.source, source); + assert_eq!(layer.base().source, source); assert_eq!( - inside(&layer.coverage.as_ref().expect("coverage").decode()), + inside(&layer.base().coverage.as_ref().expect("coverage").decode()), inside(&values) ); } @@ -969,10 +989,10 @@ exposure.exposure = 0.75 .expect_no_film(); let layer = &graph.masks().layers()[0]; - assert_eq!(layer.source, subject(4660, 2)); + assert_eq!(layer.base().source, subject(4660, 2)); assert_eq!(layer.name, "Dog"); assert!( - layer.coverage.is_none(), + layer.base().coverage.is_none(), "absent means absent, not an empty mask" ); } @@ -1011,9 +1031,9 @@ coverage = 96 64 999 not-a-payload .expect_no_film(); let layer = &graph.masks().layers()[0]; - assert_eq!(layer.source, subject(4660, 2)); + assert_eq!(layer.base().source, subject(4660, 2)); assert_eq!(layer.name, "Dog"); - assert!(layer.coverage.is_none()); + assert!(layer.base().coverage.is_none()); assert_eq!( layer .ops @@ -1048,8 +1068,8 @@ fn coverage_is_not_loaded_onto_a_source_with_no_model_behind_it() { .expect_no_film(); let layer = &graph.masks().layers()[0]; - assert!(matches!(layer.source, MaskSource::Radial { .. })); - assert!(layer.coverage.is_none()); + assert!(matches!(layer.base().source, MaskSource::Radial { .. })); + assert!(layer.base().coverage.is_none()); } /// TRACES: FR-NC-9 @@ -1063,9 +1083,9 @@ fn coverage_is_not_loaded_onto_a_source_with_no_model_behind_it() { 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.source = subject(7, 0); + l.base_mut().source = subject(7, 0); if with { - l.coverage = Some(Arc::new( + l.base_mut().coverage = Some(Arc::new( Coverage::encode(&a_disc(), PROXY.0, PROXY.1, RENDERED_LEVELS).expect("encode"), )); } @@ -1088,7 +1108,12 @@ fn a_coverage_one_device_has_and_the_other_lacks_is_not_a_conflict() { "running the model is not an edit: {conflicts:?}" ); assert!( - ours.masks.get("m1").expect("layer").coverage.is_some(), + ours.masks + .get("m1") + .expect("layer") + .base() + .coverage + .is_some(), "and the side that has the pixels keeps them" ); } @@ -1104,8 +1129,8 @@ fn a_real_edit_is_still_a_conflict_with_coverage_present() { }; let layer = |ev: f32| { let mut l = lit("m1", &[1], ev); - l.source = subject(7, 0); - l.coverage = stored(); + l.base_mut().source = subject(7, 0); + l.base_mut().coverage = stored(); l }; @@ -1149,11 +1174,11 @@ fn range_masks_keep_their_bands() { let layers = restored.masks().layers(); assert_eq!(layers.len(), 2); assert_eq!( - layers[0].source, + layers[0].base().source, MaskSource::luminance_range(0.62, 0.91, 0.2) ); assert_eq!( - layers[1].source, + layers[1].base().source, MaskSource::colour_range(0.07, 0.04, 0.2, 0.85, 0.11) ); } @@ -1200,7 +1225,7 @@ fn a_crossed_band_is_ordered_on_read() { .expect_no_film(); assert_eq!( - graph.masks().layers()[0].source, + 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" ); @@ -1229,5 +1254,255 @@ fn a_range_mask_with_no_band_falls_back_to_the_default_one() { .apply(&mut graph) .expect_no_film(); - assert_eq!(graph.masks().layers()[0].source, MaskSource::highlights()); + 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" + ); +} + +/// 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); } diff --git a/docs/gestures.md b/docs/gestures.md index 10bb80d..7f8a1ce 100644 --- a/docs/gestures.md +++ b/docs/gestures.md @@ -5,7 +5,7 @@ Every entry here is extracted from the comment beside the code that implements it, so this file cannot describe a gesture the application does not have. Add one by writing a `GESTURE:` block next to the implementation; there is nowhere else to write it. -35 gestures, in 4 places. +31 gestures, in 3 places. ## Develop @@ -25,7 +25,16 @@ Sampling a neutral is the first move of the tonal pass — every colour judgemen Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between. -`ui/dr-ui/ui/app.slint:2064` +`ui/dr-ui/ui/app.slint:2018` + +### Paint a mask by hand + +- **Touch** — Choose Paint or Erase, then drag on the photograph +- **Pointer** — Choose Paint or Erase, then drag + +A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make. + +`ui/dr-ui/ui/app.slint:2091` ### Move a magnified photograph about @@ -34,7 +43,7 @@ Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as m Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture. -`ui/dr-ui/ui/app.slint:2217` +`ui/dr-ui/ui/app.slint:2224` ### Take back the last change @@ -44,7 +53,7 @@ Only once there is something outside the viewport to reach, which is why the cur A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does. -`ui/dr-ui/ui/app.slint:2384` +`ui/dr-ui/ui/app.slint:2391` ### Do it again after taking it back @@ -52,7 +61,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a - **Pointer** — Click it, or press Redo in the History header - **Keyboard** — Ctrl+Shift+Z -`ui/dr-ui/ui/app.slint:2397` +`ui/dr-ui/ui/app.slint:2404` ### Copy the settings from this photograph @@ -62,7 +71,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a The panel is the copy that has to work: a tablet has no modifier key to hold and no menu bar to hang the action from. The shortcut is an accelerator for a control that is on screen either way. -`ui/dr-ui/ui/app.slint:2430` +`ui/dr-ui/ui/app.slint:2437` ### Paste the settings onto this photograph @@ -72,7 +81,7 @@ The panel is the copy that has to work: a tablet has no modifier key to hold and The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope. -`ui/dr-ui/ui/app.slint:2442` +`ui/dr-ui/ui/app.slint:2449` ### Change which group of adjustments is on screen @@ -82,7 +91,7 @@ The button names what would be pasted — "3 adjustments", and whether the crop The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added. -`ui/dr-ui/ui/app.slint:2470` +`ui/dr-ui/ui/app.slint:2477` ### Look at the photograph at 1:1 @@ -92,7 +101,7 @@ The groups are whatever the operation set declares itself to be about, so there Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans. -`ui/dr-ui/ui/app.slint:2505` +`ui/dr-ui/ui/app.slint:2512` ### Move to the next or previous photograph @@ -102,7 +111,7 @@ Noise reduction and capture sharpening are judgements about single pixels, and a The edit on screen is saved on the way out, so stepping through a folder is as much a departure as going back to the grid and loses nothing. -`ui/dr-ui/ui/app.slint:2535` +`ui/dr-ui/ui/app.slint:2542` ### See the photograph before you edited it @@ -112,7 +121,7 @@ The edit on screen is saved on the way out, so stepping through a folder is as m Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards. -`ui/dr-ui/ui/app.slint:2659` +`ui/dr-ui/ui/app.slint:2666` ### Put one control back to its default @@ -130,45 +139,7 @@ The column is 280px wide and the colour mixer alone puts thirty-six of these in Disabling a layer is the before-and-after a local edit constantly wants, so it is one press away rather than inside the row. It is an edit and does take a history step, unlike holding "Before" — the layer really is off until it is switched back on. -`ui/dr-ui/ui/masks.slint:163` - -## Collections sidebar - -### Pick a collection up to rearrange the tree - -- **Touch** — Press and hold it until it lifts, then drag it -- **Pointer** — Drag it, or hold it until it lifts and then drag - -The tree is inside a Flickable, which claims any drag beginning inside it — so with a finger a drag on a row is a scroll until something says otherwise. The hold is that something, and it is what every mobile list already uses to pick a row up. The row lifts the moment it fires, so the gesture says it has been understood before anything moves. - -`ui/dr-ui/ui/collections.slint:335` - -### Act on a collection — rename, nest, un-nest, delete - -- **Touch** — Press and hold the collection, then let go without moving -- **Pointer** — Right-click it - -The hold arms a drag and opens this menu, and which one you get is decided by whether you moved — the same fork the grid uses. One menu for everything done to a row, because there is one hold per row: while the hold opened the offline question by itself, nothing else the tree can do had a touch route. - -`ui/dr-ui/ui/collections.slint:346` - -### Take a collection back out of the one it is nested in - -- **Touch** — Hold it, then drag it onto "All photographs" — or let go and choose "Move to top level" -- **Pointer** — Drag it onto "All photographs", or right-click it and choose "Move to top level" - -Nesting is a drag of one row onto another, and its inverse had no gesture at all: "All photographs" refused every drop, which is right for a photograph — it is already in the library — and wrong for a collection, which has a top level to be returned to. Without it a collection dragged into another was in there permanently. - -`ui/dr-ui/ui/collections.slint:356` - -### Rename a collection - -- **Touch** — Hold the collection, then "Rename" -- **Pointer** — Double-click its name, or right-click it and choose "Rename" - -Double-click is what a file manager and a Lightroom panel use for the same thing, so it needs no discovering — but nothing on screen says so, which is what the menu item is for. - -`ui/dr-ui/ui/collections.slint:369` +`ui/dr-ui/ui/masks.slint:195` ## People @@ -261,7 +232,7 @@ This replaced a double tap, which had no visible state and could take forty phot "Any of them" is a union and "all of them" is an intersection. The tray is where both terms and the choice between them live, because a filter belongs on the filter bar. -`ui/dr-ui/ui/library.slint:2102` +`ui/dr-ui/ui/library.slint:2096` ### Resize the thumbnails @@ -270,7 +241,7 @@ This replaced a double tap, which had no visible state and could take forty phot There is no wheel on a tablet, so without the pinch the cell size could only be changed by a control a finger cannot reach. -`ui/dr-ui/ui/library.slint:2755` +`ui/dr-ui/ui/library.slint:2749` ### File photographs in a collection @@ -279,7 +250,7 @@ There is no wheel on a tablet, so without the pinch the cell size could only be The selection is what the drag carries, which is why selecting several is worth the mode: forty photographs file in one gesture. -`ui/dr-ui/ui/library.slint:2952` +`ui/dr-ui/ui/library.slint:2946` ### Open a photograph @@ -288,7 +259,7 @@ The selection is what the drag carries, which is why selecting several is worth A tap opens; a tap that *moved* does not. Travel is what separates a deliberate tap from a hand brushing past, and it is the only thing that does: the two are the same length. An earlier version required the finger to dwell 120 ms instead, and that rejected ordinary taps — a real tap is often quicker than a brush. -`ui/dr-ui/ui/library.slint:3220` +`ui/dr-ui/ui/library.slint:3214` ### Rate a photograph without opening it @@ -298,7 +269,7 @@ A tap opens; a tap that *moved* does not. Travel is what separates a deliberate A star has to take the press without it also reaching the cell, or every rating throws the user into develop. -`ui/dr-ui/ui/library.slint:3340` +`ui/dr-ui/ui/library.slint:3334` ### Choose the frame a folded burst shows @@ -307,7 +278,7 @@ A star has to take the press without it also reaching the cell, or every rating A folded burst draws its earliest frame, which is a fact about the clock and not a judgement about the photograph — nothing in this application ranks a frame (FR-CULL-5). But the point of a burst is that one of the twelve is better than the other eleven, and the photographer is the only one who knows which. So the choice is offered on the frames themselves, while they are open and side by side, which is the one moment the alternatives are on screen to be compared. -`ui/dr-ui/ui/library.slint:3471` +`ui/dr-ui/ui/library.slint:3465` ### Drop the selection but keep selecting @@ -316,7 +287,7 @@ A folded burst draws its earliest frame, which is a fact about the clock and not Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the next selection can start straight away. -`ui/dr-ui/ui/library.slint:4132` +`ui/dr-ui/ui/library.slint:4126` ### Select everything the grid is showing @@ -325,13 +296,4 @@ Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the ne A scoped grid of two hundred frames is two hundred taps otherwise, and "all of them, except those three" is a far more common shape than the taps it took to say it. -`ui/dr-ui/ui/library.slint:4149` - -### Take photographs out of a collection - -- **Touch** — Select them, then "Collections…" in the selection bar -- **Pointer** — Select them, then "Collections…" in the selection bar - -The badge on a cell says a photograph is filed in three collections and never which. This is the sheet that names them, and the only way out of one the grid is not currently scoped to. - -`ui/dr-ui/ui/library.slint:4264` +`ui/dr-ui/ui/library.slint:4143` diff --git a/docs/mask-editing.md b/docs/mask-editing.md index cc54641..9c5953a 100644 --- a/docs/mask-editing.md +++ b/docs/mask-editing.md @@ -259,16 +259,32 @@ slice, so eight layers still cost eight channels. The set operations are already expressible in fixed-function blending over `r8unorm`, which is why this is the cheap half of the feature: -| Join | `src_factor`, `dst_factor`, op | Result | -|------|-------------------------------|--------| -| Union | `One`, `One`, `Max` | `max(dst, src)` | -| Subtract | `Zero`, `OneMinusSrc`, `Add` | `dst · (1 − src)` | -| Intersect | `Zero`, `Src`, `Add` | `dst · src` | +| Join | `src_factor`, `dst_factor`, op | Result | Built | +|------|-------------------------------|--------|-------| +| Union | `One`, `One`, `Max` | `max(dst, src)` | M1 | +| Subtract | `Zero`, `OneMinusSrc`, `Add` | `dst · (1 − src)` | M1 | +| Intersect | `Zero`, `Src`, `Add` | `dst · src` | M2 | The middle row is `brush_erase`, already constructed in [`MaskPass::new`](../core/dr-gpu/src/mask.rs). The other two are the same -pipeline with a different `BlendState` — three pipeline variants over one -shader, no new bind group layout, no new shader code, and nothing read back. +three vertices with a different `BlendState`, and nothing is read back. + +**One correction to the first draft of this section, found in the building.** +It claimed no second texture was needed, because a part could be blended +straight onto the layer's slice. That is wrong, and the reason is the erase +stroke: an erase inside a part means *a hole in that part*, not a hole in the +mask. Drawn straight onto the accumulator it takes away whatever the parts +before it had put there — so tidying the edge of a correction punches through +the subject underneath, and the failure reads as the model's mask having holes +in it. + +So a part is drawn into one scratch texture (proxy-sized, `r8unorm`, allocated +the first time any layer has more than one part) and blended from there. A +layer of one part still takes the old path exactly — straight into its slice, +no scratch, no combine pass — which is what keeps every existing mask +rendering as it did. `an_erase_stroke_holes_its_own_part_and_not_the_mask` in +[`local_adjustments.rs`](../core/dr-gpu/tests/local_adjustments.rs) is the test +that holds this in place. `Max` blending on `r8unorm` is core WGPU and universally supported on the desktop backends; **verify it on the Android adapter before M2 lands**, since diff --git a/docs/requirements.md b/docs/requirements.md index 1719c64..1d785ec 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -495,6 +495,28 @@ blend without a halo — the boundary is the picture's own, at whatever detail t also the only route to selecting by skin tone, and the prerequisite for composing a selection from several criteria at once. +**FR-DEV-19 — Mask editing.** A mask layer's coverage shall be editable by hand after it is +created: painted into, erased from, and built from more than one selection. Every edit is stored as +geometry and parameters in the edit graph; no rasterised mask is written to a file and none exists +in CPU memory (ARCH §5.4). + +A model finds a subject in a second and, before this, the photographer could not then change it by a +single pixel. The coverage arrives approximately right — stopping inside a shoulder, leaking into +the hair — and FR-DEV-3's edge controls move the *whole* boundary, so no value of either fixes two +errors that go opposite ways. Every competing developer has had a brush over its selections for a +decade; a mask that cannot be corrected is the reason an edit leaves this application for a pixel +editor. + +**FR-DEV-19a — Mask composition.** A layer's mask is an ordered list of parts, each naming a source +and how it joins the mask before it — added to it, or taken out of it. A layer of one part is +exactly the layer that existed before this, and reads and writes the same sidecar. The parts of a +layer merge under FR-NC-9 as the layer does, and each carries its own edge treatment. + +**FR-DEV-19b — Hand correction.** A part may be painted, with add and erase strokes, at a radius, +hardness and flow the photographer sets. Strokes are stored as normalised source coordinates and +rasterised on the device, so a correction stays on what it was painted on through a crop, a zoom and +an export at any size. A whole stroke is one step in the history. + ### 3.4 Display and interaction **FR-DSP-1 — Proxy-resolution rendering.** The develop view renders at the resolution actually diff --git a/docs/traceability.md b/docs/traceability.md index 0bce650..0efd503 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -9,17 +9,17 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 345 | +| Source files scanned | 346 | | TRACES tags found | 1272 | -| Requirements defined | 183 | -| Requirements covered | 131 | -| **Coverage** | **71.6%** (131/183) | +| Requirements defined | 186 | +| Requirements covered | 133 | +| **Coverage** | **71.5%** (133/186) | ### By type | Type | Covered | Defined | |---|---|---| -| FR | 98 | 128 | +| FR | 100 | 131 | | NFR | 30 | 49 | | R | 3 | 6 | @@ -36,114 +36,116 @@ _None._ | FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:109`](../core/dr-catalog/src/walk.rs#L109), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-sync/src/scan.rs:118`](../core/dr-sync/src/scan.rs#L118), [`core/dr-types/src/lib.rs:203`](../core/dr-types/src/lib.rs#L203), [`core/dr-types/src/lib.rs:272`](../core/dr-types/src/lib.rs#L272), [`core/dr-types/src/lib.rs:305`](../core/dr-types/src/lib.rs#L305), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1) | | FR-CAT-10 | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-types/src/settings.rs:206`](../core/dr-types/src/settings.rs#L206), [`platform/dr-plat/src/storage.rs:287`](../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:586`](../platform/dr-plat/src/storage.rs#L586), [`platform/dr-plat/src/volumes.rs:1`](../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:336`](../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1379`](../ui/dr-ui/src/lib.rs#L1379), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:1033`](../ui/dr-ui/ui/library.slint#L1033), [`ui/dr-ui/ui/library.slint:1254`](../ui/dr-ui/ui/library.slint#L1254), [`ui/dr-ui/ui/library.slint:979`](../ui/dr-ui/ui/library.slint#L979) | | FR-CAT-11 | [`core/dr-catalog/src/dedup.rs:1`](../core/dr-catalog/src/dedup.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:392`](../core/dr-ingest/src/lib.rs#L392), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1379`](../ui/dr-ui/src/lib.rs#L1379), [`ui/dr-ui/src/library.rs:183`](../ui/dr-ui/src/library.rs#L183), [`ui/dr-ui/src/library.rs:2819`](../ui/dr-ui/src/library.rs#L2819), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | -| FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:120`](../core/dr-pipeline/src/sidecar.rs#L120) | +| FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:122`](../core/dr-pipeline/src/sidecar.rs#L122) | | FR-CAT-13 | [`core/dr-xmp/src/lib.rs:1`](../core/dr-xmp/src/lib.rs#L1), [`core/dr-xmp/src/lib.rs:331`](../core/dr-xmp/src/lib.rs#L331), [`core/dr-xmp/src/lib.rs:442`](../core/dr-xmp/src/lib.rs#L442), [`core/dr-xmp/src/lib.rs:535`](../core/dr-xmp/src/lib.rs#L535), [`core/dr-xmp/src/lib.rs:733`](../core/dr-xmp/src/lib.rs#L733), [`core/dr-xmp/src/lib.rs:746`](../core/dr-xmp/src/lib.rs#L746), [`core/dr-xmp/src/read.rs:1`](../core/dr-xmp/src/read.rs#L1), [`core/dr-xmp/src/write.rs:1`](../core/dr-xmp/src/write.rs#L1), [`core/dr-xmp/tests/round_trip.rs:1`](../core/dr-xmp/tests/round_trip.rs#L1) | -| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:813`](../core/dr-catalog/src/schema.rs#L813), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:457`](../core/dr-sync-nextcloud/src/lib.rs#L457), [`core/dr-sync/src/lib.rs:135`](../core/dr-sync/src/lib.rs#L135), [`core/dr-sync/src/scan.rs:604`](../core/dr-sync/src/scan.rs#L604), [`core/dr-sync/src/scan.rs:82`](../core/dr-sync/src/scan.rs#L82), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`core/dr-types/src/place.rs:91`](../core/dr-types/src/place.rs#L91), [`ui/dr-ui/src/collections_ui.rs:1435`](../ui/dr-ui/src/collections_ui.rs#L1435), [`ui/dr-ui/src/collections_ui.rs:2341`](../ui/dr-ui/src/collections_ui.rs#L2341), [`ui/dr-ui/src/library.rs:183`](../ui/dr-ui/src/library.rs#L183), [`ui/dr-ui/src/library.rs:200`](../ui/dr-ui/src/library.rs#L200), [`ui/dr-ui/src/library.rs:249`](../ui/dr-ui/src/library.rs#L249), [`ui/dr-ui/src/library.rs:4440`](../ui/dr-ui/src/library.rs#L4440), [`ui/dr-ui/src/library.rs:4474`](../ui/dr-ui/src/library.rs#L4474), [`ui/dr-ui/src/library_ui.rs:238`](../ui/dr-ui/src/library_ui.rs#L238), [`ui/dr-ui/src/library_ui.rs:832`](../ui/dr-ui/src/library_ui.rs#L832), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:745`](../ui/dr-ui/ui/collections.slint#L745) | +| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:813`](../core/dr-catalog/src/schema.rs#L813), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:457`](../core/dr-sync-nextcloud/src/lib.rs#L457), [`core/dr-sync/src/lib.rs:135`](../core/dr-sync/src/lib.rs#L135), [`core/dr-sync/src/scan.rs:604`](../core/dr-sync/src/scan.rs#L604), [`core/dr-sync/src/scan.rs:82`](../core/dr-sync/src/scan.rs#L82), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`core/dr-types/src/place.rs:91`](../core/dr-types/src/place.rs#L91), [`ui/dr-ui/src/collections_ui.rs:1283`](../ui/dr-ui/src/collections_ui.rs#L1283), [`ui/dr-ui/src/collections_ui.rs:2189`](../ui/dr-ui/src/collections_ui.rs#L2189), [`ui/dr-ui/src/library.rs:183`](../ui/dr-ui/src/library.rs#L183), [`ui/dr-ui/src/library.rs:200`](../ui/dr-ui/src/library.rs#L200), [`ui/dr-ui/src/library.rs:249`](../ui/dr-ui/src/library.rs#L249), [`ui/dr-ui/src/library.rs:4440`](../ui/dr-ui/src/library.rs#L4440), [`ui/dr-ui/src/library.rs:4474`](../ui/dr-ui/src/library.rs#L4474), [`ui/dr-ui/src/library_ui.rs:238`](../ui/dr-ui/src/library_ui.rs#L238), [`ui/dr-ui/src/library_ui.rs:838`](../ui/dr-ui/src/library_ui.rs#L838), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:621`](../ui/dr-ui/ui/collections.slint#L621) | | FR-CAT-1a | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:56`](../core/dr-types/src/lib.rs#L56), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`platform/dr-plat/src/storage.rs:46`](../platform/dr-plat/src/storage.rs#L46) | | FR-CAT-2 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1) | -| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/walk.rs:66`](../core/dr-catalog/src/walk.rs#L66), [`core/dr-sync/src/scan.rs:94`](../core/dr-sync/src/scan.rs#L94), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/import.rs:463`](../ui/dr-ui/src/import.rs#L463), [`ui/dr-ui/src/import.rs:488`](../ui/dr-ui/src/import.rs#L488), [`ui/dr-ui/src/library.rs:3292`](../ui/dr-ui/src/library.rs#L3292), [`ui/dr-ui/src/library.rs:3883`](../ui/dr-ui/src/library.rs#L3883), [`ui/dr-ui/src/library_ui.rs:177`](../ui/dr-ui/src/library_ui.rs#L177), [`ui/dr-ui/src/library_ui.rs:4230`](../ui/dr-ui/src/library_ui.rs#L4230), [`ui/dr-ui/src/library_ui.rs:5729`](../ui/dr-ui/src/library_ui.rs#L5729), [`ui/dr-ui/ui/app.slint:491`](../ui/dr-ui/ui/app.slint#L491), [`ui/dr-ui/ui/settings.slint:395`](../ui/dr-ui/ui/settings.slint#L395), [`ui/dr-ui/ui/settings.slint:73`](../ui/dr-ui/ui/settings.slint#L73) | +| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/walk.rs:66`](../core/dr-catalog/src/walk.rs#L66), [`core/dr-sync/src/scan.rs:94`](../core/dr-sync/src/scan.rs#L94), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/import.rs:463`](../ui/dr-ui/src/import.rs#L463), [`ui/dr-ui/src/import.rs:488`](../ui/dr-ui/src/import.rs#L488), [`ui/dr-ui/src/library.rs:3292`](../ui/dr-ui/src/library.rs#L3292), [`ui/dr-ui/src/library.rs:3883`](../ui/dr-ui/src/library.rs#L3883), [`ui/dr-ui/src/library_ui.rs:177`](../ui/dr-ui/src/library_ui.rs#L177), [`ui/dr-ui/src/library_ui.rs:4236`](../ui/dr-ui/src/library_ui.rs#L4236), [`ui/dr-ui/src/library_ui.rs:5735`](../ui/dr-ui/src/library_ui.rs#L5735), [`ui/dr-ui/ui/app.slint:490`](../ui/dr-ui/ui/app.slint#L490), [`ui/dr-ui/ui/settings.slint:395`](../ui/dr-ui/ui/settings.slint#L395), [`ui/dr-ui/ui/settings.slint:73`](../ui/dr-ui/ui/settings.slint#L73) | | FR-CAT-4 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/schema.rs:356`](../core/dr-catalog/src/schema.rs#L356), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:230`](../ui/dr-ui/src/library.rs#L230), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) | -| FR-CAT-5 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:1231`](../core/dr-catalog/src/schema.rs#L1231), [`core/dr-catalog/src/schema.rs:728`](../core/dr-catalog/src/schema.rs#L728), [`core/dr-decode/src/lib.rs:285`](../core/dr-decode/src/lib.rs#L285), [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-pipeline/src/sidecar.rs:137`](../core/dr-pipeline/src/sidecar.rs#L137), [`ui/dr-ui/src/collections_ui.rs:1731`](../ui/dr-ui/src/collections_ui.rs#L1731), [`ui/dr-ui/src/collections_ui.rs:1837`](../ui/dr-ui/src/collections_ui.rs#L1837), [`ui/dr-ui/src/collections_ui.rs:1886`](../ui/dr-ui/src/collections_ui.rs#L1886), [`ui/dr-ui/src/collections_ui.rs:275`](../ui/dr-ui/src/collections_ui.rs#L275), [`ui/dr-ui/src/collections_ui.rs:484`](../ui/dr-ui/src/collections_ui.rs#L484), [`ui/dr-ui/src/collections_ui.rs:87`](../ui/dr-ui/src/collections_ui.rs#L87), [`ui/dr-ui/src/library.rs:4488`](../ui/dr-ui/src/library.rs#L4488), [`ui/dr-ui/src/library_ui.rs:707`](../ui/dr-ui/src/library_ui.rs#L707), [`ui/dr-ui/src/library_ui.rs:7688`](../ui/dr-ui/src/library_ui.rs#L7688), [`ui/dr-ui/src/library_ui.rs:7699`](../ui/dr-ui/src/library_ui.rs#L7699), [`ui/dr-ui/src/library_ui.rs:7712`](../ui/dr-ui/src/library_ui.rs#L7712), [`ui/dr-ui/src/library_ui.rs:7727`](../ui/dr-ui/src/library_ui.rs#L7727), [`ui/dr-ui/src/library_ui.rs:7736`](../ui/dr-ui/src/library_ui.rs#L7736), [`ui/dr-ui/ui/app.slint:412`](../ui/dr-ui/ui/app.slint#L412), [`ui/dr-ui/ui/app.slint:677`](../ui/dr-ui/ui/app.slint#L677), [`ui/dr-ui/ui/library.slint:1360`](../ui/dr-ui/ui/library.slint#L1360), [`ui/dr-ui/ui/library.slint:1384`](../ui/dr-ui/ui/library.slint#L1384), [`ui/dr-ui/ui/library.slint:1437`](../ui/dr-ui/ui/library.slint#L1437), [`ui/dr-ui/ui/library.slint:19`](../ui/dr-ui/ui/library.slint#L19), [`ui/dr-ui/ui/library.slint:4201`](../ui/dr-ui/ui/library.slint#L4201) | -| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:728`](../core/dr-catalog/src/schema.rs#L728), [`core/dr-types/src/place.rs:1`](../core/dr-types/src/place.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:64`](../core/dr-types/src/settings.rs#L64), [`core/dr-types/src/time.rs:67`](../core/dr-types/src/time.rs#L67), [`core/dr-types/src/time.rs:90`](../core/dr-types/src/time.rs#L90), [`ui/dr-ui/src/library.rs:254`](../ui/dr-ui/src/library.rs#L254), [`ui/dr-ui/src/library.rs:4964`](../ui/dr-ui/src/library.rs#L4964), [`ui/dr-ui/src/library_ui.rs:384`](../ui/dr-ui/src/library_ui.rs#L384), [`ui/dr-ui/src/library_ui.rs:464`](../ui/dr-ui/src/library_ui.rs#L464), [`ui/dr-ui/src/library_ui.rs:6451`](../ui/dr-ui/src/library_ui.rs#L6451), [`ui/dr-ui/src/library_ui.rs:6504`](../ui/dr-ui/src/library_ui.rs#L6504), [`ui/dr-ui/src/library_ui.rs:7828`](../ui/dr-ui/src/library_ui.rs#L7828), [`ui/dr-ui/ui/app.slint:1007`](../ui/dr-ui/ui/app.slint#L1007), [`ui/dr-ui/ui/app.slint:424`](../ui/dr-ui/ui/app.slint#L424), [`ui/dr-ui/ui/app.slint:677`](../ui/dr-ui/ui/app.slint#L677), [`ui/dr-ui/ui/library.slint:111`](../ui/dr-ui/ui/library.slint#L111), [`ui/dr-ui/ui/library.slint:1547`](../ui/dr-ui/ui/library.slint#L1547), [`ui/dr-ui/ui/library.slint:4201`](../ui/dr-ui/ui/library.slint#L4201), [`ui/dr-ui/ui/settings.slint:158`](../ui/dr-ui/ui/settings.slint#L158) | -| FR-CAT-7 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1117`](../ui/dr-ui/src/collections_ui.rs#L1117), [`ui/dr-ui/src/collections_ui.rs:117`](../ui/dr-ui/src/collections_ui.rs#L117), [`ui/dr-ui/src/collections_ui.rs:1225`](../ui/dr-ui/src/collections_ui.rs#L1225), [`ui/dr-ui/src/collections_ui.rs:1704`](../ui/dr-ui/src/collections_ui.rs#L1704), [`ui/dr-ui/src/collections_ui.rs:1750`](../ui/dr-ui/src/collections_ui.rs#L1750), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/collections_ui.rs:2000`](../ui/dr-ui/src/collections_ui.rs#L2000), [`ui/dr-ui/src/collections_ui.rs:2065`](../ui/dr-ui/src/collections_ui.rs#L2065), [`ui/dr-ui/src/collections_ui.rs:2125`](../ui/dr-ui/src/collections_ui.rs#L2125), [`ui/dr-ui/src/collections_ui.rs:2701`](../ui/dr-ui/src/collections_ui.rs#L2701), [`ui/dr-ui/src/collections_ui.rs:2755`](../ui/dr-ui/src/collections_ui.rs#L2755), [`ui/dr-ui/src/collections_ui.rs:2801`](../ui/dr-ui/src/collections_ui.rs#L2801), [`ui/dr-ui/src/collections_ui.rs:2903`](../ui/dr-ui/src/collections_ui.rs#L2903), [`ui/dr-ui/src/collections_ui.rs:2959`](../ui/dr-ui/src/collections_ui.rs#L2959), [`ui/dr-ui/src/collections_ui.rs:3120`](../ui/dr-ui/src/collections_ui.rs#L3120), [`ui/dr-ui/src/collections_ui.rs:3142`](../ui/dr-ui/src/collections_ui.rs#L3142), [`ui/dr-ui/src/collections_ui.rs:3208`](../ui/dr-ui/src/collections_ui.rs#L3208), [`ui/dr-ui/src/collections_ui.rs:329`](../ui/dr-ui/src/collections_ui.rs#L329), [`ui/dr-ui/src/collections_ui.rs:3328`](../ui/dr-ui/src/collections_ui.rs#L3328), [`ui/dr-ui/src/collections_ui.rs:339`](../ui/dr-ui/src/collections_ui.rs#L339), [`ui/dr-ui/src/collections_ui.rs:362`](../ui/dr-ui/src/collections_ui.rs#L362), [`ui/dr-ui/src/collections_ui.rs:3633`](../ui/dr-ui/src/collections_ui.rs#L3633), [`ui/dr-ui/src/collections_ui.rs:383`](../ui/dr-ui/src/collections_ui.rs#L383), [`ui/dr-ui/src/collections_ui.rs:514`](../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:718`](../ui/dr-ui/src/collections_ui.rs#L718), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:4769`](../ui/dr-ui/src/library.rs#L4769), [`ui/dr-ui/src/library.rs:4795`](../ui/dr-ui/src/library.rs#L4795), [`ui/dr-ui/src/library.rs:4850`](../ui/dr-ui/src/library.rs#L4850), [`ui/dr-ui/src/library_ui.rs:2694`](../ui/dr-ui/src/library_ui.rs#L2694), [`ui/dr-ui/src/library_ui.rs:4092`](../ui/dr-ui/src/library_ui.rs#L4092), [`ui/dr-ui/src/library_ui.rs:4843`](../ui/dr-ui/src/library_ui.rs#L4843), [`ui/dr-ui/ui/app.slint:3271`](../ui/dr-ui/ui/app.slint#L3271), [`ui/dr-ui/ui/app.slint:415`](../ui/dr-ui/ui/app.slint#L415), [`ui/dr-ui/ui/app.slint:672`](../ui/dr-ui/ui/app.slint#L672), [`ui/dr-ui/ui/app.slint:701`](../ui/dr-ui/ui/app.slint#L701), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:587`](../ui/dr-ui/ui/collections.slint#L587), [`ui/dr-ui/ui/library.slint:1327`](../ui/dr-ui/ui/library.slint#L1327), [`ui/dr-ui/ui/library.slint:2902`](../ui/dr-ui/ui/library.slint#L2902), [`ui/dr-ui/ui/library.slint:4179`](../ui/dr-ui/ui/library.slint#L4179), [`ui/dr-ui/ui/library.slint:4257`](../ui/dr-ui/ui/library.slint#L4257), [`ui/dr-ui/ui/library.slint:998`](../ui/dr-ui/ui/library.slint#L998) | -| FR-CAT-8 | [`core/dr-catalog/src/schema.rs:532`](../core/dr-catalog/src/schema.rs#L532), [`core/dr-pipeline/src/coverage.rs:1`](../core/dr-pipeline/src/coverage.rs#L1), [`core/dr-pipeline/src/graph.rs:492`](../core/dr-pipeline/src/graph.rs#L492), [`core/dr-pipeline/src/graph.rs:540`](../core/dr-pipeline/src/graph.rs#L540), [`core/dr-pipeline/src/ops/curve.rs:137`](../core/dr-pipeline/src/ops/curve.rs#L137), [`core/dr-pipeline/src/ops/curve.rs:656`](../core/dr-pipeline/src/ops/curve.rs#L656), [`core/dr-pipeline/src/sidecar.rs:1205`](../core/dr-pipeline/src/sidecar.rs#L1205), [`core/dr-pipeline/src/sidecar.rs:1918`](../core/dr-pipeline/src/sidecar.rs#L1918), [`core/dr-pipeline/src/sidecar.rs:94`](../core/dr-pipeline/src/sidecar.rs#L94), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`core/dr-pipeline/tests/tone_curve.rs:34`](../core/dr-pipeline/tests/tone_curve.rs#L34), [`core/dr-sync/src/scan.rs:31`](../core/dr-sync/src/scan.rs#L31), [`core/dr-sync/src/scan.rs:45`](../core/dr-sync/src/scan.rs#L45), [`core/dr-sync/src/scan.rs:912`](../core/dr-sync/src/scan.rs#L912), [`ui/dr-ui/src/develop.rs:2000`](../ui/dr-ui/src/develop.rs#L2000), [`ui/dr-ui/src/develop.rs:4758`](../ui/dr-ui/src/develop.rs#L4758), [`ui/dr-ui/src/develop.rs:4787`](../ui/dr-ui/src/develop.rs#L4787), [`ui/dr-ui/src/develop.rs:5867`](../ui/dr-ui/src/develop.rs#L5867), [`ui/dr-ui/src/develop.rs:5921`](../ui/dr-ui/src/develop.rs#L5921), [`ui/dr-ui/src/develop.rs:6023`](../ui/dr-ui/src/develop.rs#L6023), [`ui/dr-ui/src/export.rs:799`](../ui/dr-ui/src/export.rs#L799), [`ui/dr-ui/src/lib.rs:1157`](../ui/dr-ui/src/lib.rs#L1157), [`ui/dr-ui/src/lib.rs:1673`](../ui/dr-ui/src/lib.rs#L1673), [`ui/dr-ui/src/lib.rs:2216`](../ui/dr-ui/src/lib.rs#L2216), [`ui/dr-ui/src/lib.rs:2353`](../ui/dr-ui/src/lib.rs#L2353), [`ui/dr-ui/src/lib.rs:709`](../ui/dr-ui/src/lib.rs#L709), [`ui/dr-ui/src/library.rs:1376`](../ui/dr-ui/src/library.rs#L1376), [`ui/dr-ui/src/library.rs:1411`](../ui/dr-ui/src/library.rs#L1411), [`ui/dr-ui/src/library.rs:2219`](../ui/dr-ui/src/library.rs#L2219), [`ui/dr-ui/src/library.rs:500`](../ui/dr-ui/src/library.rs#L500), [`ui/dr-ui/src/library.rs:547`](../ui/dr-ui/src/library.rs#L547), [`ui/dr-ui/src/library.rs:584`](../ui/dr-ui/src/library.rs#L584), [`ui/dr-ui/src/library.rs:7144`](../ui/dr-ui/src/library.rs#L7144), [`ui/dr-ui/src/library.rs:73`](../ui/dr-ui/src/library.rs#L73), [`ui/dr-ui/src/library.rs:845`](../ui/dr-ui/src/library.rs#L845), [`ui/dr-ui/src/library_ui.rs:1259`](../ui/dr-ui/src/library_ui.rs#L1259), [`ui/dr-ui/src/library_ui.rs:6052`](../ui/dr-ui/src/library_ui.rs#L6052), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | -| FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:785`](../core/dr-catalog/src/schema.rs#L785), [`core/dr-catalog/src/walk.rs:1022`](../core/dr-catalog/src/walk.rs#L1022), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:728`](../core/dr-catalog/src/walk.rs#L728), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/error.rs:113`](../core/dr-sync/src/error.rs#L113), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-sync/src/scan.rs:200`](../core/dr-sync/src/scan.rs#L200), [`core/dr-sync/src/scan.rs:505`](../core/dr-sync/src/scan.rs#L505), [`core/dr-types/src/lib.rs:122`](../core/dr-types/src/lib.rs#L122), [`ui/dr-ui/src/develop.rs:4047`](../ui/dr-ui/src/develop.rs#L4047), [`ui/dr-ui/src/develop.rs:4090`](../ui/dr-ui/src/develop.rs#L4090), [`ui/dr-ui/src/library.rs:1276`](../ui/dr-ui/src/library.rs#L1276), [`ui/dr-ui/src/library.rs:1329`](../ui/dr-ui/src/library.rs#L1329), [`ui/dr-ui/src/library.rs:1360`](../ui/dr-ui/src/library.rs#L1360), [`ui/dr-ui/src/library.rs:1411`](../ui/dr-ui/src/library.rs#L1411), [`ui/dr-ui/src/library.rs:169`](../ui/dr-ui/src/library.rs#L169), [`ui/dr-ui/src/library.rs:1727`](../ui/dr-ui/src/library.rs#L1727), [`ui/dr-ui/src/library.rs:2179`](../ui/dr-ui/src/library.rs#L2179), [`ui/dr-ui/src/library.rs:2255`](../ui/dr-ui/src/library.rs#L2255), [`ui/dr-ui/src/library.rs:274`](../ui/dr-ui/src/library.rs#L274), [`ui/dr-ui/src/library.rs:5072`](../ui/dr-ui/src/library.rs#L5072), [`ui/dr-ui/src/library.rs:584`](../ui/dr-ui/src/library.rs#L584), [`ui/dr-ui/src/library.rs:829`](../ui/dr-ui/src/library.rs#L829), [`ui/dr-ui/src/library.rs:845`](../ui/dr-ui/src/library.rs#L845), [`ui/dr-ui/src/library.rs:899`](../ui/dr-ui/src/library.rs#L899), [`ui/dr-ui/src/library_ui.rs:1326`](../ui/dr-ui/src/library_ui.rs#L1326), [`ui/dr-ui/src/library_ui.rs:1917`](../ui/dr-ui/src/library_ui.rs#L1917), [`ui/dr-ui/src/library_ui.rs:1961`](../ui/dr-ui/src/library_ui.rs#L1961), [`ui/dr-ui/src/library_ui.rs:1977`](../ui/dr-ui/src/library_ui.rs#L1977), [`ui/dr-ui/src/library_ui.rs:2071`](../ui/dr-ui/src/library_ui.rs#L2071), [`ui/dr-ui/src/library_ui.rs:284`](../ui/dr-ui/src/library_ui.rs#L284), [`ui/dr-ui/src/library_ui.rs:2934`](../ui/dr-ui/src/library_ui.rs#L2934), [`ui/dr-ui/src/library_ui.rs:317`](../ui/dr-ui/src/library_ui.rs#L317), [`ui/dr-ui/src/library_ui.rs:325`](../ui/dr-ui/src/library_ui.rs#L325), [`ui/dr-ui/src/library_ui.rs:3372`](../ui/dr-ui/src/library_ui.rs#L3372), [`ui/dr-ui/src/library_ui.rs:3594`](../ui/dr-ui/src/library_ui.rs#L3594), [`ui/dr-ui/src/library_ui.rs:3872`](../ui/dr-ui/src/library_ui.rs#L3872), [`ui/dr-ui/src/library_ui.rs:3960`](../ui/dr-ui/src/library_ui.rs#L3960), [`ui/dr-ui/src/library_ui.rs:4143`](../ui/dr-ui/src/library_ui.rs#L4143), [`ui/dr-ui/src/library_ui.rs:4257`](../ui/dr-ui/src/library_ui.rs#L4257), [`ui/dr-ui/src/library_ui.rs:517`](../ui/dr-ui/src/library_ui.rs#L517), [`ui/dr-ui/src/library_ui.rs:580`](../ui/dr-ui/src/library_ui.rs#L580), [`ui/dr-ui/src/library_ui.rs:6549`](../ui/dr-ui/src/library_ui.rs#L6549), [`ui/dr-ui/src/library_ui.rs:6631`](../ui/dr-ui/src/library_ui.rs#L6631), [`ui/dr-ui/src/presets.rs:468`](../ui/dr-ui/src/presets.rs#L468), [`ui/dr-ui/src/presets.rs:480`](../ui/dr-ui/src/presets.rs#L480), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1), [`ui/dr-ui/tests/export_ignores_the_viewport.rs:1`](../ui/dr-ui/tests/export_ignores_the_viewport.rs#L1) | +| FR-CAT-5 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:1231`](../core/dr-catalog/src/schema.rs#L1231), [`core/dr-catalog/src/schema.rs:728`](../core/dr-catalog/src/schema.rs#L728), [`core/dr-decode/src/lib.rs:285`](../core/dr-decode/src/lib.rs#L285), [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-pipeline/src/sidecar.rs:139`](../core/dr-pipeline/src/sidecar.rs#L139), [`ui/dr-ui/src/collections_ui.rs:1579`](../ui/dr-ui/src/collections_ui.rs#L1579), [`ui/dr-ui/src/collections_ui.rs:1685`](../ui/dr-ui/src/collections_ui.rs#L1685), [`ui/dr-ui/src/collections_ui.rs:1734`](../ui/dr-ui/src/collections_ui.rs#L1734), [`ui/dr-ui/src/collections_ui.rs:238`](../ui/dr-ui/src/collections_ui.rs#L238), [`ui/dr-ui/src/collections_ui.rs:335`](../ui/dr-ui/src/collections_ui.rs#L335), [`ui/dr-ui/src/collections_ui.rs:87`](../ui/dr-ui/src/collections_ui.rs#L87), [`ui/dr-ui/src/library.rs:4488`](../ui/dr-ui/src/library.rs#L4488), [`ui/dr-ui/src/library_ui.rs:713`](../ui/dr-ui/src/library_ui.rs#L713), [`ui/dr-ui/src/library_ui.rs:7727`](../ui/dr-ui/src/library_ui.rs#L7727), [`ui/dr-ui/src/library_ui.rs:7738`](../ui/dr-ui/src/library_ui.rs#L7738), [`ui/dr-ui/src/library_ui.rs:7751`](../ui/dr-ui/src/library_ui.rs#L7751), [`ui/dr-ui/src/library_ui.rs:7766`](../ui/dr-ui/src/library_ui.rs#L7766), [`ui/dr-ui/src/library_ui.rs:7775`](../ui/dr-ui/src/library_ui.rs#L7775), [`ui/dr-ui/ui/app.slint:411`](../ui/dr-ui/ui/app.slint#L411), [`ui/dr-ui/ui/app.slint:633`](../ui/dr-ui/ui/app.slint#L633), [`ui/dr-ui/ui/library.slint:1360`](../ui/dr-ui/ui/library.slint#L1360), [`ui/dr-ui/ui/library.slint:1384`](../ui/dr-ui/ui/library.slint#L1384), [`ui/dr-ui/ui/library.slint:1437`](../ui/dr-ui/ui/library.slint#L1437), [`ui/dr-ui/ui/library.slint:19`](../ui/dr-ui/ui/library.slint#L19), [`ui/dr-ui/ui/library.slint:4195`](../ui/dr-ui/ui/library.slint#L4195) | +| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:728`](../core/dr-catalog/src/schema.rs#L728), [`core/dr-types/src/place.rs:1`](../core/dr-types/src/place.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:64`](../core/dr-types/src/settings.rs#L64), [`core/dr-types/src/time.rs:67`](../core/dr-types/src/time.rs#L67), [`core/dr-types/src/time.rs:90`](../core/dr-types/src/time.rs#L90), [`ui/dr-ui/src/library.rs:254`](../ui/dr-ui/src/library.rs#L254), [`ui/dr-ui/src/library.rs:4964`](../ui/dr-ui/src/library.rs#L4964), [`ui/dr-ui/src/library_ui.rs:389`](../ui/dr-ui/src/library_ui.rs#L389), [`ui/dr-ui/src/library_ui.rs:470`](../ui/dr-ui/src/library_ui.rs#L470), [`ui/dr-ui/src/library_ui.rs:6457`](../ui/dr-ui/src/library_ui.rs#L6457), [`ui/dr-ui/src/library_ui.rs:6510`](../ui/dr-ui/src/library_ui.rs#L6510), [`ui/dr-ui/src/library_ui.rs:7867`](../ui/dr-ui/src/library_ui.rs#L7867), [`ui/dr-ui/ui/app.slint:423`](../ui/dr-ui/ui/app.slint#L423), [`ui/dr-ui/ui/app.slint:633`](../ui/dr-ui/ui/app.slint#L633), [`ui/dr-ui/ui/app.slint:951`](../ui/dr-ui/ui/app.slint#L951), [`ui/dr-ui/ui/library.slint:111`](../ui/dr-ui/ui/library.slint#L111), [`ui/dr-ui/ui/library.slint:1547`](../ui/dr-ui/ui/library.slint#L1547), [`ui/dr-ui/ui/library.slint:4195`](../ui/dr-ui/ui/library.slint#L4195), [`ui/dr-ui/ui/settings.slint:158`](../ui/dr-ui/ui/settings.slint#L158) | +| FR-CAT-7 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1073`](../ui/dr-ui/src/collections_ui.rs#L1073), [`ui/dr-ui/src/collections_ui.rs:117`](../ui/dr-ui/src/collections_ui.rs#L117), [`ui/dr-ui/src/collections_ui.rs:1552`](../ui/dr-ui/src/collections_ui.rs#L1552), [`ui/dr-ui/src/collections_ui.rs:1598`](../ui/dr-ui/src/collections_ui.rs#L1598), [`ui/dr-ui/src/collections_ui.rs:1848`](../ui/dr-ui/src/collections_ui.rs#L1848), [`ui/dr-ui/src/collections_ui.rs:1913`](../ui/dr-ui/src/collections_ui.rs#L1913), [`ui/dr-ui/src/collections_ui.rs:1973`](../ui/dr-ui/src/collections_ui.rs#L1973), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/collections_ui.rs:2963`](../ui/dr-ui/src/collections_ui.rs#L2963), [`ui/dr-ui/src/collections_ui.rs:365`](../ui/dr-ui/src/collections_ui.rs#L365), [`ui/dr-ui/src/collections_ui.rs:569`](../ui/dr-ui/src/collections_ui.rs#L569), [`ui/dr-ui/src/collections_ui.rs:965`](../ui/dr-ui/src/collections_ui.rs#L965), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:4769`](../ui/dr-ui/src/library.rs#L4769), [`ui/dr-ui/src/library.rs:4795`](../ui/dr-ui/src/library.rs#L4795), [`ui/dr-ui/src/library.rs:4850`](../ui/dr-ui/src/library.rs#L4850), [`ui/dr-ui/src/library_ui.rs:2700`](../ui/dr-ui/src/library_ui.rs#L2700), [`ui/dr-ui/src/library_ui.rs:4098`](../ui/dr-ui/src/library_ui.rs#L4098), [`ui/dr-ui/src/library_ui.rs:4849`](../ui/dr-ui/src/library_ui.rs#L4849), [`ui/dr-ui/ui/app.slint:414`](../ui/dr-ui/ui/app.slint#L414), [`ui/dr-ui/ui/app.slint:628`](../ui/dr-ui/ui/app.slint#L628), [`ui/dr-ui/ui/app.slint:657`](../ui/dr-ui/ui/app.slint#L657), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:1327`](../ui/dr-ui/ui/library.slint#L1327), [`ui/dr-ui/ui/library.slint:2896`](../ui/dr-ui/ui/library.slint#L2896), [`ui/dr-ui/ui/library.slint:4173`](../ui/dr-ui/ui/library.slint#L4173), [`ui/dr-ui/ui/library.slint:998`](../ui/dr-ui/ui/library.slint#L998) | +| FR-CAT-8 | [`core/dr-catalog/src/schema.rs:532`](../core/dr-catalog/src/schema.rs#L532), [`core/dr-pipeline/src/coverage.rs:1`](../core/dr-pipeline/src/coverage.rs#L1), [`core/dr-pipeline/src/graph.rs:492`](../core/dr-pipeline/src/graph.rs#L492), [`core/dr-pipeline/src/graph.rs:540`](../core/dr-pipeline/src/graph.rs#L540), [`core/dr-pipeline/src/ops/curve.rs:137`](../core/dr-pipeline/src/ops/curve.rs#L137), [`core/dr-pipeline/src/ops/curve.rs:656`](../core/dr-pipeline/src/ops/curve.rs#L656), [`core/dr-pipeline/src/sidecar.rs:1288`](../core/dr-pipeline/src/sidecar.rs#L1288), [`core/dr-pipeline/src/sidecar.rs:2114`](../core/dr-pipeline/src/sidecar.rs#L2114), [`core/dr-pipeline/src/sidecar.rs:96`](../core/dr-pipeline/src/sidecar.rs#L96), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`core/dr-pipeline/tests/tone_curve.rs:34`](../core/dr-pipeline/tests/tone_curve.rs#L34), [`core/dr-sync/src/scan.rs:31`](../core/dr-sync/src/scan.rs#L31), [`core/dr-sync/src/scan.rs:45`](../core/dr-sync/src/scan.rs#L45), [`core/dr-sync/src/scan.rs:912`](../core/dr-sync/src/scan.rs#L912), [`ui/dr-ui/src/develop.rs:2034`](../ui/dr-ui/src/develop.rs#L2034), [`ui/dr-ui/src/develop.rs:5060`](../ui/dr-ui/src/develop.rs#L5060), [`ui/dr-ui/src/develop.rs:5089`](../ui/dr-ui/src/develop.rs#L5089), [`ui/dr-ui/src/develop.rs:6169`](../ui/dr-ui/src/develop.rs#L6169), [`ui/dr-ui/src/develop.rs:6223`](../ui/dr-ui/src/develop.rs#L6223), [`ui/dr-ui/src/develop.rs:6325`](../ui/dr-ui/src/develop.rs#L6325), [`ui/dr-ui/src/export.rs:799`](../ui/dr-ui/src/export.rs#L799), [`ui/dr-ui/src/lib.rs:1157`](../ui/dr-ui/src/lib.rs#L1157), [`ui/dr-ui/src/lib.rs:1673`](../ui/dr-ui/src/lib.rs#L1673), [`ui/dr-ui/src/lib.rs:2216`](../ui/dr-ui/src/lib.rs#L2216), [`ui/dr-ui/src/lib.rs:2353`](../ui/dr-ui/src/lib.rs#L2353), [`ui/dr-ui/src/lib.rs:709`](../ui/dr-ui/src/lib.rs#L709), [`ui/dr-ui/src/library.rs:1376`](../ui/dr-ui/src/library.rs#L1376), [`ui/dr-ui/src/library.rs:1411`](../ui/dr-ui/src/library.rs#L1411), [`ui/dr-ui/src/library.rs:2219`](../ui/dr-ui/src/library.rs#L2219), [`ui/dr-ui/src/library.rs:500`](../ui/dr-ui/src/library.rs#L500), [`ui/dr-ui/src/library.rs:547`](../ui/dr-ui/src/library.rs#L547), [`ui/dr-ui/src/library.rs:584`](../ui/dr-ui/src/library.rs#L584), [`ui/dr-ui/src/library.rs:7144`](../ui/dr-ui/src/library.rs#L7144), [`ui/dr-ui/src/library.rs:73`](../ui/dr-ui/src/library.rs#L73), [`ui/dr-ui/src/library.rs:845`](../ui/dr-ui/src/library.rs#L845), [`ui/dr-ui/src/library_ui.rs:1265`](../ui/dr-ui/src/library_ui.rs#L1265), [`ui/dr-ui/src/library_ui.rs:6058`](../ui/dr-ui/src/library_ui.rs#L6058), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | +| FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:785`](../core/dr-catalog/src/schema.rs#L785), [`core/dr-catalog/src/walk.rs:1022`](../core/dr-catalog/src/walk.rs#L1022), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:728`](../core/dr-catalog/src/walk.rs#L728), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/error.rs:113`](../core/dr-sync/src/error.rs#L113), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-sync/src/scan.rs:200`](../core/dr-sync/src/scan.rs#L200), [`core/dr-sync/src/scan.rs:505`](../core/dr-sync/src/scan.rs#L505), [`core/dr-types/src/lib.rs:122`](../core/dr-types/src/lib.rs#L122), [`ui/dr-ui/src/develop.rs:4349`](../ui/dr-ui/src/develop.rs#L4349), [`ui/dr-ui/src/develop.rs:4392`](../ui/dr-ui/src/develop.rs#L4392), [`ui/dr-ui/src/library.rs:1276`](../ui/dr-ui/src/library.rs#L1276), [`ui/dr-ui/src/library.rs:1329`](../ui/dr-ui/src/library.rs#L1329), [`ui/dr-ui/src/library.rs:1360`](../ui/dr-ui/src/library.rs#L1360), [`ui/dr-ui/src/library.rs:1411`](../ui/dr-ui/src/library.rs#L1411), [`ui/dr-ui/src/library.rs:169`](../ui/dr-ui/src/library.rs#L169), [`ui/dr-ui/src/library.rs:1727`](../ui/dr-ui/src/library.rs#L1727), [`ui/dr-ui/src/library.rs:2179`](../ui/dr-ui/src/library.rs#L2179), [`ui/dr-ui/src/library.rs:2255`](../ui/dr-ui/src/library.rs#L2255), [`ui/dr-ui/src/library.rs:274`](../ui/dr-ui/src/library.rs#L274), [`ui/dr-ui/src/library.rs:5072`](../ui/dr-ui/src/library.rs#L5072), [`ui/dr-ui/src/library.rs:584`](../ui/dr-ui/src/library.rs#L584), [`ui/dr-ui/src/library.rs:829`](../ui/dr-ui/src/library.rs#L829), [`ui/dr-ui/src/library.rs:845`](../ui/dr-ui/src/library.rs#L845), [`ui/dr-ui/src/library.rs:899`](../ui/dr-ui/src/library.rs#L899), [`ui/dr-ui/src/library_ui.rs:1332`](../ui/dr-ui/src/library_ui.rs#L1332), [`ui/dr-ui/src/library_ui.rs:1923`](../ui/dr-ui/src/library_ui.rs#L1923), [`ui/dr-ui/src/library_ui.rs:1967`](../ui/dr-ui/src/library_ui.rs#L1967), [`ui/dr-ui/src/library_ui.rs:1983`](../ui/dr-ui/src/library_ui.rs#L1983), [`ui/dr-ui/src/library_ui.rs:2077`](../ui/dr-ui/src/library_ui.rs#L2077), [`ui/dr-ui/src/library_ui.rs:284`](../ui/dr-ui/src/library_ui.rs#L284), [`ui/dr-ui/src/library_ui.rs:2940`](../ui/dr-ui/src/library_ui.rs#L2940), [`ui/dr-ui/src/library_ui.rs:317`](../ui/dr-ui/src/library_ui.rs#L317), [`ui/dr-ui/src/library_ui.rs:325`](../ui/dr-ui/src/library_ui.rs#L325), [`ui/dr-ui/src/library_ui.rs:3378`](../ui/dr-ui/src/library_ui.rs#L3378), [`ui/dr-ui/src/library_ui.rs:3600`](../ui/dr-ui/src/library_ui.rs#L3600), [`ui/dr-ui/src/library_ui.rs:3878`](../ui/dr-ui/src/library_ui.rs#L3878), [`ui/dr-ui/src/library_ui.rs:3966`](../ui/dr-ui/src/library_ui.rs#L3966), [`ui/dr-ui/src/library_ui.rs:4149`](../ui/dr-ui/src/library_ui.rs#L4149), [`ui/dr-ui/src/library_ui.rs:4263`](../ui/dr-ui/src/library_ui.rs#L4263), [`ui/dr-ui/src/library_ui.rs:523`](../ui/dr-ui/src/library_ui.rs#L523), [`ui/dr-ui/src/library_ui.rs:586`](../ui/dr-ui/src/library_ui.rs#L586), [`ui/dr-ui/src/library_ui.rs:6555`](../ui/dr-ui/src/library_ui.rs#L6555), [`ui/dr-ui/src/library_ui.rs:6670`](../ui/dr-ui/src/library_ui.rs#L6670), [`ui/dr-ui/src/presets.rs:468`](../ui/dr-ui/src/presets.rs#L468), [`ui/dr-ui/src/presets.rs:480`](../ui/dr-ui/src/presets.rs#L480), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1), [`ui/dr-ui/tests/export_ignores_the_viewport.rs:1`](../ui/dr-ui/tests/export_ignores_the_viewport.rs#L1) | | FR-CULL-1 | [`core/dr-decode/src/preview.rs:121`](../core/dr-decode/src/preview.rs#L121) | -| FR-CULL-10 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:395`](../core/dr-catalog/src/schema.rs#L395), [`core/dr-catalog/src/schema.rs:614`](../core/dr-catalog/src/schema.rs#L614), [`core/dr-face/src/assign.rs:1`](../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../core/dr-face/src/neighbours.rs#L1), [`core/dr-types/src/settings.rs:117`](../core/dr-types/src/settings.rs#L117), [`ui/dr-ui/src/develop.rs:121`](../ui/dr-ui/src/develop.rs#L121), [`ui/dr-ui/src/develop.rs:130`](../ui/dr-ui/src/develop.rs#L130), [`ui/dr-ui/src/develop.rs:196`](../ui/dr-ui/src/develop.rs#L196), [`ui/dr-ui/src/develop.rs:2245`](../ui/dr-ui/src/develop.rs#L2245), [`ui/dr-ui/src/develop.rs:689`](../ui/dr-ui/src/develop.rs#L689), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2325`](../ui/dr-ui/src/lib.rs#L2325), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | +| FR-CULL-10 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:395`](../core/dr-catalog/src/schema.rs#L395), [`core/dr-catalog/src/schema.rs:614`](../core/dr-catalog/src/schema.rs#L614), [`core/dr-face/src/assign.rs:1`](../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../core/dr-face/src/neighbours.rs#L1), [`core/dr-types/src/settings.rs:117`](../core/dr-types/src/settings.rs#L117), [`ui/dr-ui/src/develop.rs:121`](../ui/dr-ui/src/develop.rs#L121), [`ui/dr-ui/src/develop.rs:130`](../ui/dr-ui/src/develop.rs#L130), [`ui/dr-ui/src/develop.rs:196`](../ui/dr-ui/src/develop.rs#L196), [`ui/dr-ui/src/develop.rs:2282`](../ui/dr-ui/src/develop.rs#L2282), [`ui/dr-ui/src/develop.rs:689`](../ui/dr-ui/src/develop.rs#L689), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2325`](../ui/dr-ui/src/lib.rs#L2325), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | | FR-CULL-11 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:614`](../core/dr-catalog/src/schema.rs#L614), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/library.rs:295`](../ui/dr-ui/src/library.rs#L295), [`ui/dr-ui/src/library.rs:325`](../ui/dr-ui/src/library.rs#L325), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | | FR-CULL-12 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:395`](../core/dr-catalog/src/schema.rs#L395), [`core/dr-catalog/src/schema.rs:614`](../core/dr-catalog/src/schema.rs#L614), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | | FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`ui/dr-ui/src/import.rs:463`](../ui/dr-ui/src/import.rs#L463) | -| FR-CULL-3 | [`core/dr-gpu/src/focus.rs:154`](../core/dr-gpu/src/focus.rs#L154), [`core/dr-gpu/src/focus.rs:186`](../core/dr-gpu/src/focus.rs#L186), [`core/dr-gpu/src/focus.rs:1`](../core/dr-gpu/src/focus.rs#L1), [`core/dr-gpu/src/focus.rs:317`](../core/dr-gpu/src/focus.rs#L317), [`core/dr-gpu/src/raw_histogram.rs:129`](../core/dr-gpu/src/raw_histogram.rs#L129), [`core/dr-gpu/src/raw_histogram.rs:1`](../core/dr-gpu/src/raw_histogram.rs#L1), [`core/dr-gpu/src/raw_histogram.rs:272`](../core/dr-gpu/src/raw_histogram.rs#L272), [`core/dr-gpu/src/raw_histogram.rs:407`](../core/dr-gpu/src/raw_histogram.rs#L407), [`core/dr-gpu/src/shaders/focus_peak.wgsl:1`](../core/dr-gpu/src/shaders/focus_peak.wgsl#L1), [`core/dr-gpu/src/shaders/raw_histogram.wgsl:1`](../core/dr-gpu/src/shaders/raw_histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:3478`](../ui/dr-ui/src/develop.rs#L3478), [`ui/dr-ui/src/develop.rs:3491`](../ui/dr-ui/src/develop.rs#L3491), [`ui/dr-ui/src/develop.rs:3536`](../ui/dr-ui/src/develop.rs#L3536), [`ui/dr-ui/src/develop.rs:3547`](../ui/dr-ui/src/develop.rs#L3547), [`ui/dr-ui/src/develop.rs:3553`](../ui/dr-ui/src/develop.rs#L3553), [`ui/dr-ui/src/develop.rs:3570`](../ui/dr-ui/src/develop.rs#L3570), [`ui/dr-ui/src/develop.rs:763`](../ui/dr-ui/src/develop.rs#L763), [`ui/dr-ui/src/develop.rs:7666`](../ui/dr-ui/src/develop.rs#L7666), [`ui/dr-ui/src/develop.rs:7730`](../ui/dr-ui/src/develop.rs#L7730), [`ui/dr-ui/src/develop.rs:7753`](../ui/dr-ui/src/develop.rs#L7753), [`ui/dr-ui/src/develop.rs:783`](../ui/dr-ui/src/develop.rs#L783), [`ui/dr-ui/src/develop.rs:789`](../ui/dr-ui/src/develop.rs#L789), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/histogram.rs:208`](../ui/dr-ui/src/histogram.rs#L208), [`ui/dr-ui/src/histogram.rs:228`](../ui/dr-ui/src/histogram.rs#L228), [`ui/dr-ui/src/histogram.rs:272`](../ui/dr-ui/src/histogram.rs#L272), [`ui/dr-ui/src/histogram.rs:544`](../ui/dr-ui/src/histogram.rs#L544), [`ui/dr-ui/src/histogram.rs:565`](../ui/dr-ui/src/histogram.rs#L565), [`ui/dr-ui/src/histogram.rs:593`](../ui/dr-ui/src/histogram.rs#L593), [`ui/dr-ui/src/histogram.rs:621`](../ui/dr-ui/src/histogram.rs#L621), [`ui/dr-ui/src/histogram.rs:658`](../ui/dr-ui/src/histogram.rs#L658), [`ui/dr-ui/src/lib.rs:1712`](../ui/dr-ui/src/lib.rs#L1712), [`ui/dr-ui/src/lib.rs:1809`](../ui/dr-ui/src/lib.rs#L1809), [`ui/dr-ui/src/lib.rs:1916`](../ui/dr-ui/src/lib.rs#L1916), [`ui/dr-ui/src/lib.rs:1957`](../ui/dr-ui/src/lib.rs#L1957), [`ui/dr-ui/src/lib.rs:3345`](../ui/dr-ui/src/lib.rs#L3345), [`ui/dr-ui/src/lib.rs:396`](../ui/dr-ui/src/lib.rs#L396), [`ui/dr-ui/src/peaking.rs:1`](../ui/dr-ui/src/peaking.rs#L1), [`ui/dr-ui/ui/app.slint:2022`](../ui/dr-ui/ui/app.slint#L2022), [`ui/dr-ui/ui/app.slint:2927`](../ui/dr-ui/ui/app.slint#L2927), [`ui/dr-ui/ui/app.slint:96`](../ui/dr-ui/ui/app.slint#L96), [`ui/dr-ui/ui/peaking.slint:1`](../ui/dr-ui/ui/peaking.slint#L1), [`ui/dr-ui/ui/peaking.slint:25`](../ui/dr-ui/ui/peaking.slint#L25), [`ui/dr-ui/ui/peaking.slint:56`](../ui/dr-ui/ui/peaking.slint#L56) | -| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:137`](../core/dr-pipeline/src/sidecar.rs#L137), [`core/dr-xmp/src/lib.rs:442`](../core/dr-xmp/src/lib.rs#L442), [`ui/dr-ui/src/library.rs:254`](../ui/dr-ui/src/library.rs#L254), [`ui/dr-ui/src/library.rs:500`](../ui/dr-ui/src/library.rs#L500) | +| FR-CULL-3 | [`core/dr-gpu/src/focus.rs:154`](../core/dr-gpu/src/focus.rs#L154), [`core/dr-gpu/src/focus.rs:186`](../core/dr-gpu/src/focus.rs#L186), [`core/dr-gpu/src/focus.rs:1`](../core/dr-gpu/src/focus.rs#L1), [`core/dr-gpu/src/focus.rs:317`](../core/dr-gpu/src/focus.rs#L317), [`core/dr-gpu/src/raw_histogram.rs:129`](../core/dr-gpu/src/raw_histogram.rs#L129), [`core/dr-gpu/src/raw_histogram.rs:1`](../core/dr-gpu/src/raw_histogram.rs#L1), [`core/dr-gpu/src/raw_histogram.rs:272`](../core/dr-gpu/src/raw_histogram.rs#L272), [`core/dr-gpu/src/raw_histogram.rs:407`](../core/dr-gpu/src/raw_histogram.rs#L407), [`core/dr-gpu/src/shaders/focus_peak.wgsl:1`](../core/dr-gpu/src/shaders/focus_peak.wgsl#L1), [`core/dr-gpu/src/shaders/raw_histogram.wgsl:1`](../core/dr-gpu/src/shaders/raw_histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:3780`](../ui/dr-ui/src/develop.rs#L3780), [`ui/dr-ui/src/develop.rs:3793`](../ui/dr-ui/src/develop.rs#L3793), [`ui/dr-ui/src/develop.rs:3838`](../ui/dr-ui/src/develop.rs#L3838), [`ui/dr-ui/src/develop.rs:3849`](../ui/dr-ui/src/develop.rs#L3849), [`ui/dr-ui/src/develop.rs:3855`](../ui/dr-ui/src/develop.rs#L3855), [`ui/dr-ui/src/develop.rs:3872`](../ui/dr-ui/src/develop.rs#L3872), [`ui/dr-ui/src/develop.rs:763`](../ui/dr-ui/src/develop.rs#L763), [`ui/dr-ui/src/develop.rs:783`](../ui/dr-ui/src/develop.rs#L783), [`ui/dr-ui/src/develop.rs:789`](../ui/dr-ui/src/develop.rs#L789), [`ui/dr-ui/src/develop.rs:7969`](../ui/dr-ui/src/develop.rs#L7969), [`ui/dr-ui/src/develop.rs:8033`](../ui/dr-ui/src/develop.rs#L8033), [`ui/dr-ui/src/develop.rs:8056`](../ui/dr-ui/src/develop.rs#L8056), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/histogram.rs:208`](../ui/dr-ui/src/histogram.rs#L208), [`ui/dr-ui/src/histogram.rs:228`](../ui/dr-ui/src/histogram.rs#L228), [`ui/dr-ui/src/histogram.rs:272`](../ui/dr-ui/src/histogram.rs#L272), [`ui/dr-ui/src/histogram.rs:544`](../ui/dr-ui/src/histogram.rs#L544), [`ui/dr-ui/src/histogram.rs:565`](../ui/dr-ui/src/histogram.rs#L565), [`ui/dr-ui/src/histogram.rs:593`](../ui/dr-ui/src/histogram.rs#L593), [`ui/dr-ui/src/histogram.rs:621`](../ui/dr-ui/src/histogram.rs#L621), [`ui/dr-ui/src/histogram.rs:658`](../ui/dr-ui/src/histogram.rs#L658), [`ui/dr-ui/src/lib.rs:1712`](../ui/dr-ui/src/lib.rs#L1712), [`ui/dr-ui/src/lib.rs:1809`](../ui/dr-ui/src/lib.rs#L1809), [`ui/dr-ui/src/lib.rs:1916`](../ui/dr-ui/src/lib.rs#L1916), [`ui/dr-ui/src/lib.rs:1957`](../ui/dr-ui/src/lib.rs#L1957), [`ui/dr-ui/src/lib.rs:3345`](../ui/dr-ui/src/lib.rs#L3345), [`ui/dr-ui/src/lib.rs:396`](../ui/dr-ui/src/lib.rs#L396), [`ui/dr-ui/src/peaking.rs:1`](../ui/dr-ui/src/peaking.rs#L1), [`ui/dr-ui/ui/app.slint:1976`](../ui/dr-ui/ui/app.slint#L1976), [`ui/dr-ui/ui/app.slint:2934`](../ui/dr-ui/ui/app.slint#L2934), [`ui/dr-ui/ui/app.slint:95`](../ui/dr-ui/ui/app.slint#L95), [`ui/dr-ui/ui/peaking.slint:1`](../ui/dr-ui/ui/peaking.slint#L1), [`ui/dr-ui/ui/peaking.slint:25`](../ui/dr-ui/ui/peaking.slint#L25), [`ui/dr-ui/ui/peaking.slint:56`](../ui/dr-ui/ui/peaking.slint#L56) | +| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:139`](../core/dr-pipeline/src/sidecar.rs#L139), [`core/dr-xmp/src/lib.rs:442`](../core/dr-xmp/src/lib.rs#L442), [`ui/dr-ui/src/library.rs:254`](../ui/dr-ui/src/library.rs#L254), [`ui/dr-ui/src/library.rs:500`](../ui/dr-ui/src/library.rs#L500) | | FR-CULL-5 | [`core/dr-catalog/src/bursts.rs:1`](../core/dr-catalog/src/bursts.rs#L1), [`core/dr-catalog/src/schema.rs:438`](../core/dr-catalog/src/schema.rs#L438), [`ui/dr-ui/src/bursts.rs:1`](../ui/dr-ui/src/bursts.rs#L1), [`ui/dr-ui/src/library.rs:210`](../ui/dr-ui/src/library.rs#L210), [`ui/dr-ui/src/library.rs:5853`](../ui/dr-ui/src/library.rs#L5853) | | FR-CULL-8 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:506`](../core/dr-catalog/src/schema.rs#L506), [`core/dr-catalog/src/schema.rs:573`](../core/dr-catalog/src/schema.rs#L573), [`core/dr-catalog/src/schema.rs:614`](../core/dr-catalog/src/schema.rs#L614), [`core/dr-face/src/align.rs:291`](../core/dr-face/src/align.rs#L291), [`ui/dr-ui/examples/face_native.rs:1`](../ui/dr-ui/examples/face_native.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/faces.rs:330`](../ui/dr-ui/src/faces.rs#L330), [`ui/dr-ui/src/faces.rs:346`](../ui/dr-ui/src/faces.rs#L346), [`ui/dr-ui/src/identity_ui.rs:540`](../ui/dr-ui/src/identity_ui.rs#L540), [`ui/dr-ui/src/lib.rs:1331`](../ui/dr-ui/src/lib.rs#L1331), [`ui/dr-ui/src/library.rs:3293`](../ui/dr-ui/src/library.rs#L3293), [`ui/dr-ui/src/library.rs:3385`](../ui/dr-ui/src/library.rs#L3385), [`ui/dr-ui/src/library.rs:3606`](../ui/dr-ui/src/library.rs#L3606), [`ui/dr-ui/src/library.rs:3742`](../ui/dr-ui/src/library.rs#L3742), [`ui/dr-ui/src/library.rs:3778`](../ui/dr-ui/src/library.rs#L3778), [`ui/dr-ui/ui/settings.slint:427`](../ui/dr-ui/ui/settings.slint#L427), [`ui/dr-ui/ui/settings.slint:82`](../ui/dr-ui/ui/settings.slint#L82) | | FR-CULL-9 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:614`](../core/dr-catalog/src/schema.rs#L614), [`core/dr-face/src/assign.rs:1`](../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../core/dr-face/src/neighbours.rs#L1), [`core/dr-types/src/settings.rs:117`](../core/dr-types/src/settings.rs#L117), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/ui/identity.slint:283`](../ui/dr-ui/ui/identity.slint#L283) | | FR-DEV-1 | [`core/dr-pipeline/src/graph.rs:1`](../core/dr-pipeline/src/graph.rs#L1), [`core/dr-pipeline/src/sidecar.rs:1`](../core/dr-pipeline/src/sidecar.rs#L1) | -| FR-DEV-10 | [`core/dr-gpu/src/mask.rs:1062`](../core/dr-gpu/src/mask.rs#L1062), [`core/dr-gpu/src/mask.rs:1150`](../core/dr-gpu/src/mask.rs#L1150), [`core/dr-gpu/src/mask.rs:402`](../core/dr-gpu/src/mask.rs#L402), [`core/dr-gpu/src/mask.rs:444`](../core/dr-gpu/src/mask.rs#L444), [`core/dr-gpu/src/mask.rs:58`](../core/dr-gpu/src/mask.rs#L58), [`core/dr-gpu/src/mask.rs:60`](../core/dr-gpu/src/mask.rs#L60), [`core/dr-gpu/src/mask.rs:658`](../core/dr-gpu/src/mask.rs#L658), [`core/dr-gpu/src/mask.rs:717`](../core/dr-gpu/src/mask.rs#L717), [`core/dr-gpu/src/mask.rs:829`](../core/dr-gpu/src/mask.rs#L829), [`core/dr-gpu/src/mask.rs:85`](../core/dr-gpu/src/mask.rs#L85), [`core/dr-gpu/src/shaders/mask.wgsl:101`](../core/dr-gpu/src/shaders/mask.wgsl#L101), [`core/dr-gpu/src/shaders/mask.wgsl:59`](../core/dr-gpu/src/shaders/mask.wgsl#L59), [`core/dr-gpu/tests/range_masks.rs:119`](../core/dr-gpu/tests/range_masks.rs#L119), [`core/dr-gpu/tests/range_masks.rs:147`](../core/dr-gpu/tests/range_masks.rs#L147), [`core/dr-gpu/tests/range_masks.rs:175`](../core/dr-gpu/tests/range_masks.rs#L175), [`core/dr-gpu/tests/range_masks.rs:1`](../core/dr-gpu/tests/range_masks.rs#L1), [`core/dr-gpu/tests/range_masks.rs:94`](../core/dr-gpu/tests/range_masks.rs#L94), [`core/dr-pipeline/src/mask.rs:125`](../core/dr-pipeline/src/mask.rs#L125), [`core/dr-pipeline/src/mask.rs:2162`](../core/dr-pipeline/src/mask.rs#L2162), [`core/dr-pipeline/src/mask.rs:2175`](../core/dr-pipeline/src/mask.rs#L2175), [`core/dr-pipeline/src/mask.rs:2194`](../core/dr-pipeline/src/mask.rs#L2194), [`core/dr-pipeline/src/mask.rs:2208`](../core/dr-pipeline/src/mask.rs#L2208), [`core/dr-pipeline/src/mask.rs:2218`](../core/dr-pipeline/src/mask.rs#L2218), [`core/dr-pipeline/src/mask.rs:709`](../core/dr-pipeline/src/mask.rs#L709), [`core/dr-pipeline/src/mask.rs:748`](../core/dr-pipeline/src/mask.rs#L748), [`core/dr-pipeline/src/mask.rs:808`](../core/dr-pipeline/src/mask.rs#L808), [`core/dr-pipeline/src/mask.rs:827`](../core/dr-pipeline/src/mask.rs#L827), [`core/dr-pipeline/src/mask.rs:851`](../core/dr-pipeline/src/mask.rs#L851), [`core/dr-pipeline/src/mask.rs:863`](../core/dr-pipeline/src/mask.rs#L863), [`core/dr-pipeline/src/mask.rs:875`](../core/dr-pipeline/src/mask.rs#L875), [`core/dr-pipeline/src/sidecar.rs:1132`](../core/dr-pipeline/src/sidecar.rs#L1132), [`core/dr-pipeline/src/sidecar.rs:1320`](../core/dr-pipeline/src/sidecar.rs#L1320), [`core/dr-pipeline/src/sidecar.rs:1414`](../core/dr-pipeline/src/sidecar.rs#L1414), [`core/dr-pipeline/src/sidecar.rs:1518`](../core/dr-pipeline/src/sidecar.rs#L1518), [`core/dr-pipeline/src/sidecar.rs:1602`](../core/dr-pipeline/src/sidecar.rs#L1602), [`core/dr-pipeline/tests/mask_sidecar.rs:1132`](../core/dr-pipeline/tests/mask_sidecar.rs#L1132), [`core/dr-pipeline/tests/mask_sidecar.rs:1161`](../core/dr-pipeline/tests/mask_sidecar.rs#L1161), [`core/dr-pipeline/tests/mask_sidecar.rs:1179`](../core/dr-pipeline/tests/mask_sidecar.rs#L1179), [`core/dr-pipeline/tests/mask_sidecar.rs:1209`](../core/dr-pipeline/tests/mask_sidecar.rs#L1209), [`ui/dr-ui/src/develop.rs:2148`](../ui/dr-ui/src/develop.rs#L2148), [`ui/dr-ui/src/develop.rs:3003`](../ui/dr-ui/src/develop.rs#L3003), [`ui/dr-ui/src/develop.rs:3029`](../ui/dr-ui/src/develop.rs#L3029), [`ui/dr-ui/src/develop.rs:3061`](../ui/dr-ui/src/develop.rs#L3061), [`ui/dr-ui/src/develop.rs:3085`](../ui/dr-ui/src/develop.rs#L3085), [`ui/dr-ui/src/develop.rs:3094`](../ui/dr-ui/src/develop.rs#L3094), [`ui/dr-ui/src/develop.rs:3103`](../ui/dr-ui/src/develop.rs#L3103), [`ui/dr-ui/src/develop.rs:3123`](../ui/dr-ui/src/develop.rs#L3123), [`ui/dr-ui/src/labels.rs:60`](../ui/dr-ui/src/labels.rs#L60), [`ui/dr-ui/src/masks_ui.rs:628`](../ui/dr-ui/src/masks_ui.rs#L628), [`ui/dr-ui/src/masks_ui.rs:741`](../ui/dr-ui/src/masks_ui.rs#L741) | +| FR-DEV-10 | [`core/dr-gpu/src/mask.rs:1213`](../core/dr-gpu/src/mask.rs#L1213), [`core/dr-gpu/src/mask.rs:1445`](../core/dr-gpu/src/mask.rs#L1445), [`core/dr-gpu/src/mask.rs:421`](../core/dr-gpu/src/mask.rs#L421), [`core/dr-gpu/src/mask.rs:463`](../core/dr-gpu/src/mask.rs#L463), [`core/dr-gpu/src/mask.rs:58`](../core/dr-gpu/src/mask.rs#L58), [`core/dr-gpu/src/mask.rs:60`](../core/dr-gpu/src/mask.rs#L60), [`core/dr-gpu/src/mask.rs:796`](../core/dr-gpu/src/mask.rs#L796), [`core/dr-gpu/src/mask.rs:85`](../core/dr-gpu/src/mask.rs#L85), [`core/dr-gpu/src/mask.rs:875`](../core/dr-gpu/src/mask.rs#L875), [`core/dr-gpu/src/mask.rs:989`](../core/dr-gpu/src/mask.rs#L989), [`core/dr-gpu/src/shaders/mask.wgsl:117`](../core/dr-gpu/src/shaders/mask.wgsl#L117), [`core/dr-gpu/src/shaders/mask.wgsl:59`](../core/dr-gpu/src/shaders/mask.wgsl#L59), [`core/dr-gpu/tests/range_masks.rs:119`](../core/dr-gpu/tests/range_masks.rs#L119), [`core/dr-gpu/tests/range_masks.rs:147`](../core/dr-gpu/tests/range_masks.rs#L147), [`core/dr-gpu/tests/range_masks.rs:175`](../core/dr-gpu/tests/range_masks.rs#L175), [`core/dr-gpu/tests/range_masks.rs:1`](../core/dr-gpu/tests/range_masks.rs#L1), [`core/dr-gpu/tests/range_masks.rs:94`](../core/dr-gpu/tests/range_masks.rs#L94), [`core/dr-pipeline/src/mask.rs:125`](../core/dr-pipeline/src/mask.rs#L125), [`core/dr-pipeline/src/mask.rs:2497`](../core/dr-pipeline/src/mask.rs#L2497), [`core/dr-pipeline/src/mask.rs:2510`](../core/dr-pipeline/src/mask.rs#L2510), [`core/dr-pipeline/src/mask.rs:2529`](../core/dr-pipeline/src/mask.rs#L2529), [`core/dr-pipeline/src/mask.rs:2543`](../core/dr-pipeline/src/mask.rs#L2543), [`core/dr-pipeline/src/mask.rs:2553`](../core/dr-pipeline/src/mask.rs#L2553), [`core/dr-pipeline/src/mask.rs:709`](../core/dr-pipeline/src/mask.rs#L709), [`core/dr-pipeline/src/mask.rs:748`](../core/dr-pipeline/src/mask.rs#L748), [`core/dr-pipeline/src/mask.rs:808`](../core/dr-pipeline/src/mask.rs#L808), [`core/dr-pipeline/src/mask.rs:827`](../core/dr-pipeline/src/mask.rs#L827), [`core/dr-pipeline/src/mask.rs:851`](../core/dr-pipeline/src/mask.rs#L851), [`core/dr-pipeline/src/mask.rs:863`](../core/dr-pipeline/src/mask.rs#L863), [`core/dr-pipeline/src/mask.rs:875`](../core/dr-pipeline/src/mask.rs#L875), [`core/dr-pipeline/src/sidecar.rs:1224`](../core/dr-pipeline/src/sidecar.rs#L1224), [`core/dr-pipeline/src/sidecar.rs:1403`](../core/dr-pipeline/src/sidecar.rs#L1403), [`core/dr-pipeline/src/sidecar.rs:1490`](../core/dr-pipeline/src/sidecar.rs#L1490), [`core/dr-pipeline/src/sidecar.rs:1591`](../core/dr-pipeline/src/sidecar.rs#L1591), [`core/dr-pipeline/src/sidecar.rs:1798`](../core/dr-pipeline/src/sidecar.rs#L1798), [`core/dr-pipeline/tests/mask_sidecar.rs:1157`](../core/dr-pipeline/tests/mask_sidecar.rs#L1157), [`core/dr-pipeline/tests/mask_sidecar.rs:1186`](../core/dr-pipeline/tests/mask_sidecar.rs#L1186), [`core/dr-pipeline/tests/mask_sidecar.rs:1204`](../core/dr-pipeline/tests/mask_sidecar.rs#L1204), [`core/dr-pipeline/tests/mask_sidecar.rs:1234`](../core/dr-pipeline/tests/mask_sidecar.rs#L1234), [`ui/dr-ui/src/develop.rs:2185`](../ui/dr-ui/src/develop.rs#L2185), [`ui/dr-ui/src/develop.rs:3306`](../ui/dr-ui/src/develop.rs#L3306), [`ui/dr-ui/src/develop.rs:3332`](../ui/dr-ui/src/develop.rs#L3332), [`ui/dr-ui/src/develop.rs:3364`](../ui/dr-ui/src/develop.rs#L3364), [`ui/dr-ui/src/develop.rs:3388`](../ui/dr-ui/src/develop.rs#L3388), [`ui/dr-ui/src/develop.rs:3394`](../ui/dr-ui/src/develop.rs#L3394), [`ui/dr-ui/src/develop.rs:3401`](../ui/dr-ui/src/develop.rs#L3401), [`ui/dr-ui/src/develop.rs:3421`](../ui/dr-ui/src/develop.rs#L3421), [`ui/dr-ui/src/labels.rs:60`](../ui/dr-ui/src/labels.rs#L60), [`ui/dr-ui/src/masks_ui.rs:655`](../ui/dr-ui/src/masks_ui.rs#L655), [`ui/dr-ui/src/masks_ui.rs:903`](../ui/dr-ui/src/masks_ui.rs#L903) | | FR-DEV-12 | [`core/dr-pipeline/ops/colour_grading.yaml:1`](../core/dr-pipeline/ops/colour_grading.yaml#L1) | | FR-DEV-18 | [`core/dr-pipeline/src/ops/dehaze.rs:1`](../core/dr-pipeline/src/ops/dehaze.rs#L1), [`core/dr-pipeline/src/ops/dehaze.rs:221`](../core/dr-pipeline/src/ops/dehaze.rs#L221) | +| FR-DEV-19a | [`ui/dr-ui/src/develop.rs:2633`](../ui/dr-ui/src/develop.rs#L2633), [`ui/dr-ui/src/develop.rs:2694`](../ui/dr-ui/src/develop.rs#L2694), [`ui/dr-ui/src/develop.rs:833`](../ui/dr-ui/src/develop.rs#L833), [`ui/dr-ui/src/labels.rs:70`](../ui/dr-ui/src/labels.rs#L70), [`ui/dr-ui/src/masks_ui.rs:1192`](../ui/dr-ui/src/masks_ui.rs#L1192), [`ui/dr-ui/src/masks_ui.rs:243`](../ui/dr-ui/src/masks_ui.rs#L243), [`ui/dr-ui/src/masks_ui.rs:823`](../ui/dr-ui/src/masks_ui.rs#L823), [`ui/dr-ui/ui/app.slint:1097`](../ui/dr-ui/ui/app.slint#L1097), [`ui/dr-ui/ui/masks.slint:137`](../ui/dr-ui/ui/masks.slint#L137), [`ui/dr-ui/ui/masks.slint:162`](../ui/dr-ui/ui/masks.slint#L162), [`ui/dr-ui/ui/masks.slint:283`](../ui/dr-ui/ui/masks.slint#L283), [`ui/dr-ui/ui/masks.slint:556`](../ui/dr-ui/ui/masks.slint#L556), [`ui/dr-ui/ui/masks.slint:606`](../ui/dr-ui/ui/masks.slint#L606) | +| FR-DEV-19b | [`ui/dr-ui/src/develop.rs:2775`](../ui/dr-ui/src/develop.rs#L2775), [`ui/dr-ui/src/develop.rs:842`](../ui/dr-ui/src/develop.rs#L842), [`ui/dr-ui/src/develop.rs:849`](../ui/dr-ui/src/develop.rs#L849), [`ui/dr-ui/src/labels.rs:64`](../ui/dr-ui/src/labels.rs#L64), [`ui/dr-ui/src/masks_ui.rs:1113`](../ui/dr-ui/src/masks_ui.rs#L1113), [`ui/dr-ui/src/masks_ui.rs:753`](../ui/dr-ui/src/masks_ui.rs#L753), [`ui/dr-ui/ui/app.slint:1093`](../ui/dr-ui/ui/app.slint#L1093), [`ui/dr-ui/ui/app.slint:1110`](../ui/dr-ui/ui/app.slint#L1110), [`ui/dr-ui/ui/app.slint:2103`](../ui/dr-ui/ui/app.slint#L2103), [`ui/dr-ui/ui/masks.slint:560`](../ui/dr-ui/ui/masks.slint#L560), [`ui/dr-ui/ui/masks.slint:603`](../ui/dr-ui/ui/masks.slint#L603), [`ui/dr-ui/ui/masks.slint:641`](../ui/dr-ui/ui/masks.slint#L641) | | FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:403`](../core/dr-pipeline/src/operation.rs#L403) | -| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2217`](../core/dr-gpu/src/adjust.rs#L2217), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/coverage.rs:122`](../core/dr-pipeline/src/coverage.rs#L122), [`core/dr-pipeline/src/coverage.rs:1`](../core/dr-pipeline/src/coverage.rs#L1), [`core/dr-pipeline/src/detail.rs:434`](../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:524`](../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/framing.rs:177`](../core/dr-pipeline/src/framing.rs#L177), [`core/dr-pipeline/src/framing.rs:234`](../core/dr-pipeline/src/framing.rs#L234), [`core/dr-pipeline/src/framing.rs:314`](../core/dr-pipeline/src/framing.rs#L314), [`core/dr-pipeline/src/framing.rs:498`](../core/dr-pipeline/src/framing.rs#L498), [`core/dr-pipeline/src/framing.rs:753`](../core/dr-pipeline/src/framing.rs#L753), [`core/dr-pipeline/src/graph.rs:209`](../core/dr-pipeline/src/graph.rs#L209), [`core/dr-pipeline/src/graph.rs:290`](../core/dr-pipeline/src/graph.rs#L290), [`core/dr-pipeline/src/graph.rs:762`](../core/dr-pipeline/src/graph.rs#L762), [`core/dr-pipeline/src/mask.rs:205`](../core/dr-pipeline/src/mask.rs#L205), [`core/dr-pipeline/src/neutral.rs:1`](../core/dr-pipeline/src/neutral.rs#L1), [`core/dr-pipeline/src/neutral.rs:300`](../core/dr-pipeline/src/neutral.rs#L300), [`core/dr-pipeline/src/neutral.rs:316`](../core/dr-pipeline/src/neutral.rs#L316), [`core/dr-pipeline/src/neutral.rs:332`](../core/dr-pipeline/src/neutral.rs#L332), [`core/dr-pipeline/src/neutral.rs:354`](../core/dr-pipeline/src/neutral.rs#L354), [`core/dr-pipeline/src/neutral.rs:79`](../core/dr-pipeline/src/neutral.rs#L79), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:535`](../core/dr-pipeline/src/operation.rs#L535), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:210`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L210), [`core/dr-pipeline/src/ops/curve.rs:100`](../core/dr-pipeline/src/ops/curve.rs#L100), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:219`](../core/dr-pipeline/src/ops/curve.rs#L219), [`core/dr-pipeline/src/ops/curve.rs:635`](../core/dr-pipeline/src/ops/curve.rs#L635), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:273`](../core/dr-pipeline/src/ops/noise_reduction.rs#L273), [`core/dr-pipeline/src/sidecar.rs:1205`](../core/dr-pipeline/src/sidecar.rs#L1205), [`core/dr-pipeline/src/sidecar.rs:158`](../core/dr-pipeline/src/sidecar.rs#L158), [`core/dr-pipeline/src/sidecar.rs:1918`](../core/dr-pipeline/src/sidecar.rs#L1918), [`core/dr-pipeline/src/sidecar.rs:1978`](../core/dr-pipeline/src/sidecar.rs#L1978), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`core/dr-segment/src/refine.rs:850`](../core/dr-segment/src/refine.rs#L850), [`ui/dr-ui/src/develop.rs:103`](../ui/dr-ui/src/develop.rs#L103), [`ui/dr-ui/src/develop.rs:1188`](../ui/dr-ui/src/develop.rs#L1188), [`ui/dr-ui/src/develop.rs:1277`](../ui/dr-ui/src/develop.rs#L1277), [`ui/dr-ui/src/develop.rs:1313`](../ui/dr-ui/src/develop.rs#L1313), [`ui/dr-ui/src/develop.rs:1584`](../ui/dr-ui/src/develop.rs#L1584), [`ui/dr-ui/src/develop.rs:165`](../ui/dr-ui/src/develop.rs#L165), [`ui/dr-ui/src/develop.rs:2000`](../ui/dr-ui/src/develop.rs#L2000), [`ui/dr-ui/src/develop.rs:2227`](../ui/dr-ui/src/develop.rs#L2227), [`ui/dr-ui/src/develop.rs:2245`](../ui/dr-ui/src/develop.rs#L2245), [`ui/dr-ui/src/develop.rs:2259`](../ui/dr-ui/src/develop.rs#L2259), [`ui/dr-ui/src/develop.rs:2281`](../ui/dr-ui/src/develop.rs#L2281), [`ui/dr-ui/src/develop.rs:2427`](../ui/dr-ui/src/develop.rs#L2427), [`ui/dr-ui/src/develop.rs:2525`](../ui/dr-ui/src/develop.rs#L2525), [`ui/dr-ui/src/develop.rs:328`](../ui/dr-ui/src/develop.rs#L328), [`ui/dr-ui/src/develop.rs:365`](../ui/dr-ui/src/develop.rs#L365), [`ui/dr-ui/src/develop.rs:3741`](../ui/dr-ui/src/develop.rs#L3741), [`ui/dr-ui/src/develop.rs:3897`](../ui/dr-ui/src/develop.rs#L3897), [`ui/dr-ui/src/develop.rs:3957`](../ui/dr-ui/src/develop.rs#L3957), [`ui/dr-ui/src/develop.rs:4358`](../ui/dr-ui/src/develop.rs#L4358), [`ui/dr-ui/src/develop.rs:4388`](../ui/dr-ui/src/develop.rs#L4388), [`ui/dr-ui/src/develop.rs:4454`](../ui/dr-ui/src/develop.rs#L4454), [`ui/dr-ui/src/develop.rs:4468`](../ui/dr-ui/src/develop.rs#L4468), [`ui/dr-ui/src/develop.rs:4670`](../ui/dr-ui/src/develop.rs#L4670), [`ui/dr-ui/src/develop.rs:4758`](../ui/dr-ui/src/develop.rs#L4758), [`ui/dr-ui/src/develop.rs:5586`](../ui/dr-ui/src/develop.rs#L5586), [`ui/dr-ui/src/develop.rs:5779`](../ui/dr-ui/src/develop.rs#L5779), [`ui/dr-ui/src/develop.rs:5867`](../ui/dr-ui/src/develop.rs#L5867), [`ui/dr-ui/src/develop.rs:588`](../ui/dr-ui/src/develop.rs#L588), [`ui/dr-ui/src/develop.rs:6023`](../ui/dr-ui/src/develop.rs#L6023), [`ui/dr-ui/src/develop.rs:6159`](../ui/dr-ui/src/develop.rs#L6159), [`ui/dr-ui/src/develop.rs:6203`](../ui/dr-ui/src/develop.rs#L6203), [`ui/dr-ui/src/develop.rs:6253`](../ui/dr-ui/src/develop.rs#L6253), [`ui/dr-ui/src/develop.rs:649`](../ui/dr-ui/src/develop.rs#L649), [`ui/dr-ui/src/develop.rs:6678`](../ui/dr-ui/src/develop.rs#L6678), [`ui/dr-ui/src/develop.rs:7355`](../ui/dr-ui/src/develop.rs#L7355), [`ui/dr-ui/src/develop.rs:803`](../ui/dr-ui/src/develop.rs#L803), [`ui/dr-ui/src/develop.rs:850`](../ui/dr-ui/src/develop.rs#L850), [`ui/dr-ui/src/develop.rs:879`](../ui/dr-ui/src/develop.rs#L879), [`ui/dr-ui/src/labels.rs:32`](../ui/dr-ui/src/labels.rs#L32), [`ui/dr-ui/src/lib.rs:1790`](../ui/dr-ui/src/lib.rs#L1790), [`ui/dr-ui/src/lib.rs:2644`](../ui/dr-ui/src/lib.rs#L2644), [`ui/dr-ui/src/lib.rs:2842`](../ui/dr-ui/src/lib.rs#L2842), [`ui/dr-ui/src/lib.rs:2901`](../ui/dr-ui/src/lib.rs#L2901), [`ui/dr-ui/src/lib.rs:3106`](../ui/dr-ui/src/lib.rs#L3106), [`ui/dr-ui/src/lib.rs:3170`](../ui/dr-ui/src/lib.rs#L3170), [`ui/dr-ui/src/lib.rs:3224`](../ui/dr-ui/src/lib.rs#L3224), [`ui/dr-ui/src/lib.rs:381`](../ui/dr-ui/src/lib.rs#L381), [`ui/dr-ui/src/lib.rs:402`](../ui/dr-ui/src/lib.rs#L402), [`ui/dr-ui/src/lib.rs:507`](../ui/dr-ui/src/lib.rs#L507), [`ui/dr-ui/src/lib.rs:530`](../ui/dr-ui/src/lib.rs#L530), [`ui/dr-ui/src/library.rs:547`](../ui/dr-ui/src/library.rs#L547), [`ui/dr-ui/src/masks_ui.rs:1065`](../ui/dr-ui/src/masks_ui.rs#L1065), [`ui/dr-ui/src/masks_ui.rs:263`](../ui/dr-ui/src/masks_ui.rs#L263), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:951`](../ui/dr-ui/src/masks_ui.rs#L951), [`ui/dr-ui/src/presets.rs:409`](../ui/dr-ui/src/presets.rs#L409), [`ui/dr-ui/src/segmentation.rs:116`](../ui/dr-ui/src/segmentation.rs#L116), [`ui/dr-ui/src/segmentation.rs:330`](../ui/dr-ui/src/segmentation.rs#L330), [`ui/dr-ui/src/segmentation.rs:346`](../ui/dr-ui/src/segmentation.rs#L346), [`ui/dr-ui/src/segmentation.rs:476`](../ui/dr-ui/src/segmentation.rs#L476), [`ui/dr-ui/src/segmentation.rs:504`](../ui/dr-ui/src/segmentation.rs#L504), [`ui/dr-ui/ui/adjust.slint:1073`](../ui/dr-ui/ui/adjust.slint#L1073), [`ui/dr-ui/ui/adjust.slint:125`](../ui/dr-ui/ui/adjust.slint#L125), [`ui/dr-ui/ui/adjust.slint:1369`](../ui/dr-ui/ui/adjust.slint#L1369), [`ui/dr-ui/ui/adjust.slint:170`](../ui/dr-ui/ui/adjust.slint#L170), [`ui/dr-ui/ui/adjust.slint:45`](../ui/dr-ui/ui/adjust.slint#L45), [`ui/dr-ui/ui/adjust.slint:624`](../ui/dr-ui/ui/adjust.slint#L624), [`ui/dr-ui/ui/adjust.slint:743`](../ui/dr-ui/ui/adjust.slint#L743), [`ui/dr-ui/ui/app.slint:1123`](../ui/dr-ui/ui/app.slint#L1123), [`ui/dr-ui/ui/app.slint:157`](../ui/dr-ui/ui/app.slint#L157), [`ui/dr-ui/ui/app.slint:203`](../ui/dr-ui/ui/app.slint#L203), [`ui/dr-ui/ui/app.slint:2161`](../ui/dr-ui/ui/app.slint#L2161), [`ui/dr-ui/ui/app.slint:2300`](../ui/dr-ui/ui/app.slint#L2300), [`ui/dr-ui/ui/app.slint:243`](../ui/dr-ui/ui/app.slint#L243), [`ui/dr-ui/ui/app.slint:3131`](../ui/dr-ui/ui/app.slint#L3131), [`ui/dr-ui/ui/app.slint:69`](../ui/dr-ui/ui/app.slint#L69), [`ui/dr-ui/ui/develop.slint:220`](../ui/dr-ui/ui/develop.slint#L220), [`ui/dr-ui/ui/masks.slint:767`](../ui/dr-ui/ui/masks.slint#L767) | -| FR-DEV-3a | [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:212`](../core/dr-pipeline/src/descriptor.rs#L212), [`core/dr-pipeline/src/descriptor.rs:252`](../core/dr-pipeline/src/descriptor.rs#L252), [`core/dr-pipeline/src/descriptor.rs:276`](../core/dr-pipeline/src/descriptor.rs#L276), [`core/dr-pipeline/src/descriptor.rs:331`](../core/dr-pipeline/src/descriptor.rs#L331), [`core/dr-pipeline/src/framing.rs:395`](../core/dr-pipeline/src/framing.rs#L395), [`core/dr-pipeline/src/graph.rs:25`](../core/dr-pipeline/src/graph.rs#L25), [`core/dr-pipeline/src/graph.rs:337`](../core/dr-pipeline/src/graph.rs#L337), [`core/dr-pipeline/src/graph.rs:439`](../core/dr-pipeline/src/graph.rs#L439), [`core/dr-pipeline/src/graph.rs:47`](../core/dr-pipeline/src/graph.rs#L47), [`core/dr-pipeline/src/graph.rs:60`](../core/dr-pipeline/src/graph.rs#L60), [`core/dr-pipeline/src/mask.rs:1331`](../core/dr-pipeline/src/mask.rs#L1331), [`core/dr-pipeline/src/neutral.rs:1`](../core/dr-pipeline/src/neutral.rs#L1), [`core/dr-pipeline/src/neutral.rs:372`](../core/dr-pipeline/src/neutral.rs#L372), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365), [`core/dr-pipeline/src/ops/curve.rs:319`](../core/dr-pipeline/src/ops/curve.rs#L319), [`ui/dr-ui/src/develop.rs:1200`](../ui/dr-ui/src/develop.rs#L1200), [`ui/dr-ui/src/develop.rs:1479`](../ui/dr-ui/src/develop.rs#L1479), [`ui/dr-ui/src/develop.rs:6678`](../ui/dr-ui/src/develop.rs#L6678), [`ui/dr-ui/src/lib.rs:824`](../ui/dr-ui/src/lib.rs#L824), [`ui/dr-ui/tests/ui_names_no_operation.rs:1`](../ui/dr-ui/tests/ui_names_no_operation.rs#L1), [`ui/dr-ui/ui/app.slint:2484`](../ui/dr-ui/ui/app.slint#L2484) | -| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:276`](../core/dr-pipeline/src/descriptor.rs#L276), [`core/dr-pipeline/src/framing.rs:395`](../core/dr-pipeline/src/framing.rs#L395), [`core/dr-pipeline/src/graph.rs:60`](../core/dr-pipeline/src/graph.rs#L60), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365), [`ui/dr-ui/ui/app.slint:2673`](../ui/dr-ui/ui/app.slint#L2673) | -| FR-DEV-3c | [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:337`](../core/dr-pipeline/src/graph.rs#L337), [`core/dr-pipeline/src/graph.rs:47`](../core/dr-pipeline/src/graph.rs#L47), [`core/dr-pipeline/src/mask.rs:1331`](../core/dr-pipeline/src/mask.rs#L1331), [`ui/dr-ui/src/develop.rs:6876`](../ui/dr-ui/src/develop.rs#L6876) | +| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2217`](../core/dr-gpu/src/adjust.rs#L2217), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/coverage.rs:122`](../core/dr-pipeline/src/coverage.rs#L122), [`core/dr-pipeline/src/coverage.rs:1`](../core/dr-pipeline/src/coverage.rs#L1), [`core/dr-pipeline/src/detail.rs:434`](../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:524`](../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/framing.rs:177`](../core/dr-pipeline/src/framing.rs#L177), [`core/dr-pipeline/src/framing.rs:234`](../core/dr-pipeline/src/framing.rs#L234), [`core/dr-pipeline/src/framing.rs:314`](../core/dr-pipeline/src/framing.rs#L314), [`core/dr-pipeline/src/framing.rs:498`](../core/dr-pipeline/src/framing.rs#L498), [`core/dr-pipeline/src/framing.rs:753`](../core/dr-pipeline/src/framing.rs#L753), [`core/dr-pipeline/src/graph.rs:209`](../core/dr-pipeline/src/graph.rs#L209), [`core/dr-pipeline/src/graph.rs:290`](../core/dr-pipeline/src/graph.rs#L290), [`core/dr-pipeline/src/graph.rs:762`](../core/dr-pipeline/src/graph.rs#L762), [`core/dr-pipeline/src/mask.rs:205`](../core/dr-pipeline/src/mask.rs#L205), [`core/dr-pipeline/src/neutral.rs:1`](../core/dr-pipeline/src/neutral.rs#L1), [`core/dr-pipeline/src/neutral.rs:300`](../core/dr-pipeline/src/neutral.rs#L300), [`core/dr-pipeline/src/neutral.rs:316`](../core/dr-pipeline/src/neutral.rs#L316), [`core/dr-pipeline/src/neutral.rs:332`](../core/dr-pipeline/src/neutral.rs#L332), [`core/dr-pipeline/src/neutral.rs:354`](../core/dr-pipeline/src/neutral.rs#L354), [`core/dr-pipeline/src/neutral.rs:79`](../core/dr-pipeline/src/neutral.rs#L79), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:535`](../core/dr-pipeline/src/operation.rs#L535), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:210`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L210), [`core/dr-pipeline/src/ops/curve.rs:100`](../core/dr-pipeline/src/ops/curve.rs#L100), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:219`](../core/dr-pipeline/src/ops/curve.rs#L219), [`core/dr-pipeline/src/ops/curve.rs:635`](../core/dr-pipeline/src/ops/curve.rs#L635), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:273`](../core/dr-pipeline/src/ops/noise_reduction.rs#L273), [`core/dr-pipeline/src/sidecar.rs:1288`](../core/dr-pipeline/src/sidecar.rs#L1288), [`core/dr-pipeline/src/sidecar.rs:160`](../core/dr-pipeline/src/sidecar.rs#L160), [`core/dr-pipeline/src/sidecar.rs:2114`](../core/dr-pipeline/src/sidecar.rs#L2114), [`core/dr-pipeline/src/sidecar.rs:2174`](../core/dr-pipeline/src/sidecar.rs#L2174), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`core/dr-segment/src/refine.rs:850`](../core/dr-segment/src/refine.rs#L850), [`ui/dr-ui/src/develop.rs:103`](../ui/dr-ui/src/develop.rs#L103), [`ui/dr-ui/src/develop.rs:1219`](../ui/dr-ui/src/develop.rs#L1219), [`ui/dr-ui/src/develop.rs:1308`](../ui/dr-ui/src/develop.rs#L1308), [`ui/dr-ui/src/develop.rs:1344`](../ui/dr-ui/src/develop.rs#L1344), [`ui/dr-ui/src/develop.rs:1615`](../ui/dr-ui/src/develop.rs#L1615), [`ui/dr-ui/src/develop.rs:165`](../ui/dr-ui/src/develop.rs#L165), [`ui/dr-ui/src/develop.rs:2034`](../ui/dr-ui/src/develop.rs#L2034), [`ui/dr-ui/src/develop.rs:2264`](../ui/dr-ui/src/develop.rs#L2264), [`ui/dr-ui/src/develop.rs:2282`](../ui/dr-ui/src/develop.rs#L2282), [`ui/dr-ui/src/develop.rs:2296`](../ui/dr-ui/src/develop.rs#L2296), [`ui/dr-ui/src/develop.rs:2318`](../ui/dr-ui/src/develop.rs#L2318), [`ui/dr-ui/src/develop.rs:2464`](../ui/dr-ui/src/develop.rs#L2464), [`ui/dr-ui/src/develop.rs:2562`](../ui/dr-ui/src/develop.rs#L2562), [`ui/dr-ui/src/develop.rs:328`](../ui/dr-ui/src/develop.rs#L328), [`ui/dr-ui/src/develop.rs:365`](../ui/dr-ui/src/develop.rs#L365), [`ui/dr-ui/src/develop.rs:4043`](../ui/dr-ui/src/develop.rs#L4043), [`ui/dr-ui/src/develop.rs:4199`](../ui/dr-ui/src/develop.rs#L4199), [`ui/dr-ui/src/develop.rs:4259`](../ui/dr-ui/src/develop.rs#L4259), [`ui/dr-ui/src/develop.rs:4660`](../ui/dr-ui/src/develop.rs#L4660), [`ui/dr-ui/src/develop.rs:4690`](../ui/dr-ui/src/develop.rs#L4690), [`ui/dr-ui/src/develop.rs:4756`](../ui/dr-ui/src/develop.rs#L4756), [`ui/dr-ui/src/develop.rs:4770`](../ui/dr-ui/src/develop.rs#L4770), [`ui/dr-ui/src/develop.rs:4972`](../ui/dr-ui/src/develop.rs#L4972), [`ui/dr-ui/src/develop.rs:5060`](../ui/dr-ui/src/develop.rs#L5060), [`ui/dr-ui/src/develop.rs:5888`](../ui/dr-ui/src/develop.rs#L5888), [`ui/dr-ui/src/develop.rs:588`](../ui/dr-ui/src/develop.rs#L588), [`ui/dr-ui/src/develop.rs:6081`](../ui/dr-ui/src/develop.rs#L6081), [`ui/dr-ui/src/develop.rs:6169`](../ui/dr-ui/src/develop.rs#L6169), [`ui/dr-ui/src/develop.rs:6325`](../ui/dr-ui/src/develop.rs#L6325), [`ui/dr-ui/src/develop.rs:6462`](../ui/dr-ui/src/develop.rs#L6462), [`ui/dr-ui/src/develop.rs:649`](../ui/dr-ui/src/develop.rs#L649), [`ui/dr-ui/src/develop.rs:6506`](../ui/dr-ui/src/develop.rs#L6506), [`ui/dr-ui/src/develop.rs:6556`](../ui/dr-ui/src/develop.rs#L6556), [`ui/dr-ui/src/develop.rs:6981`](../ui/dr-ui/src/develop.rs#L6981), [`ui/dr-ui/src/develop.rs:7658`](../ui/dr-ui/src/develop.rs#L7658), [`ui/dr-ui/src/develop.rs:803`](../ui/dr-ui/src/develop.rs#L803), [`ui/dr-ui/src/develop.rs:874`](../ui/dr-ui/src/develop.rs#L874), [`ui/dr-ui/src/develop.rs:903`](../ui/dr-ui/src/develop.rs#L903), [`ui/dr-ui/src/labels.rs:32`](../ui/dr-ui/src/labels.rs#L32), [`ui/dr-ui/src/lib.rs:1790`](../ui/dr-ui/src/lib.rs#L1790), [`ui/dr-ui/src/lib.rs:2644`](../ui/dr-ui/src/lib.rs#L2644), [`ui/dr-ui/src/lib.rs:2842`](../ui/dr-ui/src/lib.rs#L2842), [`ui/dr-ui/src/lib.rs:2901`](../ui/dr-ui/src/lib.rs#L2901), [`ui/dr-ui/src/lib.rs:3106`](../ui/dr-ui/src/lib.rs#L3106), [`ui/dr-ui/src/lib.rs:3170`](../ui/dr-ui/src/lib.rs#L3170), [`ui/dr-ui/src/lib.rs:3224`](../ui/dr-ui/src/lib.rs#L3224), [`ui/dr-ui/src/lib.rs:381`](../ui/dr-ui/src/lib.rs#L381), [`ui/dr-ui/src/lib.rs:402`](../ui/dr-ui/src/lib.rs#L402), [`ui/dr-ui/src/lib.rs:507`](../ui/dr-ui/src/lib.rs#L507), [`ui/dr-ui/src/lib.rs:530`](../ui/dr-ui/src/lib.rs#L530), [`ui/dr-ui/src/library.rs:547`](../ui/dr-ui/src/library.rs#L547), [`ui/dr-ui/src/masks_ui.rs:1215`](../ui/dr-ui/src/masks_ui.rs#L1215), [`ui/dr-ui/src/masks_ui.rs:1329`](../ui/dr-ui/src/masks_ui.rs#L1329), [`ui/dr-ui/src/masks_ui.rs:290`](../ui/dr-ui/src/masks_ui.rs#L290), [`ui/dr-ui/src/masks_ui.rs:43`](../ui/dr-ui/src/masks_ui.rs#L43), [`ui/dr-ui/src/presets.rs:409`](../ui/dr-ui/src/presets.rs#L409), [`ui/dr-ui/src/segmentation.rs:116`](../ui/dr-ui/src/segmentation.rs#L116), [`ui/dr-ui/src/segmentation.rs:330`](../ui/dr-ui/src/segmentation.rs#L330), [`ui/dr-ui/src/segmentation.rs:346`](../ui/dr-ui/src/segmentation.rs#L346), [`ui/dr-ui/src/segmentation.rs:476`](../ui/dr-ui/src/segmentation.rs#L476), [`ui/dr-ui/src/segmentation.rs:504`](../ui/dr-ui/src/segmentation.rs#L504), [`ui/dr-ui/ui/adjust.slint:1073`](../ui/dr-ui/ui/adjust.slint#L1073), [`ui/dr-ui/ui/adjust.slint:125`](../ui/dr-ui/ui/adjust.slint#L125), [`ui/dr-ui/ui/adjust.slint:1369`](../ui/dr-ui/ui/adjust.slint#L1369), [`ui/dr-ui/ui/adjust.slint:170`](../ui/dr-ui/ui/adjust.slint#L170), [`ui/dr-ui/ui/adjust.slint:45`](../ui/dr-ui/ui/adjust.slint#L45), [`ui/dr-ui/ui/adjust.slint:624`](../ui/dr-ui/ui/adjust.slint#L624), [`ui/dr-ui/ui/adjust.slint:743`](../ui/dr-ui/ui/adjust.slint#L743), [`ui/dr-ui/ui/app.slint:1067`](../ui/dr-ui/ui/app.slint#L1067), [`ui/dr-ui/ui/app.slint:156`](../ui/dr-ui/ui/app.slint#L156), [`ui/dr-ui/ui/app.slint:202`](../ui/dr-ui/ui/app.slint#L202), [`ui/dr-ui/ui/app.slint:2168`](../ui/dr-ui/ui/app.slint#L2168), [`ui/dr-ui/ui/app.slint:2307`](../ui/dr-ui/ui/app.slint#L2307), [`ui/dr-ui/ui/app.slint:242`](../ui/dr-ui/ui/app.slint#L242), [`ui/dr-ui/ui/app.slint:3155`](../ui/dr-ui/ui/app.slint#L3155), [`ui/dr-ui/ui/app.slint:68`](../ui/dr-ui/ui/app.slint#L68), [`ui/dr-ui/ui/develop.slint:220`](../ui/dr-ui/ui/develop.slint#L220), [`ui/dr-ui/ui/masks.slint:961`](../ui/dr-ui/ui/masks.slint#L961) | +| FR-DEV-3a | [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:212`](../core/dr-pipeline/src/descriptor.rs#L212), [`core/dr-pipeline/src/descriptor.rs:252`](../core/dr-pipeline/src/descriptor.rs#L252), [`core/dr-pipeline/src/descriptor.rs:276`](../core/dr-pipeline/src/descriptor.rs#L276), [`core/dr-pipeline/src/descriptor.rs:331`](../core/dr-pipeline/src/descriptor.rs#L331), [`core/dr-pipeline/src/framing.rs:395`](../core/dr-pipeline/src/framing.rs#L395), [`core/dr-pipeline/src/graph.rs:25`](../core/dr-pipeline/src/graph.rs#L25), [`core/dr-pipeline/src/graph.rs:337`](../core/dr-pipeline/src/graph.rs#L337), [`core/dr-pipeline/src/graph.rs:439`](../core/dr-pipeline/src/graph.rs#L439), [`core/dr-pipeline/src/graph.rs:47`](../core/dr-pipeline/src/graph.rs#L47), [`core/dr-pipeline/src/graph.rs:60`](../core/dr-pipeline/src/graph.rs#L60), [`core/dr-pipeline/src/mask.rs:1666`](../core/dr-pipeline/src/mask.rs#L1666), [`core/dr-pipeline/src/neutral.rs:1`](../core/dr-pipeline/src/neutral.rs#L1), [`core/dr-pipeline/src/neutral.rs:372`](../core/dr-pipeline/src/neutral.rs#L372), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365), [`core/dr-pipeline/src/ops/curve.rs:319`](../core/dr-pipeline/src/ops/curve.rs#L319), [`ui/dr-ui/src/develop.rs:1231`](../ui/dr-ui/src/develop.rs#L1231), [`ui/dr-ui/src/develop.rs:1510`](../ui/dr-ui/src/develop.rs#L1510), [`ui/dr-ui/src/develop.rs:6981`](../ui/dr-ui/src/develop.rs#L6981), [`ui/dr-ui/src/lib.rs:824`](../ui/dr-ui/src/lib.rs#L824), [`ui/dr-ui/tests/ui_names_no_operation.rs:1`](../ui/dr-ui/tests/ui_names_no_operation.rs#L1), [`ui/dr-ui/ui/app.slint:2491`](../ui/dr-ui/ui/app.slint#L2491) | +| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:276`](../core/dr-pipeline/src/descriptor.rs#L276), [`core/dr-pipeline/src/framing.rs:395`](../core/dr-pipeline/src/framing.rs#L395), [`core/dr-pipeline/src/graph.rs:60`](../core/dr-pipeline/src/graph.rs#L60), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365), [`ui/dr-ui/ui/app.slint:2680`](../ui/dr-ui/ui/app.slint#L2680) | +| FR-DEV-3c | [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:337`](../core/dr-pipeline/src/graph.rs#L337), [`core/dr-pipeline/src/graph.rs:47`](../core/dr-pipeline/src/graph.rs#L47), [`core/dr-pipeline/src/mask.rs:1666`](../core/dr-pipeline/src/mask.rs#L1666), [`ui/dr-ui/src/develop.rs:7179`](../ui/dr-ui/src/develop.rs#L7179) | | FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:104`](../core/dr-gpu/src/adjust.rs#L104), [`core/dr-gpu/src/adjust.rs:1079`](../core/dr-gpu/src/adjust.rs#L1079), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/src/adjust.rs:986`](../core/dr-gpu/src/adjust.rs#L986), [`core/dr-gpu/tests/capture_sharpen.rs:434`](../core/dr-gpu/tests/capture_sharpen.rs#L434), [`core/dr-gpu/tests/detail_stage.rs:242`](../core/dr-gpu/tests/detail_stage.rs#L242), [`core/dr-gpu/tests/local_contrast.rs:558`](../core/dr-gpu/tests/local_contrast.rs#L558), [`core/dr-gpu/tests/noise_reduction.rs:556`](../core/dr-gpu/tests/noise_reduction.rs#L556), [`core/dr-pipeline/src/framing.rs:314`](../core/dr-pipeline/src/framing.rs#L314), [`core/dr-pipeline/src/graph.rs:801`](../core/dr-pipeline/src/graph.rs#L801), [`core/dr-pipeline/src/operation.rs:32`](../core/dr-pipeline/src/operation.rs#L32), [`core/dr-pipeline/src/operation.rs:403`](../core/dr-pipeline/src/operation.rs#L403), [`core/dr-pipeline/src/operation.rs:53`](../core/dr-pipeline/src/operation.rs#L53), [`core/dr-pipeline/src/operation.rs:71`](../core/dr-pipeline/src/operation.rs#L71) | | FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:121`](../core/dr-decode/src/lib.rs#L121), [`core/dr-decode/src/lib.rs:708`](../core/dr-decode/src/lib.rs#L708), [`core/dr-decode/src/lib.rs:748`](../core/dr-decode/src/lib.rs#L748), [`core/dr-decode/src/profile.rs:102`](../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:458`](../core/dr-decode/src/profile.rs#L458), [`core/dr-decode/src/profile.rs:492`](../core/dr-decode/src/profile.rs#L492), [`core/dr-decode/src/profile.rs:630`](../core/dr-decode/src/profile.rs#L630), [`core/dr-gpu/src/adjust.rs:37`](../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/adjust.rs:967`](../core/dr-gpu/src/adjust.rs#L967), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1762`](../core/dr-pipeline/src/operation.rs#L1762), [`core/dr-pipeline/src/operation.rs:1843`](../core/dr-pipeline/src/operation.rs#L1843), [`core/dr-pipeline/src/operation.rs:1868`](../core/dr-pipeline/src/operation.rs#L1868), [`core/dr-pipeline/src/operation.rs:1883`](../core/dr-pipeline/src/operation.rs#L1883), [`core/dr-pipeline/src/operation.rs:1907`](../core/dr-pipeline/src/operation.rs#L1907), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/operation.rs:454`](../core/dr-pipeline/src/operation.rs#L454), [`core/dr-pipeline/src/operation.rs:464`](../core/dr-pipeline/src/operation.rs#L464), [`core/dr-pipeline/src/operation.rs:625`](../core/dr-pipeline/src/operation.rs#L625) | -| FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/boolean_grain.rs:1`](../core/dr-film/src/boolean_grain.rs#L1), [`core/dr-film/src/boolean_grain.rs:78`](../core/dr-film/src/boolean_grain.rs#L78), [`core/dr-film/src/grain.rs:140`](../core/dr-film/src/grain.rs#L140), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:302`](../core/dr-film/src/grain.rs#L302), [`core/dr-film/src/grain.rs:79`](../core/dr-film/src/grain.rs#L79), [`core/dr-film/src/lib.rs:160`](../core/dr-film/src/lib.rs#L160), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:103`](../core/dr-pipeline/src/graph.rs#L103), [`core/dr-pipeline/src/graph.rs:153`](../core/dr-pipeline/src/graph.rs#L153), [`core/dr-pipeline/src/graph.rs:471`](../core/dr-pipeline/src/graph.rs#L471), [`core/dr-pipeline/src/operation.rs:1137`](../core/dr-pipeline/src/operation.rs#L1137), [`core/dr-pipeline/src/operation.rs:1165`](../core/dr-pipeline/src/operation.rs#L1165), [`core/dr-pipeline/src/operation.rs:1211`](../core/dr-pipeline/src/operation.rs#L1211), [`core/dr-pipeline/src/operation.rs:1762`](../core/dr-pipeline/src/operation.rs#L1762), [`core/dr-pipeline/src/operation.rs:296`](../core/dr-pipeline/src/operation.rs#L296), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/ops/film_sim.rs:102`](../core/dr-pipeline/src/ops/film_sim.rs#L102), [`core/dr-pipeline/src/ops/film_sim.rs:139`](../core/dr-pipeline/src/ops/film_sim.rs#L139), [`core/dr-pipeline/src/ops/film_sim.rs:163`](../core/dr-pipeline/src/ops/film_sim.rs#L163), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:341`](../core/dr-pipeline/src/ops/film_sim.rs#L341), [`core/dr-pipeline/src/ops/film_sim.rs:43`](../core/dr-pipeline/src/ops/film_sim.rs#L43), [`core/dr-pipeline/src/ops/film_sim.rs:97`](../core/dr-pipeline/src/ops/film_sim.rs#L97), [`core/dr-pipeline/src/sidecar.rs:113`](../core/dr-pipeline/src/sidecar.rs#L113), [`core/dr-pipeline/src/sidecar.rs:169`](../core/dr-pipeline/src/sidecar.rs#L169), [`core/dr-pipeline/src/sidecar.rs:2264`](../core/dr-pipeline/src/sidecar.rs#L2264), [`core/dr-pipeline/src/sidecar.rs:2340`](../core/dr-pipeline/src/sidecar.rs#L2340), [`core/dr-pipeline/src/sidecar.rs:535`](../core/dr-pipeline/src/sidecar.rs#L535), [`core/dr-pipeline/src/sidecar.rs:779`](../core/dr-pipeline/src/sidecar.rs#L779), [`core/dr-pipeline/src/sidecar.rs:911`](../core/dr-pipeline/src/sidecar.rs#L911), [`core/dr-pipeline/src/state.rs:100`](../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:115`](../core/dr-pipeline/src/state.rs#L115), [`core/dr-pipeline/src/state.rs:60`](../core/dr-pipeline/src/state.rs#L60), [`ui/dr-ui/src/develop.rs:4105`](../ui/dr-ui/src/develop.rs#L4105), [`ui/dr-ui/src/develop.rs:4122`](../ui/dr-ui/src/develop.rs#L4122), [`ui/dr-ui/src/develop.rs:4134`](../ui/dr-ui/src/develop.rs#L4134), [`ui/dr-ui/src/develop.rs:4172`](../ui/dr-ui/src/develop.rs#L4172), [`ui/dr-ui/src/develop.rs:4181`](../ui/dr-ui/src/develop.rs#L4181), [`ui/dr-ui/src/develop.rs:4284`](../ui/dr-ui/src/develop.rs#L4284), [`ui/dr-ui/src/develop.rs:4795`](../ui/dr-ui/src/develop.rs#L4795), [`ui/dr-ui/src/develop.rs:4810`](../ui/dr-ui/src/develop.rs#L4810), [`ui/dr-ui/src/lib.rs:2618`](../ui/dr-ui/src/lib.rs#L2618), [`ui/dr-ui/src/lib.rs:757`](../ui/dr-ui/src/lib.rs#L757), [`ui/dr-ui/src/lib.rs:815`](../ui/dr-ui/src/lib.rs#L815), [`ui/dr-ui/src/library.rs:538`](../ui/dr-ui/src/library.rs#L538), [`ui/dr-ui/src/library.rs:800`](../ui/dr-ui/src/library.rs#L800), [`ui/dr-ui/src/presets.rs:417`](../ui/dr-ui/src/presets.rs#L417), [`ui/dr-ui/ui/adjust.slint:1086`](../ui/dr-ui/ui/adjust.slint#L1086), [`ui/dr-ui/ui/adjust.slint:1162`](../ui/dr-ui/ui/adjust.slint#L1162), [`ui/dr-ui/ui/app.slint:3142`](../ui/dr-ui/ui/app.slint#L3142), [`ui/dr-ui/ui/app.slint:833`](../ui/dr-ui/ui/app.slint#L833) | -| FR-DEV-3h | [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:1067`](../core/dr-pipeline/src/framing.rs#L1067), [`core/dr-pipeline/src/framing.rs:328`](../core/dr-pipeline/src/framing.rs#L328), [`core/dr-pipeline/src/framing.rs:498`](../core/dr-pipeline/src/framing.rs#L498), [`core/dr-types/src/lib.rs:339`](../core/dr-types/src/lib.rs#L339), [`core/dr-types/src/lib.rs:447`](../core/dr-types/src/lib.rs#L447), [`core/dr-types/src/lib.rs:459`](../core/dr-types/src/lib.rs#L459), [`core/dr-types/src/lib.rs:475`](../core/dr-types/src/lib.rs#L475), [`ui/dr-ui/src/develop.rs:140`](../ui/dr-ui/src/develop.rs#L140), [`ui/dr-ui/src/develop.rs:2444`](../ui/dr-ui/src/develop.rs#L2444), [`ui/dr-ui/src/segmentation.rs:476`](../ui/dr-ui/src/segmentation.rs#L476) | +| FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/boolean_grain.rs:1`](../core/dr-film/src/boolean_grain.rs#L1), [`core/dr-film/src/boolean_grain.rs:78`](../core/dr-film/src/boolean_grain.rs#L78), [`core/dr-film/src/grain.rs:140`](../core/dr-film/src/grain.rs#L140), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:302`](../core/dr-film/src/grain.rs#L302), [`core/dr-film/src/grain.rs:79`](../core/dr-film/src/grain.rs#L79), [`core/dr-film/src/lib.rs:160`](../core/dr-film/src/lib.rs#L160), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:103`](../core/dr-pipeline/src/graph.rs#L103), [`core/dr-pipeline/src/graph.rs:153`](../core/dr-pipeline/src/graph.rs#L153), [`core/dr-pipeline/src/graph.rs:471`](../core/dr-pipeline/src/graph.rs#L471), [`core/dr-pipeline/src/operation.rs:1137`](../core/dr-pipeline/src/operation.rs#L1137), [`core/dr-pipeline/src/operation.rs:1165`](../core/dr-pipeline/src/operation.rs#L1165), [`core/dr-pipeline/src/operation.rs:1211`](../core/dr-pipeline/src/operation.rs#L1211), [`core/dr-pipeline/src/operation.rs:1762`](../core/dr-pipeline/src/operation.rs#L1762), [`core/dr-pipeline/src/operation.rs:296`](../core/dr-pipeline/src/operation.rs#L296), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/ops/film_sim.rs:102`](../core/dr-pipeline/src/ops/film_sim.rs#L102), [`core/dr-pipeline/src/ops/film_sim.rs:139`](../core/dr-pipeline/src/ops/film_sim.rs#L139), [`core/dr-pipeline/src/ops/film_sim.rs:163`](../core/dr-pipeline/src/ops/film_sim.rs#L163), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:341`](../core/dr-pipeline/src/ops/film_sim.rs#L341), [`core/dr-pipeline/src/ops/film_sim.rs:43`](../core/dr-pipeline/src/ops/film_sim.rs#L43), [`core/dr-pipeline/src/ops/film_sim.rs:97`](../core/dr-pipeline/src/ops/film_sim.rs#L97), [`core/dr-pipeline/src/sidecar.rs:115`](../core/dr-pipeline/src/sidecar.rs#L115), [`core/dr-pipeline/src/sidecar.rs:171`](../core/dr-pipeline/src/sidecar.rs#L171), [`core/dr-pipeline/src/sidecar.rs:2460`](../core/dr-pipeline/src/sidecar.rs#L2460), [`core/dr-pipeline/src/sidecar.rs:2536`](../core/dr-pipeline/src/sidecar.rs#L2536), [`core/dr-pipeline/src/sidecar.rs:537`](../core/dr-pipeline/src/sidecar.rs#L537), [`core/dr-pipeline/src/sidecar.rs:781`](../core/dr-pipeline/src/sidecar.rs#L781), [`core/dr-pipeline/src/sidecar.rs:953`](../core/dr-pipeline/src/sidecar.rs#L953), [`core/dr-pipeline/src/state.rs:100`](../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:115`](../core/dr-pipeline/src/state.rs#L115), [`core/dr-pipeline/src/state.rs:60`](../core/dr-pipeline/src/state.rs#L60), [`ui/dr-ui/src/develop.rs:4407`](../ui/dr-ui/src/develop.rs#L4407), [`ui/dr-ui/src/develop.rs:4424`](../ui/dr-ui/src/develop.rs#L4424), [`ui/dr-ui/src/develop.rs:4436`](../ui/dr-ui/src/develop.rs#L4436), [`ui/dr-ui/src/develop.rs:4474`](../ui/dr-ui/src/develop.rs#L4474), [`ui/dr-ui/src/develop.rs:4483`](../ui/dr-ui/src/develop.rs#L4483), [`ui/dr-ui/src/develop.rs:4586`](../ui/dr-ui/src/develop.rs#L4586), [`ui/dr-ui/src/develop.rs:5097`](../ui/dr-ui/src/develop.rs#L5097), [`ui/dr-ui/src/develop.rs:5112`](../ui/dr-ui/src/develop.rs#L5112), [`ui/dr-ui/src/lib.rs:2618`](../ui/dr-ui/src/lib.rs#L2618), [`ui/dr-ui/src/lib.rs:757`](../ui/dr-ui/src/lib.rs#L757), [`ui/dr-ui/src/lib.rs:815`](../ui/dr-ui/src/lib.rs#L815), [`ui/dr-ui/src/library.rs:538`](../ui/dr-ui/src/library.rs#L538), [`ui/dr-ui/src/library.rs:800`](../ui/dr-ui/src/library.rs#L800), [`ui/dr-ui/src/presets.rs:417`](../ui/dr-ui/src/presets.rs#L417), [`ui/dr-ui/ui/adjust.slint:1086`](../ui/dr-ui/ui/adjust.slint#L1086), [`ui/dr-ui/ui/adjust.slint:1162`](../ui/dr-ui/ui/adjust.slint#L1162), [`ui/dr-ui/ui/app.slint:3166`](../ui/dr-ui/ui/app.slint#L3166), [`ui/dr-ui/ui/app.slint:777`](../ui/dr-ui/ui/app.slint#L777) | +| FR-DEV-3h | [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:1067`](../core/dr-pipeline/src/framing.rs#L1067), [`core/dr-pipeline/src/framing.rs:328`](../core/dr-pipeline/src/framing.rs#L328), [`core/dr-pipeline/src/framing.rs:498`](../core/dr-pipeline/src/framing.rs#L498), [`core/dr-types/src/lib.rs:339`](../core/dr-types/src/lib.rs#L339), [`core/dr-types/src/lib.rs:447`](../core/dr-types/src/lib.rs#L447), [`core/dr-types/src/lib.rs:459`](../core/dr-types/src/lib.rs#L459), [`core/dr-types/src/lib.rs:475`](../core/dr-types/src/lib.rs#L475), [`ui/dr-ui/src/develop.rs:140`](../ui/dr-ui/src/develop.rs#L140), [`ui/dr-ui/src/develop.rs:2481`](../ui/dr-ui/src/develop.rs#L2481), [`ui/dr-ui/src/segmentation.rs:476`](../ui/dr-ui/src/segmentation.rs#L476) | | FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:351`](../core/dr-gpu/src/lib.rs#L351), [`ui/dr-ui/ui/crop.slint:1`](../ui/dr-ui/ui/crop.slint#L1) | -| FR-DEV-5 | [`core/dr-pipeline/src/graph.rs:492`](../core/dr-pipeline/src/graph.rs#L492), [`core/dr-pipeline/src/graph.rs:540`](../core/dr-pipeline/src/graph.rs#L540), [`core/dr-pipeline/src/history.rs:102`](../core/dr-pipeline/src/history.rs#L102), [`core/dr-pipeline/src/history.rs:110`](../core/dr-pipeline/src/history.rs#L110), [`core/dr-pipeline/src/history.rs:127`](../core/dr-pipeline/src/history.rs#L127), [`core/dr-pipeline/src/history.rs:184`](../core/dr-pipeline/src/history.rs#L184), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:234`](../core/dr-pipeline/src/history.rs#L234), [`core/dr-pipeline/src/history.rs:293`](../core/dr-pipeline/src/history.rs#L293), [`core/dr-pipeline/src/history.rs:479`](../core/dr-pipeline/src/history.rs#L479), [`core/dr-pipeline/src/history.rs:489`](../core/dr-pipeline/src/history.rs#L489), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`ui/dr-ui/src/develop.rs:3741`](../ui/dr-ui/src/develop.rs#L3741), [`ui/dr-ui/src/develop.rs:4134`](../ui/dr-ui/src/develop.rs#L4134), [`ui/dr-ui/src/develop.rs:4810`](../ui/dr-ui/src/develop.rs#L4810), [`ui/dr-ui/src/develop.rs:4840`](../ui/dr-ui/src/develop.rs#L4840), [`ui/dr-ui/src/develop.rs:4853`](../ui/dr-ui/src/develop.rs#L4853), [`ui/dr-ui/src/develop.rs:4865`](../ui/dr-ui/src/develop.rs#L4865), [`ui/dr-ui/src/develop.rs:4881`](../ui/dr-ui/src/develop.rs#L4881), [`ui/dr-ui/src/develop.rs:4913`](../ui/dr-ui/src/develop.rs#L4913), [`ui/dr-ui/src/develop.rs:4917`](../ui/dr-ui/src/develop.rs#L4917), [`ui/dr-ui/src/develop.rs:4936`](../ui/dr-ui/src/develop.rs#L4936), [`ui/dr-ui/src/develop.rs:4952`](../ui/dr-ui/src/develop.rs#L4952), [`ui/dr-ui/src/develop.rs:5759`](../ui/dr-ui/src/develop.rs#L5759), [`ui/dr-ui/src/develop.rs:5779`](../ui/dr-ui/src/develop.rs#L5779), [`ui/dr-ui/src/develop.rs:5810`](../ui/dr-ui/src/develop.rs#L5810), [`ui/dr-ui/src/develop.rs:746`](../ui/dr-ui/src/develop.rs#L746), [`ui/dr-ui/src/labels.rs:12`](../ui/dr-ui/src/labels.rs#L12), [`ui/dr-ui/src/labels.rs:246`](../ui/dr-ui/src/labels.rs#L246), [`ui/dr-ui/src/lib.rs:1706`](../ui/dr-ui/src/lib.rs#L1706), [`ui/dr-ui/src/lib.rs:1768`](../ui/dr-ui/src/lib.rs#L1768), [`ui/dr-ui/src/lib.rs:1776`](../ui/dr-ui/src/lib.rs#L1776), [`ui/dr-ui/src/lib.rs:2875`](../ui/dr-ui/src/lib.rs#L2875), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) | -| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-pipeline/src/preset.rs:381`](../core/dr-pipeline/src/preset.rs#L381), [`core/dr-pipeline/src/preset.rs:414`](../core/dr-pipeline/src/preset.rs#L414), [`core/dr-pipeline/src/starter.rs:1`](../core/dr-pipeline/src/starter.rs#L1), [`core/dr-preset-xmp/src/lib.rs:1`](../core/dr-preset-xmp/src/lib.rs#L1), [`core/dr-types/src/settings.rs:272`](../core/dr-types/src/settings.rs#L272), [`ui/dr-ui/src/develop.rs:4752`](../ui/dr-ui/src/develop.rs#L4752), [`ui/dr-ui/src/develop.rs:4773`](../ui/dr-ui/src/develop.rs#L4773), [`ui/dr-ui/src/lib.rs:1673`](../ui/dr-ui/src/lib.rs#L1673), [`ui/dr-ui/src/lib.rs:2423`](../ui/dr-ui/src/lib.rs#L2423), [`ui/dr-ui/src/lib.rs:2452`](../ui/dr-ui/src/lib.rs#L2452), [`ui/dr-ui/src/library.rs:2219`](../ui/dr-ui/src/library.rs#L2219), [`ui/dr-ui/src/library.rs:500`](../ui/dr-ui/src/library.rs#L500), [`ui/dr-ui/src/library.rs:528`](../ui/dr-ui/src/library.rs#L528), [`ui/dr-ui/src/library_ui.rs:3194`](../ui/dr-ui/src/library_ui.rs#L3194), [`ui/dr-ui/src/library_ui.rs:3594`](../ui/dr-ui/src/library_ui.rs#L3594), [`ui/dr-ui/src/library_ui.rs:548`](../ui/dr-ui/src/library_ui.rs#L548), [`ui/dr-ui/src/preset_store.rs:1`](../ui/dr-ui/src/preset_store.rs#L1), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/presets.rs:679`](../ui/dr-ui/src/presets.rs#L679), [`ui/dr-ui/src/presets.rs:713`](../ui/dr-ui/src/presets.rs#L713), [`ui/dr-ui/ui/adjust.slint:817`](../ui/dr-ui/ui/adjust.slint#L817), [`ui/dr-ui/ui/adjust.slint:844`](../ui/dr-ui/ui/adjust.slint#L844), [`ui/dr-ui/ui/adjust.slint:878`](../ui/dr-ui/ui/adjust.slint#L878), [`ui/dr-ui/ui/app.slint:1762`](../ui/dr-ui/ui/app.slint#L1762), [`ui/dr-ui/ui/app.slint:2453`](../ui/dr-ui/ui/app.slint#L2453), [`ui/dr-ui/ui/app.slint:3231`](../ui/dr-ui/ui/app.slint#L3231), [`ui/dr-ui/ui/app.slint:917`](../ui/dr-ui/ui/app.slint#L917), [`ui/dr-ui/ui/app.slint:928`](../ui/dr-ui/ui/app.slint#L928), [`ui/dr-ui/ui/library.slint:1574`](../ui/dr-ui/ui/library.slint#L1574), [`ui/dr-ui/ui/library.slint:1580`](../ui/dr-ui/ui/library.slint#L1580), [`ui/dr-ui/ui/library.slint:4217`](../ui/dr-ui/ui/library.slint#L4217), [`ui/dr-ui/ui/library.slint:4232`](../ui/dr-ui/ui/library.slint#L4232), [`ui/dr-ui/ui/presets.slint:105`](../ui/dr-ui/ui/presets.slint#L105), [`ui/dr-ui/ui/presets.slint:113`](../ui/dr-ui/ui/presets.slint#L113), [`ui/dr-ui/ui/presets.slint:19`](../ui/dr-ui/ui/presets.slint#L19), [`ui/dr-ui/ui/presets.slint:276`](../ui/dr-ui/ui/presets.slint#L276), [`ui/dr-ui/ui/presets.slint:297`](../ui/dr-ui/ui/presets.slint#L297), [`ui/dr-ui/ui/presets.slint:5`](../ui/dr-ui/ui/presets.slint#L5), [`ui/dr-ui/ui/presets.slint:60`](../ui/dr-ui/ui/presets.slint#L60), [`ui/dr-ui/ui/settings.slint:101`](../ui/dr-ui/ui/settings.slint#L101), [`ui/dr-ui/ui/settings.slint:595`](../ui/dr-ui/ui/settings.slint#L595) | -| FR-DEV-7 | [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`ui/dr-ui/src/develop.rs:3675`](../ui/dr-ui/src/develop.rs#L3675), [`ui/dr-ui/src/develop.rs:3716`](../ui/dr-ui/src/develop.rs#L3716), [`ui/dr-ui/src/develop.rs:4881`](../ui/dr-ui/src/develop.rs#L4881), [`ui/dr-ui/src/develop.rs:4913`](../ui/dr-ui/src/develop.rs#L4913), [`ui/dr-ui/src/develop.rs:5810`](../ui/dr-ui/src/develop.rs#L5810), [`ui/dr-ui/src/lib.rs:1776`](../ui/dr-ui/src/lib.rs#L1776), [`ui/dr-ui/src/lib.rs:1837`](../ui/dr-ui/src/lib.rs#L1837), [`ui/dr-ui/src/lib.rs:2814`](../ui/dr-ui/src/lib.rs#L2814), [`ui/dr-ui/src/lib.rs:2875`](../ui/dr-ui/src/lib.rs#L2875), [`ui/dr-ui/src/lib.rs:373`](../ui/dr-ui/src/lib.rs#L373), [`ui/dr-ui/ui/app.slint:225`](../ui/dr-ui/ui/app.slint#L225), [`ui/dr-ui/ui/app.slint:2360`](../ui/dr-ui/ui/app.slint#L2360), [`ui/dr-ui/ui/app.slint:2375`](../ui/dr-ui/ui/app.slint#L2375), [`ui/dr-ui/ui/app.slint:2673`](../ui/dr-ui/ui/app.slint#L2673), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1), [`ui/dr-ui/ui/widgets.slint:71`](../ui/dr-ui/ui/widgets.slint#L71) | -| FR-DEV-8 | [`core/dr-gpu/src/detail.rs:405`](../core/dr-gpu/src/detail.rs#L405), [`core/dr-gpu/src/detail.rs:623`](../core/dr-gpu/src/detail.rs#L623), [`core/dr-gpu/tests/detail_instances.rs:1`](../core/dr-gpu/tests/detail_instances.rs#L1), [`core/dr-gpu/tests/spot_removal.rs:1`](../core/dr-gpu/tests/spot_removal.rs#L1), [`core/dr-pipeline/src/detail.rs:410`](../core/dr-pipeline/src/detail.rs#L410), [`core/dr-pipeline/src/detail.rs:434`](../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:469`](../core/dr-pipeline/src/detail.rs#L469), [`core/dr-pipeline/src/detail.rs:555`](../core/dr-pipeline/src/detail.rs#L555), [`core/dr-pipeline/src/graph.rs:115`](../core/dr-pipeline/src/graph.rs#L115), [`core/dr-pipeline/src/graph.rs:231`](../core/dr-pipeline/src/graph.rs#L231), [`core/dr-pipeline/src/graph.rs:886`](../core/dr-pipeline/src/graph.rs#L886), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:579`](../core/dr-pipeline/src/operation.rs#L579), [`core/dr-pipeline/src/sidecar.rs:1011`](../core/dr-pipeline/src/sidecar.rs#L1011), [`core/dr-pipeline/src/sidecar.rs:185`](../core/dr-pipeline/src/sidecar.rs#L185), [`core/dr-pipeline/src/sidecar.rs:354`](../core/dr-pipeline/src/sidecar.rs#L354), [`core/dr-pipeline/src/sidecar.rs:791`](../core/dr-pipeline/src/sidecar.rs#L791), [`core/dr-pipeline/src/sidecar.rs:927`](../core/dr-pipeline/src/sidecar.rs#L927), [`core/dr-pipeline/src/sidecar.rs:981`](../core/dr-pipeline/src/sidecar.rs#L981), [`core/dr-pipeline/src/spot.rs:115`](../core/dr-pipeline/src/spot.rs#L115), [`core/dr-pipeline/src/spot.rs:151`](../core/dr-pipeline/src/spot.rs#L151), [`core/dr-pipeline/src/spot.rs:1`](../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:207`](../core/dr-pipeline/src/spot.rs#L207), [`core/dr-pipeline/src/spot.rs:387`](../core/dr-pipeline/src/spot.rs#L387), [`core/dr-pipeline/src/spot.rs:472`](../core/dr-pipeline/src/spot.rs#L472), [`core/dr-pipeline/src/spot.rs:582`](../core/dr-pipeline/src/spot.rs#L582), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`core/dr-pipeline/src/state.rs:103`](../core/dr-pipeline/src/state.rs#L103), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-pipeline/tests/spots.rs:1`](../core/dr-pipeline/tests/spots.rs#L1), [`ui/dr-ui/src/develop.rs:2596`](../ui/dr-ui/src/develop.rs#L2596), [`ui/dr-ui/src/develop.rs:2634`](../ui/dr-ui/src/develop.rs#L2634), [`ui/dr-ui/src/develop.rs:2699`](../ui/dr-ui/src/develop.rs#L2699), [`ui/dr-ui/src/develop.rs:2782`](../ui/dr-ui/src/develop.rs#L2782), [`ui/dr-ui/src/develop.rs:2796`](../ui/dr-ui/src/develop.rs#L2796), [`ui/dr-ui/src/develop.rs:833`](../ui/dr-ui/src/develop.rs#L833), [`ui/dr-ui/src/labels.rs:64`](../ui/dr-ui/src/labels.rs#L64), [`ui/dr-ui/src/lib.rs:1797`](../ui/dr-ui/src/lib.rs#L1797), [`ui/dr-ui/src/lib.rs:3026`](../ui/dr-ui/src/lib.rs#L3026), [`ui/dr-ui/src/lib.rs:407`](../ui/dr-ui/src/lib.rs#L407), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/src/spots_ui.rs:1`](../ui/dr-ui/src/spots_ui.rs#L1), [`ui/dr-ui/src/spots_ui.rs:265`](../ui/dr-ui/src/spots_ui.rs#L265), [`ui/dr-ui/ui/adjust.slint:909`](../ui/dr-ui/ui/adjust.slint#L909), [`ui/dr-ui/ui/app.slint:147`](../ui/dr-ui/ui/app.slint#L147), [`ui/dr-ui/ui/app.slint:2137`](../ui/dr-ui/ui/app.slint#L2137), [`ui/dr-ui/ui/app.slint:2418`](../ui/dr-ui/ui/app.slint#L2418), [`ui/dr-ui/ui/app.slint:3025`](../ui/dr-ui/ui/app.slint#L3025), [`ui/dr-ui/ui/icons.slint:338`](../ui/dr-ui/ui/icons.slint#L338), [`ui/dr-ui/ui/spots.slint:172`](../ui/dr-ui/ui/spots.slint#L172), [`ui/dr-ui/ui/spots.slint:48`](../ui/dr-ui/ui/spots.slint#L48), [`ui/dr-ui/ui/spots.slint:5`](../ui/dr-ui/ui/spots.slint#L5) | -| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2140`](../core/dr-gpu/src/adjust.rs#L2140), [`core/dr-gpu/src/adjust.rs:2217`](../core/dr-gpu/src/adjust.rs#L2217), [`core/dr-gpu/src/adjust.rs:2302`](../core/dr-gpu/src/adjust.rs#L2302), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:172`](../core/dr-gpu/src/lib.rs#L172), [`core/dr-gpu/src/lib.rs:192`](../core/dr-gpu/src/lib.rs#L192), [`core/dr-gpu/src/lib.rs:70`](../core/dr-gpu/src/lib.rs#L70), [`core/dr-gpu/src/lib.rs:93`](../core/dr-gpu/src/lib.rs#L93), [`core/dr-gpu/tests/capture_sharpen.rs:200`](../core/dr-gpu/tests/capture_sharpen.rs#L200), [`core/dr-gpu/tests/detail_stage.rs:328`](../core/dr-gpu/tests/detail_stage.rs#L328), [`core/dr-gpu/tests/local_contrast.rs:264`](../core/dr-gpu/tests/local_contrast.rs#L264), [`core/dr-gpu/tests/noise_reduction.rs:378`](../core/dr-gpu/tests/noise_reduction.rs#L378), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:524`](../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/graph.rs:732`](../core/dr-pipeline/src/graph.rs#L732), [`core/dr-pipeline/src/graph.rs:762`](../core/dr-pipeline/src/graph.rs#L762), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:659`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L659), [`core/dr-pipeline/src/ops/dehaze.rs:1`](../core/dr-pipeline/src/ops/dehaze.rs#L1), [`core/dr-pipeline/src/ops/dehaze.rs:592`](../core/dr-pipeline/src/ops/dehaze.rs#L592), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:971`](../core/dr-pipeline/src/ops/local_contrast.rs#L971), [`core/dr-pipeline/src/ops/noise_reduction.rs:700`](../core/dr-pipeline/src/ops/noise_reduction.rs#L700), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`ui/dr-ui/src/develop.rs:3367`](../ui/dr-ui/src/develop.rs#L3367), [`ui/dr-ui/src/develop.rs:4620`](../ui/dr-ui/src/develop.rs#L4620), [`ui/dr-ui/src/develop.rs:5207`](../ui/dr-ui/src/develop.rs#L5207), [`ui/dr-ui/src/develop.rs:6377`](../ui/dr-ui/src/develop.rs#L6377), [`ui/dr-ui/src/develop.rs:6411`](../ui/dr-ui/src/develop.rs#L6411), [`ui/dr-ui/src/lib.rs:1024`](../ui/dr-ui/src/lib.rs#L1024), [`ui/dr-ui/src/lib.rs:93`](../ui/dr-ui/src/lib.rs#L93), [`ui/dr-ui/src/lib.rs:965`](../ui/dr-ui/src/lib.rs#L965) | +| FR-DEV-5 | [`core/dr-pipeline/src/graph.rs:492`](../core/dr-pipeline/src/graph.rs#L492), [`core/dr-pipeline/src/graph.rs:540`](../core/dr-pipeline/src/graph.rs#L540), [`core/dr-pipeline/src/history.rs:102`](../core/dr-pipeline/src/history.rs#L102), [`core/dr-pipeline/src/history.rs:110`](../core/dr-pipeline/src/history.rs#L110), [`core/dr-pipeline/src/history.rs:127`](../core/dr-pipeline/src/history.rs#L127), [`core/dr-pipeline/src/history.rs:184`](../core/dr-pipeline/src/history.rs#L184), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:234`](../core/dr-pipeline/src/history.rs#L234), [`core/dr-pipeline/src/history.rs:293`](../core/dr-pipeline/src/history.rs#L293), [`core/dr-pipeline/src/history.rs:479`](../core/dr-pipeline/src/history.rs#L479), [`core/dr-pipeline/src/history.rs:489`](../core/dr-pipeline/src/history.rs#L489), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`ui/dr-ui/src/develop.rs:4043`](../ui/dr-ui/src/develop.rs#L4043), [`ui/dr-ui/src/develop.rs:4436`](../ui/dr-ui/src/develop.rs#L4436), [`ui/dr-ui/src/develop.rs:5112`](../ui/dr-ui/src/develop.rs#L5112), [`ui/dr-ui/src/develop.rs:5142`](../ui/dr-ui/src/develop.rs#L5142), [`ui/dr-ui/src/develop.rs:5155`](../ui/dr-ui/src/develop.rs#L5155), [`ui/dr-ui/src/develop.rs:5167`](../ui/dr-ui/src/develop.rs#L5167), [`ui/dr-ui/src/develop.rs:5183`](../ui/dr-ui/src/develop.rs#L5183), [`ui/dr-ui/src/develop.rs:5215`](../ui/dr-ui/src/develop.rs#L5215), [`ui/dr-ui/src/develop.rs:5219`](../ui/dr-ui/src/develop.rs#L5219), [`ui/dr-ui/src/develop.rs:5238`](../ui/dr-ui/src/develop.rs#L5238), [`ui/dr-ui/src/develop.rs:5254`](../ui/dr-ui/src/develop.rs#L5254), [`ui/dr-ui/src/develop.rs:6061`](../ui/dr-ui/src/develop.rs#L6061), [`ui/dr-ui/src/develop.rs:6081`](../ui/dr-ui/src/develop.rs#L6081), [`ui/dr-ui/src/develop.rs:6112`](../ui/dr-ui/src/develop.rs#L6112), [`ui/dr-ui/src/develop.rs:746`](../ui/dr-ui/src/develop.rs#L746), [`ui/dr-ui/src/labels.rs:12`](../ui/dr-ui/src/labels.rs#L12), [`ui/dr-ui/src/labels.rs:263`](../ui/dr-ui/src/labels.rs#L263), [`ui/dr-ui/src/lib.rs:1706`](../ui/dr-ui/src/lib.rs#L1706), [`ui/dr-ui/src/lib.rs:1768`](../ui/dr-ui/src/lib.rs#L1768), [`ui/dr-ui/src/lib.rs:1776`](../ui/dr-ui/src/lib.rs#L1776), [`ui/dr-ui/src/lib.rs:2875`](../ui/dr-ui/src/lib.rs#L2875), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) | +| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-pipeline/src/preset.rs:381`](../core/dr-pipeline/src/preset.rs#L381), [`core/dr-pipeline/src/preset.rs:414`](../core/dr-pipeline/src/preset.rs#L414), [`core/dr-pipeline/src/starter.rs:1`](../core/dr-pipeline/src/starter.rs#L1), [`core/dr-preset-xmp/src/lib.rs:1`](../core/dr-preset-xmp/src/lib.rs#L1), [`core/dr-types/src/settings.rs:272`](../core/dr-types/src/settings.rs#L272), [`ui/dr-ui/src/develop.rs:5054`](../ui/dr-ui/src/develop.rs#L5054), [`ui/dr-ui/src/develop.rs:5075`](../ui/dr-ui/src/develop.rs#L5075), [`ui/dr-ui/src/lib.rs:1673`](../ui/dr-ui/src/lib.rs#L1673), [`ui/dr-ui/src/lib.rs:2423`](../ui/dr-ui/src/lib.rs#L2423), [`ui/dr-ui/src/lib.rs:2452`](../ui/dr-ui/src/lib.rs#L2452), [`ui/dr-ui/src/library.rs:2219`](../ui/dr-ui/src/library.rs#L2219), [`ui/dr-ui/src/library.rs:500`](../ui/dr-ui/src/library.rs#L500), [`ui/dr-ui/src/library.rs:528`](../ui/dr-ui/src/library.rs#L528), [`ui/dr-ui/src/library_ui.rs:3200`](../ui/dr-ui/src/library_ui.rs#L3200), [`ui/dr-ui/src/library_ui.rs:3600`](../ui/dr-ui/src/library_ui.rs#L3600), [`ui/dr-ui/src/library_ui.rs:554`](../ui/dr-ui/src/library_ui.rs#L554), [`ui/dr-ui/src/preset_store.rs:1`](../ui/dr-ui/src/preset_store.rs#L1), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/presets.rs:679`](../ui/dr-ui/src/presets.rs#L679), [`ui/dr-ui/src/presets.rs:713`](../ui/dr-ui/src/presets.rs#L713), [`ui/dr-ui/ui/adjust.slint:817`](../ui/dr-ui/ui/adjust.slint#L817), [`ui/dr-ui/ui/adjust.slint:844`](../ui/dr-ui/ui/adjust.slint#L844), [`ui/dr-ui/ui/adjust.slint:878`](../ui/dr-ui/ui/adjust.slint#L878), [`ui/dr-ui/ui/app.slint:1717`](../ui/dr-ui/ui/app.slint#L1717), [`ui/dr-ui/ui/app.slint:2460`](../ui/dr-ui/ui/app.slint#L2460), [`ui/dr-ui/ui/app.slint:3255`](../ui/dr-ui/ui/app.slint#L3255), [`ui/dr-ui/ui/app.slint:861`](../ui/dr-ui/ui/app.slint#L861), [`ui/dr-ui/ui/app.slint:872`](../ui/dr-ui/ui/app.slint#L872), [`ui/dr-ui/ui/library.slint:1574`](../ui/dr-ui/ui/library.slint#L1574), [`ui/dr-ui/ui/library.slint:1580`](../ui/dr-ui/ui/library.slint#L1580), [`ui/dr-ui/ui/library.slint:4211`](../ui/dr-ui/ui/library.slint#L4211), [`ui/dr-ui/ui/library.slint:4226`](../ui/dr-ui/ui/library.slint#L4226), [`ui/dr-ui/ui/presets.slint:105`](../ui/dr-ui/ui/presets.slint#L105), [`ui/dr-ui/ui/presets.slint:113`](../ui/dr-ui/ui/presets.slint#L113), [`ui/dr-ui/ui/presets.slint:19`](../ui/dr-ui/ui/presets.slint#L19), [`ui/dr-ui/ui/presets.slint:276`](../ui/dr-ui/ui/presets.slint#L276), [`ui/dr-ui/ui/presets.slint:297`](../ui/dr-ui/ui/presets.slint#L297), [`ui/dr-ui/ui/presets.slint:5`](../ui/dr-ui/ui/presets.slint#L5), [`ui/dr-ui/ui/presets.slint:60`](../ui/dr-ui/ui/presets.slint#L60), [`ui/dr-ui/ui/settings.slint:101`](../ui/dr-ui/ui/settings.slint#L101), [`ui/dr-ui/ui/settings.slint:595`](../ui/dr-ui/ui/settings.slint#L595) | +| FR-DEV-7 | [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`ui/dr-ui/src/develop.rs:3977`](../ui/dr-ui/src/develop.rs#L3977), [`ui/dr-ui/src/develop.rs:4018`](../ui/dr-ui/src/develop.rs#L4018), [`ui/dr-ui/src/develop.rs:5183`](../ui/dr-ui/src/develop.rs#L5183), [`ui/dr-ui/src/develop.rs:5215`](../ui/dr-ui/src/develop.rs#L5215), [`ui/dr-ui/src/develop.rs:6112`](../ui/dr-ui/src/develop.rs#L6112), [`ui/dr-ui/src/lib.rs:1776`](../ui/dr-ui/src/lib.rs#L1776), [`ui/dr-ui/src/lib.rs:1837`](../ui/dr-ui/src/lib.rs#L1837), [`ui/dr-ui/src/lib.rs:2814`](../ui/dr-ui/src/lib.rs#L2814), [`ui/dr-ui/src/lib.rs:2875`](../ui/dr-ui/src/lib.rs#L2875), [`ui/dr-ui/src/lib.rs:373`](../ui/dr-ui/src/lib.rs#L373), [`ui/dr-ui/ui/app.slint:224`](../ui/dr-ui/ui/app.slint#L224), [`ui/dr-ui/ui/app.slint:2367`](../ui/dr-ui/ui/app.slint#L2367), [`ui/dr-ui/ui/app.slint:2382`](../ui/dr-ui/ui/app.slint#L2382), [`ui/dr-ui/ui/app.slint:2680`](../ui/dr-ui/ui/app.slint#L2680), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1), [`ui/dr-ui/ui/widgets.slint:71`](../ui/dr-ui/ui/widgets.slint#L71) | +| FR-DEV-8 | [`core/dr-gpu/src/detail.rs:405`](../core/dr-gpu/src/detail.rs#L405), [`core/dr-gpu/src/detail.rs:623`](../core/dr-gpu/src/detail.rs#L623), [`core/dr-gpu/tests/detail_instances.rs:1`](../core/dr-gpu/tests/detail_instances.rs#L1), [`core/dr-gpu/tests/spot_removal.rs:1`](../core/dr-gpu/tests/spot_removal.rs#L1), [`core/dr-pipeline/src/detail.rs:410`](../core/dr-pipeline/src/detail.rs#L410), [`core/dr-pipeline/src/detail.rs:434`](../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:469`](../core/dr-pipeline/src/detail.rs#L469), [`core/dr-pipeline/src/detail.rs:555`](../core/dr-pipeline/src/detail.rs#L555), [`core/dr-pipeline/src/graph.rs:115`](../core/dr-pipeline/src/graph.rs#L115), [`core/dr-pipeline/src/graph.rs:231`](../core/dr-pipeline/src/graph.rs#L231), [`core/dr-pipeline/src/graph.rs:900`](../core/dr-pipeline/src/graph.rs#L900), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:579`](../core/dr-pipeline/src/operation.rs#L579), [`core/dr-pipeline/src/sidecar.rs:1023`](../core/dr-pipeline/src/sidecar.rs#L1023), [`core/dr-pipeline/src/sidecar.rs:1053`](../core/dr-pipeline/src/sidecar.rs#L1053), [`core/dr-pipeline/src/sidecar.rs:187`](../core/dr-pipeline/src/sidecar.rs#L187), [`core/dr-pipeline/src/sidecar.rs:356`](../core/dr-pipeline/src/sidecar.rs#L356), [`core/dr-pipeline/src/sidecar.rs:793`](../core/dr-pipeline/src/sidecar.rs#L793), [`core/dr-pipeline/src/sidecar.rs:969`](../core/dr-pipeline/src/sidecar.rs#L969), [`core/dr-pipeline/src/spot.rs:115`](../core/dr-pipeline/src/spot.rs#L115), [`core/dr-pipeline/src/spot.rs:151`](../core/dr-pipeline/src/spot.rs#L151), [`core/dr-pipeline/src/spot.rs:1`](../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:207`](../core/dr-pipeline/src/spot.rs#L207), [`core/dr-pipeline/src/spot.rs:387`](../core/dr-pipeline/src/spot.rs#L387), [`core/dr-pipeline/src/spot.rs:472`](../core/dr-pipeline/src/spot.rs#L472), [`core/dr-pipeline/src/spot.rs:582`](../core/dr-pipeline/src/spot.rs#L582), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`core/dr-pipeline/src/state.rs:103`](../core/dr-pipeline/src/state.rs#L103), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-pipeline/tests/spots.rs:1`](../core/dr-pipeline/tests/spots.rs#L1), [`ui/dr-ui/src/develop.rs:2893`](../ui/dr-ui/src/develop.rs#L2893), [`ui/dr-ui/src/develop.rs:2931`](../ui/dr-ui/src/develop.rs#L2931), [`ui/dr-ui/src/develop.rs:2996`](../ui/dr-ui/src/develop.rs#L2996), [`ui/dr-ui/src/develop.rs:3079`](../ui/dr-ui/src/develop.rs#L3079), [`ui/dr-ui/src/develop.rs:3093`](../ui/dr-ui/src/develop.rs#L3093), [`ui/dr-ui/src/develop.rs:857`](../ui/dr-ui/src/develop.rs#L857), [`ui/dr-ui/src/labels.rs:76`](../ui/dr-ui/src/labels.rs#L76), [`ui/dr-ui/src/lib.rs:1797`](../ui/dr-ui/src/lib.rs#L1797), [`ui/dr-ui/src/lib.rs:3026`](../ui/dr-ui/src/lib.rs#L3026), [`ui/dr-ui/src/lib.rs:407`](../ui/dr-ui/src/lib.rs#L407), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/src/spots_ui.rs:1`](../ui/dr-ui/src/spots_ui.rs#L1), [`ui/dr-ui/src/spots_ui.rs:265`](../ui/dr-ui/src/spots_ui.rs#L265), [`ui/dr-ui/ui/adjust.slint:909`](../ui/dr-ui/ui/adjust.slint#L909), [`ui/dr-ui/ui/app.slint:146`](../ui/dr-ui/ui/app.slint#L146), [`ui/dr-ui/ui/app.slint:2144`](../ui/dr-ui/ui/app.slint#L2144), [`ui/dr-ui/ui/app.slint:2425`](../ui/dr-ui/ui/app.slint#L2425), [`ui/dr-ui/ui/app.slint:3032`](../ui/dr-ui/ui/app.slint#L3032), [`ui/dr-ui/ui/icons.slint:338`](../ui/dr-ui/ui/icons.slint#L338), [`ui/dr-ui/ui/spots.slint:172`](../ui/dr-ui/ui/spots.slint#L172), [`ui/dr-ui/ui/spots.slint:48`](../ui/dr-ui/ui/spots.slint#L48), [`ui/dr-ui/ui/spots.slint:5`](../ui/dr-ui/ui/spots.slint#L5) | +| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2140`](../core/dr-gpu/src/adjust.rs#L2140), [`core/dr-gpu/src/adjust.rs:2217`](../core/dr-gpu/src/adjust.rs#L2217), [`core/dr-gpu/src/adjust.rs:2302`](../core/dr-gpu/src/adjust.rs#L2302), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:172`](../core/dr-gpu/src/lib.rs#L172), [`core/dr-gpu/src/lib.rs:192`](../core/dr-gpu/src/lib.rs#L192), [`core/dr-gpu/src/lib.rs:70`](../core/dr-gpu/src/lib.rs#L70), [`core/dr-gpu/src/lib.rs:93`](../core/dr-gpu/src/lib.rs#L93), [`core/dr-gpu/tests/capture_sharpen.rs:200`](../core/dr-gpu/tests/capture_sharpen.rs#L200), [`core/dr-gpu/tests/detail_stage.rs:328`](../core/dr-gpu/tests/detail_stage.rs#L328), [`core/dr-gpu/tests/local_contrast.rs:264`](../core/dr-gpu/tests/local_contrast.rs#L264), [`core/dr-gpu/tests/noise_reduction.rs:378`](../core/dr-gpu/tests/noise_reduction.rs#L378), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:524`](../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/graph.rs:732`](../core/dr-pipeline/src/graph.rs#L732), [`core/dr-pipeline/src/graph.rs:762`](../core/dr-pipeline/src/graph.rs#L762), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:659`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L659), [`core/dr-pipeline/src/ops/dehaze.rs:1`](../core/dr-pipeline/src/ops/dehaze.rs#L1), [`core/dr-pipeline/src/ops/dehaze.rs:592`](../core/dr-pipeline/src/ops/dehaze.rs#L592), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:971`](../core/dr-pipeline/src/ops/local_contrast.rs#L971), [`core/dr-pipeline/src/ops/noise_reduction.rs:700`](../core/dr-pipeline/src/ops/noise_reduction.rs#L700), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`ui/dr-ui/src/develop.rs:3669`](../ui/dr-ui/src/develop.rs#L3669), [`ui/dr-ui/src/develop.rs:4922`](../ui/dr-ui/src/develop.rs#L4922), [`ui/dr-ui/src/develop.rs:5509`](../ui/dr-ui/src/develop.rs#L5509), [`ui/dr-ui/src/develop.rs:6680`](../ui/dr-ui/src/develop.rs#L6680), [`ui/dr-ui/src/develop.rs:6714`](../ui/dr-ui/src/develop.rs#L6714), [`ui/dr-ui/src/lib.rs:1024`](../ui/dr-ui/src/lib.rs#L1024), [`ui/dr-ui/src/lib.rs:93`](../ui/dr-ui/src/lib.rs#L93), [`ui/dr-ui/src/lib.rs:965`](../ui/dr-ui/src/lib.rs#L965) | | FR-DSP-3 | [`core/dr-gpu/tests/frame_budget.rs:101`](../core/dr-gpu/tests/frame_budget.rs#L101), [`core/dr-gpu/tests/local_contrast.rs:329`](../core/dr-gpu/tests/local_contrast.rs#L329), [`core/dr-pipeline/src/ops/local_contrast.rs:447`](../core/dr-pipeline/src/ops/local_contrast.rs#L447) | | FR-DSP-5 | [`core/dr-gpu/tests/frame_budget.rs:101`](../core/dr-gpu/tests/frame_budget.rs#L101), [`core/dr-gpu/tests/zoom_resolution.rs:135`](../core/dr-gpu/tests/zoom_resolution.rs#L135), [`core/dr-gpu/tests/zoom_resolution.rs:166`](../core/dr-gpu/tests/zoom_resolution.rs#L166), [`core/dr-gpu/tests/zoom_resolution.rs:1`](../core/dr-gpu/tests/zoom_resolution.rs#L1), [`core/dr-gpu/tests/zoom_resolution.rs:216`](../core/dr-gpu/tests/zoom_resolution.rs#L216) | -| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:497`](../core/dr-pipeline/src/operation.rs#L497), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`ui/dr-ui/src/develop.rs:3397`](../ui/dr-ui/src/develop.rs#L3397), [`ui/dr-ui/src/develop.rs:7899`](../ui/dr-ui/src/develop.rs#L7899), [`ui/dr-ui/src/lib.rs:1753`](../ui/dr-ui/src/lib.rs#L1753), [`ui/dr-ui/src/lib.rs:3409`](../ui/dr-ui/src/lib.rs#L3409) | -| FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:3446`](../ui/dr-ui/src/develop.rs#L3446), [`ui/dr-ui/src/develop.rs:7573`](../ui/dr-ui/src/develop.rs#L7573), [`ui/dr-ui/src/develop.rs:757`](../ui/dr-ui/src/develop.rs#L757), [`ui/dr-ui/src/develop.rs:7605`](../ui/dr-ui/src/develop.rs#L7605), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1871`](../ui/dr-ui/src/lib.rs#L1871), [`ui/dr-ui/src/lib.rs:386`](../ui/dr-ui/src/lib.rs#L386), [`ui/dr-ui/ui/app.slint:81`](../ui/dr-ui/ui/app.slint#L81), [`ui/dr-ui/ui/histogram.slint:168`](../ui/dr-ui/ui/histogram.slint#L168), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) | -| FR-DSP-8 | [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/icc.rs:1`](../platform/dr-plat/src/display/icc.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../platform/dr-plat/src/display/x11.rs#L1), [`ui/dr-ui/src/develop.rs:3343`](../ui/dr-ui/src/develop.rs#L3343), [`ui/dr-ui/src/develop.rs:3397`](../ui/dr-ui/src/develop.rs#L3397), [`ui/dr-ui/src/develop.rs:7899`](../ui/dr-ui/src/develop.rs#L7899), [`ui/dr-ui/src/develop.rs:7945`](../ui/dr-ui/src/develop.rs#L7945), [`ui/dr-ui/src/develop.rs:7964`](../ui/dr-ui/src/develop.rs#L7964), [`ui/dr-ui/src/develop.rs:864`](../ui/dr-ui/src/develop.rs#L864), [`ui/dr-ui/src/display_ui.rs:192`](../ui/dr-ui/src/display_ui.rs#L192), [`ui/dr-ui/src/display_ui.rs:1`](../ui/dr-ui/src/display_ui.rs#L1), [`ui/dr-ui/src/display_ui.rs:325`](../ui/dr-ui/src/display_ui.rs#L325), [`ui/dr-ui/src/display_ui.rs:346`](../ui/dr-ui/src/display_ui.rs#L346), [`ui/dr-ui/src/display_ui.rs:379`](../ui/dr-ui/src/display_ui.rs#L379), [`ui/dr-ui/src/lib.rs:1695`](../ui/dr-ui/src/lib.rs#L1695), [`ui/dr-ui/src/lib.rs:1753`](../ui/dr-ui/src/lib.rs#L1753), [`ui/dr-ui/src/lib.rs:3322`](../ui/dr-ui/src/lib.rs#L3322), [`ui/dr-ui/src/lib.rs:3409`](../ui/dr-ui/src/lib.rs#L3409), [`ui/dr-ui/ui/app.slint:1975`](../ui/dr-ui/ui/app.slint#L1975), [`ui/dr-ui/ui/app.slint:54`](../ui/dr-ui/ui/app.slint#L54), [`ui/dr-ui/ui/settings.slint:132`](../ui/dr-ui/ui/settings.slint#L132), [`ui/dr-ui/ui/settings.slint:832`](../ui/dr-ui/ui/settings.slint#L832) | +| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:497`](../core/dr-pipeline/src/operation.rs#L497), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`ui/dr-ui/src/develop.rs:3699`](../ui/dr-ui/src/develop.rs#L3699), [`ui/dr-ui/src/develop.rs:8202`](../ui/dr-ui/src/develop.rs#L8202), [`ui/dr-ui/src/lib.rs:1753`](../ui/dr-ui/src/lib.rs#L1753), [`ui/dr-ui/src/lib.rs:3409`](../ui/dr-ui/src/lib.rs#L3409) | +| FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:3748`](../ui/dr-ui/src/develop.rs#L3748), [`ui/dr-ui/src/develop.rs:757`](../ui/dr-ui/src/develop.rs#L757), [`ui/dr-ui/src/develop.rs:7876`](../ui/dr-ui/src/develop.rs#L7876), [`ui/dr-ui/src/develop.rs:7908`](../ui/dr-ui/src/develop.rs#L7908), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1871`](../ui/dr-ui/src/lib.rs#L1871), [`ui/dr-ui/src/lib.rs:386`](../ui/dr-ui/src/lib.rs#L386), [`ui/dr-ui/ui/app.slint:80`](../ui/dr-ui/ui/app.slint#L80), [`ui/dr-ui/ui/histogram.slint:168`](../ui/dr-ui/ui/histogram.slint#L168), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) | +| FR-DSP-8 | [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/icc.rs:1`](../platform/dr-plat/src/display/icc.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../platform/dr-plat/src/display/x11.rs#L1), [`ui/dr-ui/src/develop.rs:3645`](../ui/dr-ui/src/develop.rs#L3645), [`ui/dr-ui/src/develop.rs:3699`](../ui/dr-ui/src/develop.rs#L3699), [`ui/dr-ui/src/develop.rs:8202`](../ui/dr-ui/src/develop.rs#L8202), [`ui/dr-ui/src/develop.rs:8248`](../ui/dr-ui/src/develop.rs#L8248), [`ui/dr-ui/src/develop.rs:8267`](../ui/dr-ui/src/develop.rs#L8267), [`ui/dr-ui/src/develop.rs:888`](../ui/dr-ui/src/develop.rs#L888), [`ui/dr-ui/src/display_ui.rs:192`](../ui/dr-ui/src/display_ui.rs#L192), [`ui/dr-ui/src/display_ui.rs:1`](../ui/dr-ui/src/display_ui.rs#L1), [`ui/dr-ui/src/display_ui.rs:325`](../ui/dr-ui/src/display_ui.rs#L325), [`ui/dr-ui/src/display_ui.rs:346`](../ui/dr-ui/src/display_ui.rs#L346), [`ui/dr-ui/src/display_ui.rs:379`](../ui/dr-ui/src/display_ui.rs#L379), [`ui/dr-ui/src/lib.rs:1695`](../ui/dr-ui/src/lib.rs#L1695), [`ui/dr-ui/src/lib.rs:1753`](../ui/dr-ui/src/lib.rs#L1753), [`ui/dr-ui/src/lib.rs:3322`](../ui/dr-ui/src/lib.rs#L3322), [`ui/dr-ui/src/lib.rs:3409`](../ui/dr-ui/src/lib.rs#L3409), [`ui/dr-ui/ui/app.slint:1929`](../ui/dr-ui/ui/app.slint#L1929), [`ui/dr-ui/ui/app.slint:53`](../ui/dr-ui/ui/app.slint#L53), [`ui/dr-ui/ui/settings.slint:132`](../ui/dr-ui/ui/settings.slint#L132), [`ui/dr-ui/ui/settings.slint:832`](../ui/dr-ui/ui/settings.slint#L832) | | FR-EXP-1 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | -| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:153`](../core/dr-export/src/lib.rs#L153), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:53`](../core/dr-export/src/lib.rs#L53), [`core/dr-gpu/src/adjust.rs:2450`](../core/dr-gpu/src/adjust.rs#L2450), [`core/dr-pipeline/src/graph.rs:715`](../core/dr-pipeline/src/graph.rs#L715), [`core/dr-pipeline/src/graph.rs:776`](../core/dr-pipeline/src/graph.rs#L776), [`core/dr-pipeline/src/operation.rs:497`](../core/dr-pipeline/src/operation.rs#L497), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:761`](../core/dr-types/src/settings.rs#L761), [`ui/dr-ui/src/develop.rs:7964`](../ui/dr-ui/src/develop.rs#L7964), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | +| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:153`](../core/dr-export/src/lib.rs#L153), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:53`](../core/dr-export/src/lib.rs#L53), [`core/dr-gpu/src/adjust.rs:2450`](../core/dr-gpu/src/adjust.rs#L2450), [`core/dr-pipeline/src/graph.rs:715`](../core/dr-pipeline/src/graph.rs#L715), [`core/dr-pipeline/src/graph.rs:776`](../core/dr-pipeline/src/graph.rs#L776), [`core/dr-pipeline/src/operation.rs:497`](../core/dr-pipeline/src/operation.rs#L497), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:761`](../core/dr-types/src/settings.rs#L761), [`ui/dr-ui/src/develop.rs:8267`](../ui/dr-ui/src/develop.rs#L8267), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-3 | [`core/dr-export/src/lib.rs:182`](../core/dr-export/src/lib.rs#L182), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/size.rs:136`](../core/dr-export/src/size.rs#L136), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`core/dr-export/src/size.rs:25`](../core/dr-export/src/size.rs#L25), [`core/dr-export/src/size.rs:84`](../core/dr-export/src/size.rs#L84), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:806`](../core/dr-types/src/settings.rs#L806), [`core/dr-types/src/settings.rs:814`](../core/dr-types/src/settings.rs#L814), [`core/dr-types/src/settings.rs:830`](../core/dr-types/src/settings.rs#L830), [`core/dr-types/src/settings.rs:899`](../core/dr-types/src/settings.rs#L899), [`core/dr-types/src/settings.rs:927`](../core/dr-types/src/settings.rs#L927), [`core/dr-types/src/settings.rs:938`](../core/dr-types/src/settings.rs#L938), [`core/dr-types/src/settings.rs:944`](../core/dr-types/src/settings.rs#L944), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:237`](../ui/dr-ui/src/settings_ui.rs#L237), [`ui/dr-ui/src/settings_ui.rs:250`](../ui/dr-ui/src/settings_ui.rs#L250), [`ui/dr-ui/src/settings_ui.rs:265`](../ui/dr-ui/src/settings_ui.rs#L265), [`ui/dr-ui/src/settings_ui.rs:615`](../ui/dr-ui/src/settings_ui.rs#L615), [`ui/dr-ui/ui/settings.slint:735`](../ui/dr-ui/ui/settings.slint#L735), [`ui/dr-ui/ui/settings.slint:745`](../ui/dr-ui/ui/settings.slint#L745) | | FR-EXP-4 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/sharpen.rs:1`](../core/dr-export/src/sharpen.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-5 | [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | | FR-EXP-6 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:559`](../ui/dr-ui/src/lib.rs#L559), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:49`](../ui/dr-ui/src/settings_ui.rs#L49), [`ui/dr-ui/src/settings_ui.rs:729`](../ui/dr-ui/src/settings_ui.rs#L729) | -| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/export.rs:992`](../ui/dr-ui/src/export.rs#L992), [`ui/dr-ui/src/lib.rs:232`](../ui/dr-ui/src/lib.rs#L232), [`ui/dr-ui/src/lib.rs:2560`](../ui/dr-ui/src/lib.rs#L2560), [`ui/dr-ui/src/lib.rs:559`](../ui/dr-ui/src/lib.rs#L559), [`ui/dr-ui/src/lib.rs:605`](../ui/dr-ui/src/lib.rs#L605), [`ui/dr-ui/src/lib.rs:632`](../ui/dr-ui/src/lib.rs#L632), [`ui/dr-ui/src/library_ui.rs:3977`](../ui/dr-ui/src/library_ui.rs#L3977), [`ui/dr-ui/src/library_ui.rs:639`](../ui/dr-ui/src/library_ui.rs#L639), [`ui/dr-ui/src/library_ui.rs:739`](../ui/dr-ui/src/library_ui.rs#L739), [`ui/dr-ui/src/library_ui.rs:7670`](../ui/dr-ui/src/library_ui.rs#L7670), [`ui/dr-ui/src/library_ui.rs:7747`](../ui/dr-ui/src/library_ui.rs#L7747), [`ui/dr-ui/src/library_ui.rs:7759`](../ui/dr-ui/src/library_ui.rs#L7759), [`ui/dr-ui/src/library_ui.rs:796`](../ui/dr-ui/src/library_ui.rs#L796), [`ui/dr-ui/ui/app.slint:1265`](../ui/dr-ui/ui/app.slint#L1265), [`ui/dr-ui/ui/app.slint:1772`](../ui/dr-ui/ui/app.slint#L1772), [`ui/dr-ui/ui/library.slint:1583`](../ui/dr-ui/ui/library.slint#L1583), [`ui/dr-ui/ui/library.slint:4278`](../ui/dr-ui/ui/library.slint#L4278) | -| FR-EXP-8 | [`core/dr-decode/src/lib.rs:326`](../core/dr-decode/src/lib.rs#L326), [`core/dr-decode/src/lib.rs:350`](../core/dr-decode/src/lib.rs#L350), [`core/dr-decode/src/lib.rs:364`](../core/dr-decode/src/lib.rs#L364), [`core/dr-decode/src/lib.rs:71`](../core/dr-decode/src/lib.rs#L71), [`core/dr-decode/src/lib.rs:79`](../core/dr-decode/src/lib.rs#L79), [`core/dr-decode/src/lib.rs:82`](../core/dr-decode/src/lib.rs#L82), [`core/dr-decode/src/locate.rs:1164`](../core/dr-decode/src/locate.rs#L1164), [`core/dr-decode/src/locate.rs:1223`](../core/dr-decode/src/locate.rs#L1223), [`core/dr-decode/src/locate.rs:316`](../core/dr-decode/src/locate.rs#L316), [`core/dr-decode/src/locate.rs:487`](../core/dr-decode/src/locate.rs#L487), [`core/dr-decode/src/locate.rs:571`](../core/dr-decode/src/locate.rs#L571), [`core/dr-decode/src/locate.rs:584`](../core/dr-decode/src/locate.rs#L584), [`core/dr-decode/src/locate.rs:667`](../core/dr-decode/src/locate.rs#L667), [`core/dr-export/examples/export.rs:99`](../core/dr-export/examples/export.rs#L99), [`core/dr-export/src/encode.rs:117`](../core/dr-export/src/encode.rs#L117), [`core/dr-export/src/encode.rs:161`](../core/dr-export/src/encode.rs#L161), [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/encode.rs:206`](../core/dr-export/src/encode.rs#L206), [`core/dr-export/src/encode.rs:235`](../core/dr-export/src/encode.rs#L235), [`core/dr-export/src/encode.rs:311`](../core/dr-export/src/encode.rs#L311), [`core/dr-export/src/encode.rs:325`](../core/dr-export/src/encode.rs#L325), [`core/dr-export/src/encode.rs:408`](../core/dr-export/src/encode.rs#L408), [`core/dr-export/src/encode.rs:456`](../core/dr-export/src/encode.rs#L456), [`core/dr-export/src/encode.rs:70`](../core/dr-export/src/encode.rs#L70), [`core/dr-export/src/encode.rs:795`](../core/dr-export/src/encode.rs#L795), [`core/dr-export/src/encode.rs:809`](../core/dr-export/src/encode.rs#L809), [`core/dr-export/src/encode.rs:850`](../core/dr-export/src/encode.rs#L850), [`core/dr-export/src/encode.rs:898`](../core/dr-export/src/encode.rs#L898), [`core/dr-export/src/exif.rs:1`](../core/dr-export/src/exif.rs#L1), [`core/dr-export/src/lib.rs:136`](../core/dr-export/src/lib.rs#L136), [`core/dr-export/src/metadata.rs:1`](../core/dr-export/src/metadata.rs#L1), [`core/dr-export/src/metadata.rs:41`](../core/dr-export/src/metadata.rs#L41), [`core/dr-export/src/metadata.rs:74`](../core/dr-export/src/metadata.rs#L74), [`core/dr-types/src/lib.rs:655`](../core/dr-types/src/lib.rs#L655), [`core/dr-types/src/settings.rs:474`](../core/dr-types/src/settings.rs#L474), [`ui/dr-ui/src/develop.rs:3880`](../ui/dr-ui/src/develop.rs#L3880), [`ui/dr-ui/src/develop.rs:3988`](../ui/dr-ui/src/develop.rs#L3988), [`ui/dr-ui/src/develop.rs:703`](../ui/dr-ui/src/develop.rs#L703), [`ui/dr-ui/src/export.rs:467`](../ui/dr-ui/src/export.rs#L467), [`ui/dr-ui/src/export.rs:634`](../ui/dr-ui/src/export.rs#L634), [`ui/dr-ui/src/export.rs:680`](../ui/dr-ui/src/export.rs#L680), [`ui/dr-ui/src/export.rs:702`](../ui/dr-ui/src/export.rs#L702), [`ui/dr-ui/src/export.rs:826`](../ui/dr-ui/src/export.rs#L826), [`ui/dr-ui/src/export.rs:844`](../ui/dr-ui/src/export.rs#L844), [`ui/dr-ui/src/lib.rs:232`](../ui/dr-ui/src/lib.rs#L232), [`ui/dr-ui/src/lib.rs:255`](../ui/dr-ui/src/lib.rs#L255), [`ui/dr-ui/src/lib.rs:591`](../ui/dr-ui/src/lib.rs#L591), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | -| FR-EXP-9 | [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-export/src/lib.rs:128`](../core/dr-export/src/lib.rs#L128), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:1106`](../core/dr-gpu/src/adjust.rs#L1106), [`ui/dr-ui/examples/face_native.rs:1`](../ui/dr-ui/examples/face_native.rs#L1), [`ui/dr-ui/src/develop.rs:4009`](../ui/dr-ui/src/develop.rs#L4009), [`ui/dr-ui/src/develop.rs:4047`](../ui/dr-ui/src/develop.rs#L4047), [`ui/dr-ui/src/develop.rs:5921`](../ui/dr-ui/src/develop.rs#L5921), [`ui/dr-ui/src/lib.rs:559`](../ui/dr-ui/src/lib.rs#L559), [`ui/dr-ui/src/library.rs:3385`](../ui/dr-ui/src/library.rs#L3385), [`ui/dr-ui/src/library.rs:3742`](../ui/dr-ui/src/library.rs#L3742), [`ui/dr-ui/src/library.rs:3778`](../ui/dr-ui/src/library.rs#L3778), [`ui/dr-ui/tests/export_ignores_the_viewport.rs:1`](../ui/dr-ui/tests/export_ignores_the_viewport.rs#L1) | +| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/export.rs:992`](../ui/dr-ui/src/export.rs#L992), [`ui/dr-ui/src/lib.rs:232`](../ui/dr-ui/src/lib.rs#L232), [`ui/dr-ui/src/lib.rs:2560`](../ui/dr-ui/src/lib.rs#L2560), [`ui/dr-ui/src/lib.rs:559`](../ui/dr-ui/src/lib.rs#L559), [`ui/dr-ui/src/lib.rs:605`](../ui/dr-ui/src/lib.rs#L605), [`ui/dr-ui/src/lib.rs:632`](../ui/dr-ui/src/lib.rs#L632), [`ui/dr-ui/src/library_ui.rs:3983`](../ui/dr-ui/src/library_ui.rs#L3983), [`ui/dr-ui/src/library_ui.rs:645`](../ui/dr-ui/src/library_ui.rs#L645), [`ui/dr-ui/src/library_ui.rs:745`](../ui/dr-ui/src/library_ui.rs#L745), [`ui/dr-ui/src/library_ui.rs:7709`](../ui/dr-ui/src/library_ui.rs#L7709), [`ui/dr-ui/src/library_ui.rs:7786`](../ui/dr-ui/src/library_ui.rs#L7786), [`ui/dr-ui/src/library_ui.rs:7798`](../ui/dr-ui/src/library_ui.rs#L7798), [`ui/dr-ui/src/library_ui.rs:802`](../ui/dr-ui/src/library_ui.rs#L802), [`ui/dr-ui/ui/app.slint:1237`](../ui/dr-ui/ui/app.slint#L1237), [`ui/dr-ui/ui/app.slint:1727`](../ui/dr-ui/ui/app.slint#L1727), [`ui/dr-ui/ui/library.slint:1583`](../ui/dr-ui/ui/library.slint#L1583), [`ui/dr-ui/ui/library.slint:4251`](../ui/dr-ui/ui/library.slint#L4251) | +| FR-EXP-8 | [`core/dr-decode/src/lib.rs:326`](../core/dr-decode/src/lib.rs#L326), [`core/dr-decode/src/lib.rs:350`](../core/dr-decode/src/lib.rs#L350), [`core/dr-decode/src/lib.rs:364`](../core/dr-decode/src/lib.rs#L364), [`core/dr-decode/src/lib.rs:71`](../core/dr-decode/src/lib.rs#L71), [`core/dr-decode/src/lib.rs:79`](../core/dr-decode/src/lib.rs#L79), [`core/dr-decode/src/lib.rs:82`](../core/dr-decode/src/lib.rs#L82), [`core/dr-decode/src/locate.rs:1164`](../core/dr-decode/src/locate.rs#L1164), [`core/dr-decode/src/locate.rs:1223`](../core/dr-decode/src/locate.rs#L1223), [`core/dr-decode/src/locate.rs:316`](../core/dr-decode/src/locate.rs#L316), [`core/dr-decode/src/locate.rs:487`](../core/dr-decode/src/locate.rs#L487), [`core/dr-decode/src/locate.rs:571`](../core/dr-decode/src/locate.rs#L571), [`core/dr-decode/src/locate.rs:584`](../core/dr-decode/src/locate.rs#L584), [`core/dr-decode/src/locate.rs:667`](../core/dr-decode/src/locate.rs#L667), [`core/dr-export/examples/export.rs:99`](../core/dr-export/examples/export.rs#L99), [`core/dr-export/src/encode.rs:117`](../core/dr-export/src/encode.rs#L117), [`core/dr-export/src/encode.rs:161`](../core/dr-export/src/encode.rs#L161), [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/encode.rs:206`](../core/dr-export/src/encode.rs#L206), [`core/dr-export/src/encode.rs:235`](../core/dr-export/src/encode.rs#L235), [`core/dr-export/src/encode.rs:311`](../core/dr-export/src/encode.rs#L311), [`core/dr-export/src/encode.rs:325`](../core/dr-export/src/encode.rs#L325), [`core/dr-export/src/encode.rs:408`](../core/dr-export/src/encode.rs#L408), [`core/dr-export/src/encode.rs:456`](../core/dr-export/src/encode.rs#L456), [`core/dr-export/src/encode.rs:70`](../core/dr-export/src/encode.rs#L70), [`core/dr-export/src/encode.rs:795`](../core/dr-export/src/encode.rs#L795), [`core/dr-export/src/encode.rs:809`](../core/dr-export/src/encode.rs#L809), [`core/dr-export/src/encode.rs:850`](../core/dr-export/src/encode.rs#L850), [`core/dr-export/src/encode.rs:898`](../core/dr-export/src/encode.rs#L898), [`core/dr-export/src/exif.rs:1`](../core/dr-export/src/exif.rs#L1), [`core/dr-export/src/lib.rs:136`](../core/dr-export/src/lib.rs#L136), [`core/dr-export/src/metadata.rs:1`](../core/dr-export/src/metadata.rs#L1), [`core/dr-export/src/metadata.rs:41`](../core/dr-export/src/metadata.rs#L41), [`core/dr-export/src/metadata.rs:74`](../core/dr-export/src/metadata.rs#L74), [`core/dr-types/src/lib.rs:655`](../core/dr-types/src/lib.rs#L655), [`core/dr-types/src/settings.rs:474`](../core/dr-types/src/settings.rs#L474), [`ui/dr-ui/src/develop.rs:4182`](../ui/dr-ui/src/develop.rs#L4182), [`ui/dr-ui/src/develop.rs:4290`](../ui/dr-ui/src/develop.rs#L4290), [`ui/dr-ui/src/develop.rs:703`](../ui/dr-ui/src/develop.rs#L703), [`ui/dr-ui/src/export.rs:467`](../ui/dr-ui/src/export.rs#L467), [`ui/dr-ui/src/export.rs:634`](../ui/dr-ui/src/export.rs#L634), [`ui/dr-ui/src/export.rs:680`](../ui/dr-ui/src/export.rs#L680), [`ui/dr-ui/src/export.rs:702`](../ui/dr-ui/src/export.rs#L702), [`ui/dr-ui/src/export.rs:826`](../ui/dr-ui/src/export.rs#L826), [`ui/dr-ui/src/export.rs:844`](../ui/dr-ui/src/export.rs#L844), [`ui/dr-ui/src/lib.rs:232`](../ui/dr-ui/src/lib.rs#L232), [`ui/dr-ui/src/lib.rs:255`](../ui/dr-ui/src/lib.rs#L255), [`ui/dr-ui/src/lib.rs:591`](../ui/dr-ui/src/lib.rs#L591), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | +| FR-EXP-9 | [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-export/src/lib.rs:128`](../core/dr-export/src/lib.rs#L128), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:1106`](../core/dr-gpu/src/adjust.rs#L1106), [`ui/dr-ui/examples/face_native.rs:1`](../ui/dr-ui/examples/face_native.rs#L1), [`ui/dr-ui/src/develop.rs:4311`](../ui/dr-ui/src/develop.rs#L4311), [`ui/dr-ui/src/develop.rs:4349`](../ui/dr-ui/src/develop.rs#L4349), [`ui/dr-ui/src/develop.rs:6223`](../ui/dr-ui/src/develop.rs#L6223), [`ui/dr-ui/src/lib.rs:559`](../ui/dr-ui/src/lib.rs#L559), [`ui/dr-ui/src/library.rs:3385`](../ui/dr-ui/src/library.rs#L3385), [`ui/dr-ui/src/library.rs:3742`](../ui/dr-ui/src/library.rs#L3742), [`ui/dr-ui/src/library.rs:3778`](../ui/dr-ui/src/library.rs#L3778), [`ui/dr-ui/tests/export_ignores_the_viewport.rs:1`](../ui/dr-ui/tests/export_ignores_the_viewport.rs#L1) | | FR-NC-1 | [`core/dr-sync-nextcloud/src/auth.rs:132`](../core/dr-sync-nextcloud/src/auth.rs#L132), [`core/dr-sync-nextcloud/src/auth.rs:44`](../core/dr-sync-nextcloud/src/auth.rs#L44), [`core/dr-sync-nextcloud/src/provider.rs:1`](../core/dr-sync-nextcloud/src/provider.rs#L1), [`core/dr-sync/src/account.rs:355`](../core/dr-sync/src/account.rs#L355), [`ui/dr-ui/src/launch.rs:277`](../ui/dr-ui/src/launch.rs#L277), [`ui/dr-ui/src/launch.rs:61`](../ui/dr-ui/src/launch.rs#L61), [`ui/dr-ui/src/launch_ui.rs:417`](../ui/dr-ui/src/launch_ui.rs#L417) | -| FR-NC-10 | [`core/dr-sync/src/account.rs:220`](../core/dr-sync/src/account.rs#L220), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:632`](../ui/dr-ui/src/lib.rs#L632), [`ui/dr-ui/src/library.rs:1155`](../ui/dr-ui/src/library.rs#L1155), [`ui/dr-ui/src/library.rs:2255`](../ui/dr-ui/src/library.rs#L2255), [`ui/dr-ui/src/library.rs:584`](../ui/dr-ui/src/library.rs#L584), [`ui/dr-ui/src/library.rs:899`](../ui/dr-ui/src/library.rs#L899), [`ui/dr-ui/src/library_ui.rs:1977`](../ui/dr-ui/src/library_ui.rs#L1977), [`ui/dr-ui/src/library_ui.rs:3977`](../ui/dr-ui/src/library_ui.rs#L3977), [`ui/dr-ui/src/library_ui.rs:580`](../ui/dr-ui/src/library_ui.rs#L580), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | +| FR-NC-10 | [`core/dr-sync/src/account.rs:220`](../core/dr-sync/src/account.rs#L220), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:632`](../ui/dr-ui/src/lib.rs#L632), [`ui/dr-ui/src/library.rs:1155`](../ui/dr-ui/src/library.rs#L1155), [`ui/dr-ui/src/library.rs:2255`](../ui/dr-ui/src/library.rs#L2255), [`ui/dr-ui/src/library.rs:584`](../ui/dr-ui/src/library.rs#L584), [`ui/dr-ui/src/library.rs:899`](../ui/dr-ui/src/library.rs#L899), [`ui/dr-ui/src/library_ui.rs:1983`](../ui/dr-ui/src/library_ui.rs#L1983), [`ui/dr-ui/src/library_ui.rs:3983`](../ui/dr-ui/src/library_ui.rs#L3983), [`ui/dr-ui/src/library_ui.rs:586`](../ui/dr-ui/src/library_ui.rs#L586), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | | FR-NC-12 | [`core/dr-sync-folder/src/lib.rs:1`](../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:40`](../core/dr-sync-nextcloud/src/lib.rs#L40), [`core/dr-sync-nextcloud/src/lib.rs:914`](../core/dr-sync-nextcloud/src/lib.rs#L914), [`core/dr-sync-nextcloud/src/provider.rs:1`](../core/dr-sync-nextcloud/src/provider.rs#L1), [`core/dr-sync/src/account.rs:1`](../core/dr-sync/src/account.rs#L1), [`core/dr-sync/src/account.rs:81`](../core/dr-sync/src/account.rs#L81), [`core/dr-sync/src/lib.rs:218`](../core/dr-sync/src/lib.rs#L218), [`core/dr-sync/src/lib.rs:51`](../core/dr-sync/src/lib.rs#L51), [`core/dr-sync/src/provider.rs:106`](../core/dr-sync/src/provider.rs#L106), [`core/dr-sync/src/provider.rs:1`](../core/dr-sync/src/provider.rs#L1), [`core/dr-sync/src/provider.rs:53`](../core/dr-sync/src/provider.rs#L53), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`ui/dr-ui/src/remote.rs:1`](../ui/dr-ui/src/remote.rs#L1) | | FR-NC-13 | [`core/dr-sync-folder/src/lib.rs:197`](../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:1`](../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-folder/src/lib.rs:73`](../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync/src/provider.rs:106`](../core/dr-sync/src/provider.rs#L106), [`ui/dr-ui/src/launch_ui.rs:316`](../ui/dr-ui/src/launch_ui.rs#L316), [`ui/dr-ui/src/remote.rs:1`](../ui/dr-ui/src/remote.rs#L1) | | FR-NC-2 | [`core/dr-sync/src/account.rs:1`](../core/dr-sync/src/account.rs#L1), [`core/dr-sync/src/account.rs:298`](../core/dr-sync/src/account.rs#L298), [`core/dr-sync/src/account.rs:355`](../core/dr-sync/src/account.rs#L355), [`core/dr-sync/src/account.rs:59`](../core/dr-sync/src/account.rs#L59), [`platform/dr-plat/src/secrets.rs:82`](../platform/dr-plat/src/secrets.rs#L82) | -| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`core/dr-sync/src/capability.rs:85`](../core/dr-sync/src/capability.rs#L85), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-types/src/place.rs:1`](../core/dr-types/src/place.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:3292`](../ui/dr-ui/src/library.rs#L3292), [`ui/dr-ui/src/library.rs:3883`](../ui/dr-ui/src/library.rs#L3883), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:4230`](../ui/dr-ui/src/library_ui.rs#L4230), [`ui/dr-ui/src/library_ui.rs:5729`](../ui/dr-ui/src/library_ui.rs#L5729), [`ui/dr-ui/ui/app.slint:491`](../ui/dr-ui/ui/app.slint#L491), [`ui/dr-ui/ui/settings.slint:395`](../ui/dr-ui/ui/settings.slint#L395), [`ui/dr-ui/ui/settings.slint:73`](../ui/dr-ui/ui/settings.slint#L73) | +| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`core/dr-sync/src/capability.rs:85`](../core/dr-sync/src/capability.rs#L85), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-types/src/place.rs:1`](../core/dr-types/src/place.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:3292`](../ui/dr-ui/src/library.rs#L3292), [`ui/dr-ui/src/library.rs:3883`](../ui/dr-ui/src/library.rs#L3883), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:4236`](../ui/dr-ui/src/library_ui.rs#L4236), [`ui/dr-ui/src/library_ui.rs:5735`](../ui/dr-ui/src/library_ui.rs#L5735), [`ui/dr-ui/ui/app.slint:490`](../ui/dr-ui/ui/app.slint#L490), [`ui/dr-ui/ui/settings.slint:395`](../ui/dr-ui/ui/settings.slint#L395), [`ui/dr-ui/ui/settings.slint:73`](../ui/dr-ui/ui/settings.slint#L73) | | FR-NC-4 | [`core/dr-sync-folder/src/lib.rs:197`](../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-nextcloud/src/propfind.rs:103`](../core/dr-sync-nextcloud/src/propfind.rs#L103), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:218`](../core/dr-sync/src/lib.rs#L218), [`core/dr-sync/src/scan.rs:118`](../core/dr-sync/src/scan.rs#L118), [`ui/dr-ui/src/launch.rs:61`](../ui/dr-ui/src/launch.rs#L61) | | FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`ui/dr-ui/src/import.rs:488`](../ui/dr-ui/src/import.rs#L488) | | FR-NC-6 | [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1) | -| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/cache.rs:225`](../core/dr-catalog/src/cache.rs#L225), [`core/dr-catalog/src/schema.rs:1186`](../core/dr-catalog/src/schema.rs#L1186), [`core/dr-catalog/src/schema.rs:785`](../core/dr-catalog/src/schema.rs#L785), [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/collections_ui.rs:2940`](../ui/dr-ui/src/collections_ui.rs#L2940), [`ui/dr-ui/src/collections_ui.rs:3182`](../ui/dr-ui/src/collections_ui.rs#L3182), [`ui/dr-ui/src/collections_ui.rs:4083`](../ui/dr-ui/src/collections_ui.rs#L4083), [`ui/dr-ui/src/collections_ui.rs:785`](../ui/dr-ui/src/collections_ui.rs#L785), [`ui/dr-ui/src/collections_ui.rs:856`](../ui/dr-ui/src/collections_ui.rs#L856), [`ui/dr-ui/src/lib.rs:2246`](../ui/dr-ui/src/lib.rs#L2246), [`ui/dr-ui/src/lib.rs:3481`](../ui/dr-ui/src/lib.rs#L3481), [`ui/dr-ui/src/library.rs:1881`](../ui/dr-ui/src/library.rs#L1881), [`ui/dr-ui/src/library.rs:1904`](../ui/dr-ui/src/library.rs#L1904), [`ui/dr-ui/src/library.rs:2179`](../ui/dr-ui/src/library.rs#L2179), [`ui/dr-ui/src/library_ui.rs:1402`](../ui/dr-ui/src/library_ui.rs#L1402), [`ui/dr-ui/src/library_ui.rs:1475`](../ui/dr-ui/src/library_ui.rs#L1475), [`ui/dr-ui/src/library_ui.rs:1576`](../ui/dr-ui/src/library_ui.rs#L1576), [`ui/dr-ui/src/library_ui.rs:1631`](../ui/dr-ui/src/library_ui.rs#L1631), [`ui/dr-ui/src/library_ui.rs:1766`](../ui/dr-ui/src/library_ui.rs#L1766), [`ui/dr-ui/src/library_ui.rs:1884`](../ui/dr-ui/src/library_ui.rs#L1884), [`ui/dr-ui/src/library_ui.rs:2651`](../ui/dr-ui/src/library_ui.rs#L2651), [`ui/dr-ui/src/library_ui.rs:341`](../ui/dr-ui/src/library_ui.rs#L341), [`ui/dr-ui/src/library_ui.rs:352`](../ui/dr-ui/src/library_ui.rs#L352), [`ui/dr-ui/src/library_ui.rs:367`](../ui/dr-ui/src/library_ui.rs#L367), [`ui/dr-ui/src/library_ui.rs:376`](../ui/dr-ui/src/library_ui.rs#L376), [`ui/dr-ui/src/library_ui.rs:476`](../ui/dr-ui/src/library_ui.rs#L476), [`ui/dr-ui/src/library_ui.rs:486`](../ui/dr-ui/src/library_ui.rs#L486), [`ui/dr-ui/src/library_ui.rs:533`](../ui/dr-ui/src/library_ui.rs#L533), [`ui/dr-ui/src/library_ui.rs:596`](../ui/dr-ui/src/library_ui.rs#L596), [`ui/dr-ui/src/library_ui.rs:627`](../ui/dr-ui/src/library_ui.rs#L627), [`ui/dr-ui/src/library_ui.rs:639`](../ui/dr-ui/src/library_ui.rs#L639), [`ui/dr-ui/src/library_ui.rs:6566`](../ui/dr-ui/src/library_ui.rs#L6566), [`ui/dr-ui/src/library_ui.rs:6584`](../ui/dr-ui/src/library_ui.rs#L6584), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/app.slint:1666`](../ui/dr-ui/ui/app.slint#L1666), [`ui/dr-ui/ui/app.slint:3221`](../ui/dr-ui/ui/app.slint#L3221), [`ui/dr-ui/ui/app.slint:643`](../ui/dr-ui/ui/app.slint#L643), [`ui/dr-ui/ui/app.slint:651`](../ui/dr-ui/ui/app.slint#L651), [`ui/dr-ui/ui/app.slint:657`](../ui/dr-ui/ui/app.slint#L657), [`ui/dr-ui/ui/collections.slint:1037`](../ui/dr-ui/ui/collections.slint#L1037), [`ui/dr-ui/ui/collections.slint:1048`](../ui/dr-ui/ui/collections.slint#L1048), [`ui/dr-ui/ui/collections.slint:1127`](../ui/dr-ui/ui/collections.slint#L1127), [`ui/dr-ui/ui/collections.slint:272`](../ui/dr-ui/ui/collections.slint#L272), [`ui/dr-ui/ui/collections.slint:424`](../ui/dr-ui/ui/collections.slint#L424), [`ui/dr-ui/ui/collections.slint:433`](../ui/dr-ui/ui/collections.slint#L433), [`ui/dr-ui/ui/collections.slint:436`](../ui/dr-ui/ui/collections.slint#L436), [`ui/dr-ui/ui/collections.slint:482`](../ui/dr-ui/ui/collections.slint#L482), [`ui/dr-ui/ui/collections.slint:52`](../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:720`](../ui/dr-ui/ui/collections.slint#L720), [`ui/dr-ui/ui/collections.slint:877`](../ui/dr-ui/ui/collections.slint#L877), [`ui/dr-ui/ui/collections.slint:91`](../ui/dr-ui/ui/collections.slint#L91), [`ui/dr-ui/ui/icons.slint:261`](../ui/dr-ui/ui/icons.slint#L261) | -| FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1631`](../ui/dr-ui/src/library_ui.rs#L1631), [`ui/dr-ui/src/memory.rs:1`](../ui/dr-ui/src/memory.rs#L1) | -| FR-NC-6c | [`core/dr-catalog/src/cache.rs:225`](../core/dr-catalog/src/cache.rs#L225), [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-sync-folder/src/lib.rs:197`](../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:73`](../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync-folder/src/lib.rs:818`](../core/dr-sync-folder/src/lib.rs#L818), [`core/dr-sync-folder/src/lib.rs:857`](../core/dr-sync-folder/src/lib.rs#L857), [`core/dr-sync-folder/src/vfs.rs:1`](../core/dr-sync-folder/src/vfs.rs#L1), [`core/dr-sync-nextcloud/src/desktop_client.rs:167`](../core/dr-sync-nextcloud/src/desktop_client.rs#L167), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-sync/src/error.rs:39`](../core/dr-sync/src/error.rs#L39), [`core/dr-sync/src/lib.rs:158`](../core/dr-sync/src/lib.rs#L158), [`core/dr-sync/src/types.rs:101`](../core/dr-sync/src/types.rs#L101), [`core/dr-types/src/lib.rs:122`](../core/dr-types/src/lib.rs#L122), [`core/dr-types/src/lib.rs:204`](../core/dr-types/src/lib.rs#L204), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:4083`](../ui/dr-ui/src/collections_ui.rs#L4083), [`ui/dr-ui/src/collections_ui.rs:785`](../ui/dr-ui/src/collections_ui.rs#L785), [`ui/dr-ui/src/collections_ui.rs:856`](../ui/dr-ui/src/collections_ui.rs#L856), [`ui/dr-ui/src/derived_sync.rs:585`](../ui/dr-ui/src/derived_sync.rs#L585), [`ui/dr-ui/src/derived_sync.rs:805`](../ui/dr-ui/src/derived_sync.rs#L805), [`ui/dr-ui/src/library.rs:1003`](../ui/dr-ui/src/library.rs#L1003), [`ui/dr-ui/src/library.rs:2001`](../ui/dr-ui/src/library.rs#L2001), [`ui/dr-ui/src/library.rs:2091`](../ui/dr-ui/src/library.rs#L2091), [`ui/dr-ui/src/library.rs:3569`](../ui/dr-ui/src/library.rs#L3569), [`ui/dr-ui/src/library.rs:4003`](../ui/dr-ui/src/library.rs#L4003), [`ui/dr-ui/src/library_ui.rs:1402`](../ui/dr-ui/src/library_ui.rs#L1402), [`ui/dr-ui/src/library_ui.rs:1475`](../ui/dr-ui/src/library_ui.rs#L1475), [`ui/dr-ui/src/library_ui.rs:1674`](../ui/dr-ui/src/library_ui.rs#L1674), [`ui/dr-ui/src/remote.rs:40`](../ui/dr-ui/src/remote.rs#L40), [`ui/dr-ui/ui/collections.slint:272`](../ui/dr-ui/ui/collections.slint#L272), [`ui/dr-ui/ui/collections.slint:877`](../ui/dr-ui/ui/collections.slint#L877), [`ui/dr-ui/ui/icons.slint:261`](../ui/dr-ui/ui/icons.slint#L261) | +| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/cache.rs:225`](../core/dr-catalog/src/cache.rs#L225), [`core/dr-catalog/src/schema.rs:1186`](../core/dr-catalog/src/schema.rs#L1186), [`core/dr-catalog/src/schema.rs:785`](../core/dr-catalog/src/schema.rs#L785), [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3413`](../ui/dr-ui/src/collections_ui.rs#L3413), [`ui/dr-ui/src/collections_ui.rs:636`](../ui/dr-ui/src/collections_ui.rs#L636), [`ui/dr-ui/src/collections_ui.rs:704`](../ui/dr-ui/src/collections_ui.rs#L704), [`ui/dr-ui/src/lib.rs:2246`](../ui/dr-ui/src/lib.rs#L2246), [`ui/dr-ui/src/lib.rs:3481`](../ui/dr-ui/src/lib.rs#L3481), [`ui/dr-ui/src/library.rs:1881`](../ui/dr-ui/src/library.rs#L1881), [`ui/dr-ui/src/library.rs:1904`](../ui/dr-ui/src/library.rs#L1904), [`ui/dr-ui/src/library.rs:2179`](../ui/dr-ui/src/library.rs#L2179), [`ui/dr-ui/src/library_ui.rs:1408`](../ui/dr-ui/src/library_ui.rs#L1408), [`ui/dr-ui/src/library_ui.rs:1481`](../ui/dr-ui/src/library_ui.rs#L1481), [`ui/dr-ui/src/library_ui.rs:1582`](../ui/dr-ui/src/library_ui.rs#L1582), [`ui/dr-ui/src/library_ui.rs:1637`](../ui/dr-ui/src/library_ui.rs#L1637), [`ui/dr-ui/src/library_ui.rs:1772`](../ui/dr-ui/src/library_ui.rs#L1772), [`ui/dr-ui/src/library_ui.rs:1890`](../ui/dr-ui/src/library_ui.rs#L1890), [`ui/dr-ui/src/library_ui.rs:2657`](../ui/dr-ui/src/library_ui.rs#L2657), [`ui/dr-ui/src/library_ui.rs:341`](../ui/dr-ui/src/library_ui.rs#L341), [`ui/dr-ui/src/library_ui.rs:352`](../ui/dr-ui/src/library_ui.rs#L352), [`ui/dr-ui/src/library_ui.rs:360`](../ui/dr-ui/src/library_ui.rs#L360), [`ui/dr-ui/src/library_ui.rs:372`](../ui/dr-ui/src/library_ui.rs#L372), [`ui/dr-ui/src/library_ui.rs:381`](../ui/dr-ui/src/library_ui.rs#L381), [`ui/dr-ui/src/library_ui.rs:482`](../ui/dr-ui/src/library_ui.rs#L482), [`ui/dr-ui/src/library_ui.rs:492`](../ui/dr-ui/src/library_ui.rs#L492), [`ui/dr-ui/src/library_ui.rs:539`](../ui/dr-ui/src/library_ui.rs#L539), [`ui/dr-ui/src/library_ui.rs:602`](../ui/dr-ui/src/library_ui.rs#L602), [`ui/dr-ui/src/library_ui.rs:633`](../ui/dr-ui/src/library_ui.rs#L633), [`ui/dr-ui/src/library_ui.rs:645`](../ui/dr-ui/src/library_ui.rs#L645), [`ui/dr-ui/src/library_ui.rs:6572`](../ui/dr-ui/src/library_ui.rs#L6572), [`ui/dr-ui/src/library_ui.rs:6590`](../ui/dr-ui/src/library_ui.rs#L6590), [`ui/dr-ui/src/library_ui.rs:6602`](../ui/dr-ui/src/library_ui.rs#L6602), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/app.slint:1625`](../ui/dr-ui/ui/app.slint#L1625), [`ui/dr-ui/ui/app.slint:3245`](../ui/dr-ui/ui/app.slint#L3245), [`ui/dr-ui/ui/app.slint:622`](../ui/dr-ui/ui/app.slint#L622), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:360`](../ui/dr-ui/ui/collections.slint#L360), [`ui/dr-ui/ui/collections.slint:369`](../ui/dr-ui/ui/collections.slint#L369), [`ui/dr-ui/ui/collections.slint:372`](../ui/dr-ui/ui/collections.slint#L372), [`ui/dr-ui/ui/collections.slint:395`](../ui/dr-ui/ui/collections.slint#L395), [`ui/dr-ui/ui/collections.slint:52`](../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:596`](../ui/dr-ui/ui/collections.slint#L596), [`ui/dr-ui/ui/collections.slint:747`](../ui/dr-ui/ui/collections.slint#L747), [`ui/dr-ui/ui/collections.slint:84`](../ui/dr-ui/ui/collections.slint#L84), [`ui/dr-ui/ui/icons.slint:261`](../ui/dr-ui/ui/icons.slint#L261) | +| FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1637`](../ui/dr-ui/src/library_ui.rs#L1637), [`ui/dr-ui/src/memory.rs:1`](../ui/dr-ui/src/memory.rs#L1) | +| FR-NC-6c | [`core/dr-catalog/src/cache.rs:225`](../core/dr-catalog/src/cache.rs#L225), [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-sync-folder/src/lib.rs:197`](../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:73`](../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync-folder/src/lib.rs:818`](../core/dr-sync-folder/src/lib.rs#L818), [`core/dr-sync-folder/src/lib.rs:857`](../core/dr-sync-folder/src/lib.rs#L857), [`core/dr-sync-folder/src/vfs.rs:1`](../core/dr-sync-folder/src/vfs.rs#L1), [`core/dr-sync-nextcloud/src/desktop_client.rs:167`](../core/dr-sync-nextcloud/src/desktop_client.rs#L167), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-sync/src/error.rs:39`](../core/dr-sync/src/error.rs#L39), [`core/dr-sync/src/lib.rs:158`](../core/dr-sync/src/lib.rs#L158), [`core/dr-sync/src/types.rs:101`](../core/dr-sync/src/types.rs#L101), [`core/dr-types/src/lib.rs:122`](../core/dr-types/src/lib.rs#L122), [`core/dr-types/src/lib.rs:204`](../core/dr-types/src/lib.rs#L204), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3413`](../ui/dr-ui/src/collections_ui.rs#L3413), [`ui/dr-ui/src/collections_ui.rs:636`](../ui/dr-ui/src/collections_ui.rs#L636), [`ui/dr-ui/src/collections_ui.rs:704`](../ui/dr-ui/src/collections_ui.rs#L704), [`ui/dr-ui/src/derived_sync.rs:585`](../ui/dr-ui/src/derived_sync.rs#L585), [`ui/dr-ui/src/derived_sync.rs:805`](../ui/dr-ui/src/derived_sync.rs#L805), [`ui/dr-ui/src/library.rs:1003`](../ui/dr-ui/src/library.rs#L1003), [`ui/dr-ui/src/library.rs:2001`](../ui/dr-ui/src/library.rs#L2001), [`ui/dr-ui/src/library.rs:2091`](../ui/dr-ui/src/library.rs#L2091), [`ui/dr-ui/src/library.rs:3569`](../ui/dr-ui/src/library.rs#L3569), [`ui/dr-ui/src/library.rs:4003`](../ui/dr-ui/src/library.rs#L4003), [`ui/dr-ui/src/library_ui.rs:1408`](../ui/dr-ui/src/library_ui.rs#L1408), [`ui/dr-ui/src/library_ui.rs:1481`](../ui/dr-ui/src/library_ui.rs#L1481), [`ui/dr-ui/src/library_ui.rs:1680`](../ui/dr-ui/src/library_ui.rs#L1680), [`ui/dr-ui/src/remote.rs:40`](../ui/dr-ui/src/remote.rs#L40), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:747`](../ui/dr-ui/ui/collections.slint#L747), [`ui/dr-ui/ui/icons.slint:261`](../ui/dr-ui/ui/icons.slint#L261) | | FR-NC-6d | [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-sync-folder/src/lib.rs:1`](../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-folder/src/vfs.rs:1`](../core/dr-sync-folder/src/vfs.rs#L1) | -| FR-NC-7 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:105`](../core/dr-sync-nextcloud/src/lib.rs#L105), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:3883`](../ui/dr-ui/src/library.rs#L3883), [`ui/dr-ui/src/library_ui.rs:4230`](../ui/dr-ui/src/library_ui.rs#L4230), [`ui/dr-ui/ui/settings.slint:395`](../ui/dr-ui/ui/settings.slint#L395) | +| FR-NC-7 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:105`](../core/dr-sync-nextcloud/src/lib.rs#L105), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:3883`](../ui/dr-ui/src/library.rs#L3883), [`ui/dr-ui/src/library_ui.rs:4236`](../ui/dr-ui/src/library_ui.rs#L4236), [`ui/dr-ui/ui/settings.slint:395`](../ui/dr-ui/ui/settings.slint#L395) | | FR-NC-7a | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`core/dr-types/src/settings.rs:206`](../core/dr-types/src/settings.rs#L206), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1379`](../ui/dr-ui/src/lib.rs#L1379), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | | FR-NC-7b | [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`ui/dr-ui/src/import.rs:122`](../ui/dr-ui/src/import.rs#L122), [`ui/dr-ui/src/import.rs:336`](../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import.rs:584`](../ui/dr-ui/src/import.rs#L584), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1379`](../ui/dr-ui/src/lib.rs#L1379) | -| FR-NC-8 | [`core/dr-catalog/src/rating.rs:204`](../core/dr-catalog/src/rating.rs#L204), [`core/dr-catalog/src/rating.rs:66`](../core/dr-catalog/src/rating.rs#L66), [`core/dr-catalog/src/rating.rs:900`](../core/dr-catalog/src/rating.rs#L900), [`core/dr-catalog/src/schema.rs:155`](../core/dr-catalog/src/schema.rs#L155), [`core/dr-pipeline/src/sidecar.rs:120`](../core/dr-pipeline/src/sidecar.rs#L120), [`core/dr-pipeline/src/sidecar.rs:2574`](../core/dr-pipeline/src/sidecar.rs#L2574), [`core/dr-pipeline/src/sidecar.rs:619`](../core/dr-pipeline/src/sidecar.rs#L619), [`core/dr-pipeline/src/sidecar.rs:649`](../core/dr-pipeline/src/sidecar.rs#L649), [`core/dr-pipeline/src/sidecar.rs:94`](../core/dr-pipeline/src/sidecar.rs#L94), [`ui/dr-ui/src/lib.rs:2216`](../ui/dr-ui/src/lib.rs#L2216), [`ui/dr-ui/src/library.rs:1045`](../ui/dr-ui/src/library.rs#L1045), [`ui/dr-ui/src/library.rs:2310`](../ui/dr-ui/src/library.rs#L2310), [`ui/dr-ui/src/library.rs:500`](../ui/dr-ui/src/library.rs#L500), [`ui/dr-ui/src/library.rs:7312`](../ui/dr-ui/src/library.rs#L7312), [`ui/dr-ui/src/library.rs:755`](../ui/dr-ui/src/library.rs#L755), [`ui/dr-ui/src/library_ui.rs:548`](../ui/dr-ui/src/library_ui.rs#L548), [`ui/dr-ui/src/presets.rs:288`](../ui/dr-ui/src/presets.rs#L288), [`ui/dr-ui/src/presets.rs:335`](../ui/dr-ui/src/presets.rs#L335) | -| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:204`](../core/dr-catalog/src/rating.rs#L204), [`core/dr-catalog/src/rating.rs:66`](../core/dr-catalog/src/rating.rs#L66), [`core/dr-catalog/src/rating.rs:900`](../core/dr-catalog/src/rating.rs#L900), [`core/dr-catalog/src/schema.rs:155`](../core/dr-catalog/src/schema.rs#L155), [`core/dr-catalog/src/schema.rs:532`](../core/dr-catalog/src/schema.rs#L532), [`core/dr-catalog/src/schema.rs:728`](../core/dr-catalog/src/schema.rs#L728), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:158`](../core/dr-pipeline/src/sidecar.rs#L158), [`core/dr-pipeline/src/sidecar.rs:185`](../core/dr-pipeline/src/sidecar.rs#L185), [`core/dr-pipeline/src/sidecar.rs:2340`](../core/dr-pipeline/src/sidecar.rs#L2340), [`core/dr-pipeline/src/sidecar.rs:2574`](../core/dr-pipeline/src/sidecar.rs#L2574), [`core/dr-pipeline/src/sidecar.rs:354`](../core/dr-pipeline/src/sidecar.rs#L354), [`core/dr-pipeline/src/sidecar.rs:452`](../core/dr-pipeline/src/sidecar.rs#L452), [`core/dr-pipeline/src/sidecar.rs:619`](../core/dr-pipeline/src/sidecar.rs#L619), [`core/dr-pipeline/src/sidecar.rs:649`](../core/dr-pipeline/src/sidecar.rs#L649), [`core/dr-pipeline/src/spot.rs:245`](../core/dr-pipeline/src/spot.rs#L245), [`core/dr-pipeline/tests/mask_sidecar.rs:1055`](../core/dr-pipeline/tests/mask_sidecar.rs#L1055), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-sync/src/scan.rs:31`](../core/dr-sync/src/scan.rs#L31), [`core/dr-sync/src/scan.rs:45`](../core/dr-sync/src/scan.rs#L45), [`core/dr-sync/src/scan.rs:912`](../core/dr-sync/src/scan.rs#L912), [`core/dr-xmp/src/lib.rs:733`](../core/dr-xmp/src/lib.rs#L733), [`core/dr-xmp/src/lib.rs:746`](../core/dr-xmp/src/lib.rs#L746), [`ui/dr-ui/src/derived_sync.rs:585`](../ui/dr-ui/src/derived_sync.rs#L585), [`ui/dr-ui/src/library.rs:1045`](../ui/dr-ui/src/library.rs#L1045), [`ui/dr-ui/src/library.rs:1065`](../ui/dr-ui/src/library.rs#L1065), [`ui/dr-ui/src/library.rs:1376`](../ui/dr-ui/src/library.rs#L1376), [`ui/dr-ui/src/library.rs:1411`](../ui/dr-ui/src/library.rs#L1411), [`ui/dr-ui/src/library.rs:2310`](../ui/dr-ui/src/library.rs#L2310), [`ui/dr-ui/src/library.rs:7144`](../ui/dr-ui/src/library.rs#L7144), [`ui/dr-ui/src/library.rs:7312`](../ui/dr-ui/src/library.rs#L7312), [`ui/dr-ui/src/library.rs:73`](../ui/dr-ui/src/library.rs#L73), [`ui/dr-ui/src/library.rs:755`](../ui/dr-ui/src/library.rs#L755), [`ui/dr-ui/src/library.rs:899`](../ui/dr-ui/src/library.rs#L899), [`ui/dr-ui/src/library_ui.rs:1259`](../ui/dr-ui/src/library_ui.rs#L1259), [`ui/dr-ui/src/presets.rs:288`](../ui/dr-ui/src/presets.rs#L288), [`ui/dr-ui/src/presets.rs:335`](../ui/dr-ui/src/presets.rs#L335) | -| FR-PLAT-AND-2 | [`core/dr-catalog/src/walk.rs:1022`](../core/dr-catalog/src/walk.rs#L1022), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-sync-folder/src/lib.rs:242`](../core/dr-sync-folder/src/lib.rs#L242), [`core/dr-sync-folder/src/tests.rs:51`](../core/dr-sync-folder/src/tests.rs#L51), [`core/dr-sync/src/error.rs:113`](../core/dr-sync/src/error.rs#L113), [`core/dr-sync/src/error.rs:195`](../core/dr-sync/src/error.rs#L195), [`core/dr-sync/src/scan.rs:200`](../core/dr-sync/src/scan.rs#L200), [`core/dr-sync/src/scan.rs:505`](../core/dr-sync/src/scan.rs#L505), [`core/dr-sync/src/scan.rs:531`](../core/dr-sync/src/scan.rs#L531), [`core/dr-sync/src/scan.rs:560`](../core/dr-sync/src/scan.rs#L560), [`ui/dr-ui/src/library.rs:1276`](../ui/dr-ui/src/library.rs#L1276), [`ui/dr-ui/src/library.rs:1329`](../ui/dr-ui/src/library.rs#L1329), [`ui/dr-ui/src/library.rs:1360`](../ui/dr-ui/src/library.rs#L1360), [`ui/dr-ui/src/library.rs:1727`](../ui/dr-ui/src/library.rs#L1727), [`ui/dr-ui/src/library_ui.rs:1235`](../ui/dr-ui/src/library_ui.rs#L1235), [`ui/dr-ui/src/library_ui.rs:1326`](../ui/dr-ui/src/library_ui.rs#L1326), [`ui/dr-ui/src/library_ui.rs:1925`](../ui/dr-ui/src/library_ui.rs#L1925), [`ui/dr-ui/src/library_ui.rs:325`](../ui/dr-ui/src/library_ui.rs#L325), [`ui/dr-ui/src/library_ui.rs:517`](../ui/dr-ui/src/library_ui.rs#L517) | +| FR-NC-8 | [`core/dr-catalog/src/rating.rs:204`](../core/dr-catalog/src/rating.rs#L204), [`core/dr-catalog/src/rating.rs:66`](../core/dr-catalog/src/rating.rs#L66), [`core/dr-catalog/src/rating.rs:900`](../core/dr-catalog/src/rating.rs#L900), [`core/dr-catalog/src/schema.rs:155`](../core/dr-catalog/src/schema.rs#L155), [`core/dr-pipeline/src/sidecar.rs:122`](../core/dr-pipeline/src/sidecar.rs#L122), [`core/dr-pipeline/src/sidecar.rs:2770`](../core/dr-pipeline/src/sidecar.rs#L2770), [`core/dr-pipeline/src/sidecar.rs:621`](../core/dr-pipeline/src/sidecar.rs#L621), [`core/dr-pipeline/src/sidecar.rs:651`](../core/dr-pipeline/src/sidecar.rs#L651), [`core/dr-pipeline/src/sidecar.rs:96`](../core/dr-pipeline/src/sidecar.rs#L96), [`ui/dr-ui/src/lib.rs:2216`](../ui/dr-ui/src/lib.rs#L2216), [`ui/dr-ui/src/library.rs:1045`](../ui/dr-ui/src/library.rs#L1045), [`ui/dr-ui/src/library.rs:2310`](../ui/dr-ui/src/library.rs#L2310), [`ui/dr-ui/src/library.rs:500`](../ui/dr-ui/src/library.rs#L500), [`ui/dr-ui/src/library.rs:7312`](../ui/dr-ui/src/library.rs#L7312), [`ui/dr-ui/src/library.rs:755`](../ui/dr-ui/src/library.rs#L755), [`ui/dr-ui/src/library_ui.rs:554`](../ui/dr-ui/src/library_ui.rs#L554), [`ui/dr-ui/src/presets.rs:288`](../ui/dr-ui/src/presets.rs#L288), [`ui/dr-ui/src/presets.rs:335`](../ui/dr-ui/src/presets.rs#L335) | +| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:204`](../core/dr-catalog/src/rating.rs#L204), [`core/dr-catalog/src/rating.rs:66`](../core/dr-catalog/src/rating.rs#L66), [`core/dr-catalog/src/rating.rs:900`](../core/dr-catalog/src/rating.rs#L900), [`core/dr-catalog/src/schema.rs:155`](../core/dr-catalog/src/schema.rs#L155), [`core/dr-catalog/src/schema.rs:532`](../core/dr-catalog/src/schema.rs#L532), [`core/dr-catalog/src/schema.rs:728`](../core/dr-catalog/src/schema.rs#L728), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:160`](../core/dr-pipeline/src/sidecar.rs#L160), [`core/dr-pipeline/src/sidecar.rs:187`](../core/dr-pipeline/src/sidecar.rs#L187), [`core/dr-pipeline/src/sidecar.rs:2536`](../core/dr-pipeline/src/sidecar.rs#L2536), [`core/dr-pipeline/src/sidecar.rs:2770`](../core/dr-pipeline/src/sidecar.rs#L2770), [`core/dr-pipeline/src/sidecar.rs:356`](../core/dr-pipeline/src/sidecar.rs#L356), [`core/dr-pipeline/src/sidecar.rs:454`](../core/dr-pipeline/src/sidecar.rs#L454), [`core/dr-pipeline/src/sidecar.rs:621`](../core/dr-pipeline/src/sidecar.rs#L621), [`core/dr-pipeline/src/sidecar.rs:651`](../core/dr-pipeline/src/sidecar.rs#L651), [`core/dr-pipeline/src/spot.rs:245`](../core/dr-pipeline/src/spot.rs#L245), [`core/dr-pipeline/tests/mask_sidecar.rs:1075`](../core/dr-pipeline/tests/mask_sidecar.rs#L1075), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-sync/src/scan.rs:31`](../core/dr-sync/src/scan.rs#L31), [`core/dr-sync/src/scan.rs:45`](../core/dr-sync/src/scan.rs#L45), [`core/dr-sync/src/scan.rs:912`](../core/dr-sync/src/scan.rs#L912), [`core/dr-xmp/src/lib.rs:733`](../core/dr-xmp/src/lib.rs#L733), [`core/dr-xmp/src/lib.rs:746`](../core/dr-xmp/src/lib.rs#L746), [`ui/dr-ui/src/derived_sync.rs:585`](../ui/dr-ui/src/derived_sync.rs#L585), [`ui/dr-ui/src/library.rs:1045`](../ui/dr-ui/src/library.rs#L1045), [`ui/dr-ui/src/library.rs:1065`](../ui/dr-ui/src/library.rs#L1065), [`ui/dr-ui/src/library.rs:1376`](../ui/dr-ui/src/library.rs#L1376), [`ui/dr-ui/src/library.rs:1411`](../ui/dr-ui/src/library.rs#L1411), [`ui/dr-ui/src/library.rs:2310`](../ui/dr-ui/src/library.rs#L2310), [`ui/dr-ui/src/library.rs:7144`](../ui/dr-ui/src/library.rs#L7144), [`ui/dr-ui/src/library.rs:7312`](../ui/dr-ui/src/library.rs#L7312), [`ui/dr-ui/src/library.rs:73`](../ui/dr-ui/src/library.rs#L73), [`ui/dr-ui/src/library.rs:755`](../ui/dr-ui/src/library.rs#L755), [`ui/dr-ui/src/library.rs:899`](../ui/dr-ui/src/library.rs#L899), [`ui/dr-ui/src/library_ui.rs:1265`](../ui/dr-ui/src/library_ui.rs#L1265), [`ui/dr-ui/src/presets.rs:288`](../ui/dr-ui/src/presets.rs#L288), [`ui/dr-ui/src/presets.rs:335`](../ui/dr-ui/src/presets.rs#L335) | +| FR-PLAT-AND-2 | [`core/dr-catalog/src/walk.rs:1022`](../core/dr-catalog/src/walk.rs#L1022), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-sync-folder/src/lib.rs:242`](../core/dr-sync-folder/src/lib.rs#L242), [`core/dr-sync-folder/src/tests.rs:51`](../core/dr-sync-folder/src/tests.rs#L51), [`core/dr-sync/src/error.rs:113`](../core/dr-sync/src/error.rs#L113), [`core/dr-sync/src/error.rs:195`](../core/dr-sync/src/error.rs#L195), [`core/dr-sync/src/scan.rs:200`](../core/dr-sync/src/scan.rs#L200), [`core/dr-sync/src/scan.rs:505`](../core/dr-sync/src/scan.rs#L505), [`core/dr-sync/src/scan.rs:531`](../core/dr-sync/src/scan.rs#L531), [`core/dr-sync/src/scan.rs:560`](../core/dr-sync/src/scan.rs#L560), [`ui/dr-ui/src/library.rs:1276`](../ui/dr-ui/src/library.rs#L1276), [`ui/dr-ui/src/library.rs:1329`](../ui/dr-ui/src/library.rs#L1329), [`ui/dr-ui/src/library.rs:1360`](../ui/dr-ui/src/library.rs#L1360), [`ui/dr-ui/src/library.rs:1727`](../ui/dr-ui/src/library.rs#L1727), [`ui/dr-ui/src/library_ui.rs:1241`](../ui/dr-ui/src/library_ui.rs#L1241), [`ui/dr-ui/src/library_ui.rs:1332`](../ui/dr-ui/src/library_ui.rs#L1332), [`ui/dr-ui/src/library_ui.rs:1931`](../ui/dr-ui/src/library_ui.rs#L1931), [`ui/dr-ui/src/library_ui.rs:325`](../ui/dr-ui/src/library_ui.rs#L325), [`ui/dr-ui/src/library_ui.rs:523`](../ui/dr-ui/src/library_ui.rs#L523) | | FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/runner.rs:1`](../core/dr-catalog/src/runner.rs#L1) | | FR-PLAT-AND-4 | [`core/dr-catalog/src/runner.rs:1`](../core/dr-catalog/src/runner.rs#L1) | -| FR-PLAT-AND-5 | [`apps/darkroom-android/src/lib.rs:166`](../apps/darkroom-android/src/lib.rs#L166), [`core/dr-gpu/src/adjust.rs:1029`](../core/dr-gpu/src/adjust.rs#L1029), [`core/dr-gpu/src/detail.rs:161`](../core/dr-gpu/src/detail.rs#L161), [`core/dr-gpu/src/detail.rs:547`](../core/dr-gpu/src/detail.rs#L547), [`core/dr-gpu/tests/detail_stage.rs:417`](../core/dr-gpu/tests/detail_stage.rs#L417), [`ui/dr-ui/src/develop.rs:3853`](../ui/dr-ui/src/develop.rs#L3853), [`ui/dr-ui/src/identity_ui.rs:121`](../ui/dr-ui/src/identity_ui.rs#L121), [`ui/dr-ui/src/lib.rs:1198`](../ui/dr-ui/src/lib.rs#L1198), [`ui/dr-ui/src/lib.rs:1647`](../ui/dr-ui/src/lib.rs#L1647), [`ui/dr-ui/src/memory.rs:163`](../ui/dr-ui/src/memory.rs#L163), [`ui/dr-ui/src/memory.rs:1`](../ui/dr-ui/src/memory.rs#L1) | +| FR-PLAT-AND-5 | [`apps/darkroom-android/src/lib.rs:166`](../apps/darkroom-android/src/lib.rs#L166), [`core/dr-gpu/src/adjust.rs:1029`](../core/dr-gpu/src/adjust.rs#L1029), [`core/dr-gpu/src/detail.rs:161`](../core/dr-gpu/src/detail.rs#L161), [`core/dr-gpu/src/detail.rs:547`](../core/dr-gpu/src/detail.rs#L547), [`core/dr-gpu/tests/detail_stage.rs:417`](../core/dr-gpu/tests/detail_stage.rs#L417), [`ui/dr-ui/src/develop.rs:4155`](../ui/dr-ui/src/develop.rs#L4155), [`ui/dr-ui/src/identity_ui.rs:121`](../ui/dr-ui/src/identity_ui.rs#L121), [`ui/dr-ui/src/lib.rs:1198`](../ui/dr-ui/src/lib.rs#L1198), [`ui/dr-ui/src/lib.rs:1647`](../ui/dr-ui/src/lib.rs#L1647), [`ui/dr-ui/src/memory.rs:163`](../ui/dr-ui/src/memory.rs#L163), [`ui/dr-ui/src/memory.rs:1`](../ui/dr-ui/src/memory.rs#L1) | | FR-PLAT-AND-6 | [`apps/darkroom-android/src/lib.rs:388`](../apps/darkroom-android/src/lib.rs#L388) | | FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`platform/dr-plat/src/storage.rs:344`](../platform/dr-plat/src/storage.rs#L344), [`ui/dr-ui/src/lib.rs:1074`](../ui/dr-ui/src/lib.rs#L1074), [`ui/dr-ui/src/preset_store.rs:1`](../ui/dr-ui/src/preset_store.rs#L1), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | | FR-PLAT-LIN-2 | [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../platform/dr-plat/src/display/x11.rs#L1) | | FR-PLG-2 | [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/expr.rs:152`](../core/dr-pipeline/src/declared/expr.rs#L152), [`core/dr-pipeline/src/declared/expr.rs:1`](../core/dr-pipeline/src/declared/expr.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:82`](../core/dr-pipeline/src/declared/mod.rs#L82), [`core/dr-pipeline/src/descriptor.rs:15`](../core/dr-pipeline/src/descriptor.rs#L15), [`core/dr-pipeline/src/descriptor.rs:713`](../core/dr-pipeline/src/descriptor.rs#L713), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/tests/declared_parity.rs:1`](../core/dr-pipeline/tests/declared_parity.rs#L1), [`core/dr-pipeline/tests/declared_parity.rs:240`](../core/dr-pipeline/tests/declared_parity.rs#L240), [`core/dr-pipeline/tests/declared_parity.rs:305`](../core/dr-pipeline/tests/declared_parity.rs#L305), [`core/dr-pipeline/tests/declared_parity.rs:358`](../core/dr-pipeline/tests/declared_parity.rs#L358), [`core/dr-pipeline/tests/declared_parity.rs:418`](../core/dr-pipeline/tests/declared_parity.rs#L418) | | FR-PLG-2d | [`core/dr-pipeline/src/declared/decl.rs:112`](../core/dr-pipeline/src/declared/decl.rs#L112), [`core/dr-pipeline/src/declared/decl.rs:154`](../core/dr-pipeline/src/declared/decl.rs#L154), [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/decl.rs:422`](../core/dr-pipeline/src/declared/decl.rs#L422), [`core/dr-pipeline/src/declared/decl.rs:67`](../core/dr-pipeline/src/declared/decl.rs#L67), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:384`](../core/dr-pipeline/src/declared/mod.rs#L384), [`core/dr-pipeline/src/declared/mod.rs:403`](../core/dr-pipeline/src/declared/mod.rs#L403) | -| FR-PLG-8 | [`core/dr-pipeline/src/sidecar.rs:2011`](../core/dr-pipeline/src/sidecar.rs#L2011), [`core/dr-pipeline/src/sidecar.rs:2044`](../core/dr-pipeline/src/sidecar.rs#L2044) | +| FR-PLG-8 | [`core/dr-pipeline/src/sidecar.rs:2207`](../core/dr-pipeline/src/sidecar.rs#L2207), [`core/dr-pipeline/src/sidecar.rs:2240`](../core/dr-pipeline/src/sidecar.rs#L2240) | | FR-RAW-1 | [`core/dr-decode/src/lib.rs:243`](../core/dr-decode/src/lib.rs#L243), [`core/dr-types/src/lib.rs:132`](../core/dr-types/src/lib.rs#L132), [`core/dr-types/src/lib.rs:203`](../core/dr-types/src/lib.rs#L203) | | FR-RAW-3 | [`core/dr-decode/src/lib.rs:139`](../core/dr-decode/src/lib.rs#L139), [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-decode/src/locate.rs:1366`](../core/dr-decode/src/locate.rs#L1366) | | FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:232`](../ui/dr-ui/src/lib.rs#L232) | | FR-RAW-5 | [`core/dr-decode/src/lib.rs:167`](../core/dr-decode/src/lib.rs#L167), [`core/dr-gpu/src/demosaic.rs:34`](../core/dr-gpu/src/demosaic.rs#L34), [`core/dr-gpu/src/demosaic.rs:602`](../core/dr-gpu/src/demosaic.rs#L602), [`core/dr-gpu/src/demosaic.rs:681`](../core/dr-gpu/src/demosaic.rs#L681), [`core/dr-gpu/src/demosaic.rs:805`](../core/dr-gpu/src/demosaic.rs#L805) | -| FR-UI-1 | [`core/dr-types/src/settings.rs:1166`](../core/dr-types/src/settings.rs#L1166), [`core/dr-types/src/settings.rs:321`](../core/dr-types/src/settings.rs#L321), [`core/dr-types/src/settings.rs:341`](../core/dr-types/src/settings.rs#L341), [`ui/dr-ui/src/lib.rs:101`](../ui/dr-ui/src/lib.rs#L101), [`ui/dr-ui/src/lib.rs:108`](../ui/dr-ui/src/lib.rs#L108), [`ui/dr-ui/src/lib.rs:2460`](../ui/dr-ui/src/lib.rs#L2460), [`ui/dr-ui/src/lib.rs:3563`](../ui/dr-ui/src/lib.rs#L3563), [`ui/dr-ui/src/lib.rs:3582`](../ui/dr-ui/src/lib.rs#L3582), [`ui/dr-ui/src/masks_ui.rs:951`](../ui/dr-ui/src/masks_ui.rs#L951), [`ui/dr-ui/src/settings_ui.rs:160`](../ui/dr-ui/src/settings_ui.rs#L160), [`ui/dr-ui/ui/app.slint:1012`](../ui/dr-ui/ui/app.slint#L1012), [`ui/dr-ui/ui/app.slint:2735`](../ui/dr-ui/ui/app.slint#L2735), [`ui/dr-ui/ui/app.slint:843`](../ui/dr-ui/ui/app.slint#L843), [`ui/dr-ui/ui/identity.slint:188`](../ui/dr-ui/ui/identity.slint#L188), [`ui/dr-ui/ui/library.slint:1076`](../ui/dr-ui/ui/library.slint#L1076), [`ui/dr-ui/ui/settings.slint:104`](../ui/dr-ui/ui/settings.slint#L104), [`ui/dr-ui/ui/settings.slint:565`](../ui/dr-ui/ui/settings.slint#L565), [`ui/dr-ui/ui/toolrail.slint:156`](../ui/dr-ui/ui/toolrail.slint#L156), [`ui/dr-ui/ui/toolrail.slint:89`](../ui/dr-ui/ui/toolrail.slint#L89) | -| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:116`](../ui/dr-ui/src/collections_ui.rs#L116), [`ui/dr-ui/src/collections_ui.rs:1197`](../ui/dr-ui/src/collections_ui.rs#L1197), [`ui/dr-ui/src/collections_ui.rs:1207`](../ui/dr-ui/src/collections_ui.rs#L1207), [`ui/dr-ui/src/collections_ui.rs:154`](../ui/dr-ui/src/collections_ui.rs#L154), [`ui/dr-ui/src/collections_ui.rs:1704`](../ui/dr-ui/src/collections_ui.rs#L1704), [`ui/dr-ui/src/collections_ui.rs:1721`](../ui/dr-ui/src/collections_ui.rs#L1721), [`ui/dr-ui/src/collections_ui.rs:1973`](../ui/dr-ui/src/collections_ui.rs#L1973), [`ui/dr-ui/src/collections_ui.rs:2701`](../ui/dr-ui/src/collections_ui.rs#L2701), [`ui/dr-ui/src/collections_ui.rs:3596`](../ui/dr-ui/src/collections_ui.rs#L3596), [`ui/dr-ui/src/gestures.rs:1`](../ui/dr-ui/src/gestures.rs#L1), [`ui/dr-ui/src/lib.rs:101`](../ui/dr-ui/src/lib.rs#L101), [`ui/dr-ui/src/library_ui.rs:6451`](../ui/dr-ui/src/library_ui.rs#L6451), [`ui/dr-ui/src/library_ui.rs:7778`](../ui/dr-ui/src/library_ui.rs#L7778), [`ui/dr-ui/ui/app.slint:1094`](../ui/dr-ui/ui/app.slint#L1094), [`ui/dr-ui/ui/app.slint:61`](../ui/dr-ui/ui/app.slint#L61), [`ui/dr-ui/ui/app.slint:696`](../ui/dr-ui/ui/app.slint#L696), [`ui/dr-ui/ui/app.slint:710`](../ui/dr-ui/ui/app.slint#L710), [`ui/dr-ui/ui/controls.slint:605`](../ui/dr-ui/ui/controls.slint#L605), [`ui/dr-ui/ui/gestures.slint:1`](../ui/dr-ui/ui/gestures.slint#L1), [`ui/dr-ui/ui/library.slint:1287`](../ui/dr-ui/ui/library.slint#L1287), [`ui/dr-ui/ui/library.slint:1294`](../ui/dr-ui/ui/library.slint#L1294), [`ui/dr-ui/ui/library.slint:1391`](../ui/dr-ui/ui/library.slint#L1391), [`ui/dr-ui/ui/library.slint:2581`](../ui/dr-ui/ui/library.slint#L2581), [`ui/dr-ui/ui/library.slint:4316`](../ui/dr-ui/ui/library.slint#L4316), [`ui/dr-ui/ui/library.slint:965`](../ui/dr-ui/ui/library.slint#L965), [`ui/dr-ui/ui/library.slint:998`](../ui/dr-ui/ui/library.slint#L998), [`ui/dr-ui/ui/presets.slint:19`](../ui/dr-ui/ui/presets.slint#L19), [`ui/dr-ui/ui/settings.slint:118`](../ui/dr-ui/ui/settings.slint#L118) | -| FR-UI-3 | [`ui/dr-ui/src/collections_ui.rs:209`](../ui/dr-ui/src/collections_ui.rs#L209), [`ui/dr-ui/src/collections_ui.rs:2701`](../ui/dr-ui/src/collections_ui.rs#L2701), [`ui/dr-ui/src/collections_ui.rs:414`](../ui/dr-ui/src/collections_ui.rs#L414), [`ui/dr-ui/src/collections_ui.rs:427`](../ui/dr-ui/src/collections_ui.rs#L427), [`ui/dr-ui/src/develop.rs:2525`](../ui/dr-ui/src/develop.rs#L2525), [`ui/dr-ui/src/develop.rs:2634`](../ui/dr-ui/src/develop.rs#L2634), [`ui/dr-ui/src/library_ui.rs:5480`](../ui/dr-ui/src/library_ui.rs#L5480), [`ui/dr-ui/src/masks_ui.rs:1043`](../ui/dr-ui/src/masks_ui.rs#L1043), [`ui/dr-ui/src/masks_ui.rs:1065`](../ui/dr-ui/src/masks_ui.rs#L1065), [`ui/dr-ui/src/masks_ui.rs:263`](../ui/dr-ui/src/masks_ui.rs#L263), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/ui/app.slint:2300`](../ui/dr-ui/ui/app.slint#L2300), [`ui/dr-ui/ui/collections.slint:1127`](../ui/dr-ui/ui/collections.slint#L1127), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:75`](../ui/dr-ui/ui/collections.slint#L75), [`ui/dr-ui/ui/collections.slint:877`](../ui/dr-ui/ui/collections.slint#L877), [`ui/dr-ui/ui/masks.slint:767`](../ui/dr-ui/ui/masks.slint#L767), [`ui/dr-ui/ui/widgets.slint:71`](../ui/dr-ui/ui/widgets.slint#L71) | -| FR-UI-4 | [`tools/traceability/src/gestures.rs:1`](../tools/traceability/src/gestures.rs#L1), [`ui/dr-ui/src/collections_ui.rs:116`](../ui/dr-ui/src/collections_ui.rs#L116), [`ui/dr-ui/src/collections_ui.rs:117`](../ui/dr-ui/src/collections_ui.rs#L117), [`ui/dr-ui/src/collections_ui.rs:1197`](../ui/dr-ui/src/collections_ui.rs#L1197), [`ui/dr-ui/src/collections_ui.rs:1207`](../ui/dr-ui/src/collections_ui.rs#L1207), [`ui/dr-ui/src/collections_ui.rs:126`](../ui/dr-ui/src/collections_ui.rs#L126), [`ui/dr-ui/src/collections_ui.rs:154`](../ui/dr-ui/src/collections_ui.rs#L154), [`ui/dr-ui/src/collections_ui.rs:1704`](../ui/dr-ui/src/collections_ui.rs#L1704), [`ui/dr-ui/src/collections_ui.rs:1721`](../ui/dr-ui/src/collections_ui.rs#L1721), [`ui/dr-ui/src/collections_ui.rs:1837`](../ui/dr-ui/src/collections_ui.rs#L1837), [`ui/dr-ui/src/collections_ui.rs:1973`](../ui/dr-ui/src/collections_ui.rs#L1973), [`ui/dr-ui/src/collections_ui.rs:2000`](../ui/dr-ui/src/collections_ui.rs#L2000), [`ui/dr-ui/src/collections_ui.rs:209`](../ui/dr-ui/src/collections_ui.rs#L209), [`ui/dr-ui/src/collections_ui.rs:2116`](../ui/dr-ui/src/collections_ui.rs#L2116), [`ui/dr-ui/src/collections_ui.rs:2701`](../ui/dr-ui/src/collections_ui.rs#L2701), [`ui/dr-ui/src/collections_ui.rs:2755`](../ui/dr-ui/src/collections_ui.rs#L2755), [`ui/dr-ui/src/collections_ui.rs:3208`](../ui/dr-ui/src/collections_ui.rs#L3208), [`ui/dr-ui/src/collections_ui.rs:3596`](../ui/dr-ui/src/collections_ui.rs#L3596), [`ui/dr-ui/src/collections_ui.rs:3633`](../ui/dr-ui/src/collections_ui.rs#L3633), [`ui/dr-ui/src/collections_ui.rs:414`](../ui/dr-ui/src/collections_ui.rs#L414), [`ui/dr-ui/src/collections_ui.rs:427`](../ui/dr-ui/src/collections_ui.rs#L427), [`ui/dr-ui/src/collections_ui.rs:514`](../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:718`](../ui/dr-ui/src/collections_ui.rs#L718), [`ui/dr-ui/src/collections_ui.rs:738`](../ui/dr-ui/src/collections_ui.rs#L738), [`ui/dr-ui/src/develop.rs:4620`](../ui/dr-ui/src/develop.rs#L4620), [`ui/dr-ui/src/develop.rs:4645`](../ui/dr-ui/src/develop.rs#L4645), [`ui/dr-ui/src/develop.rs:4656`](../ui/dr-ui/src/develop.rs#L4656), [`ui/dr-ui/src/develop.rs:4670`](../ui/dr-ui/src/develop.rs#L4670), [`ui/dr-ui/src/develop.rs:5707`](../ui/dr-ui/src/develop.rs#L5707), [`ui/dr-ui/src/develop.rs:5738`](../ui/dr-ui/src/develop.rs#L5738), [`ui/dr-ui/src/develop.rs:5759`](../ui/dr-ui/src/develop.rs#L5759), [`ui/dr-ui/src/gesture_book.rs:3`](../ui/dr-ui/src/gesture_book.rs#L3), [`ui/dr-ui/src/gestures.rs:1`](../ui/dr-ui/src/gestures.rs#L1), [`ui/dr-ui/src/lib.rs:1724`](../ui/dr-ui/src/lib.rs#L1724), [`ui/dr-ui/src/lib.rs:2130`](../ui/dr-ui/src/lib.rs#L2130), [`ui/dr-ui/src/lib.rs:2367`](../ui/dr-ui/src/lib.rs#L2367), [`ui/dr-ui/src/lib.rs:2946`](../ui/dr-ui/src/lib.rs#L2946), [`ui/dr-ui/src/lib.rs:3669`](../ui/dr-ui/src/lib.rs#L3669), [`ui/dr-ui/src/lib.rs:414`](../ui/dr-ui/src/lib.rs#L414), [`ui/dr-ui/src/lib.rs:443`](../ui/dr-ui/src/lib.rs#L443), [`ui/dr-ui/src/library_ui.rs:5480`](../ui/dr-ui/src/library_ui.rs#L5480), [`ui/dr-ui/src/library_ui.rs:5492`](../ui/dr-ui/src/library_ui.rs#L5492), [`ui/dr-ui/src/library_ui.rs:5562`](../ui/dr-ui/src/library_ui.rs#L5562), [`ui/dr-ui/src/library_ui.rs:5672`](../ui/dr-ui/src/library_ui.rs#L5672), [`ui/dr-ui/src/library_ui.rs:5700`](../ui/dr-ui/src/library_ui.rs#L5700), [`ui/dr-ui/src/library_ui.rs:6584`](../ui/dr-ui/src/library_ui.rs#L6584), [`ui/dr-ui/ui/app.slint:2074`](../ui/dr-ui/ui/app.slint#L2074), [`ui/dr-ui/ui/app.slint:2258`](../ui/dr-ui/ui/app.slint#L2258), [`ui/dr-ui/ui/app.slint:2524`](../ui/dr-ui/ui/app.slint#L2524), [`ui/dr-ui/ui/app.slint:2635`](../ui/dr-ui/ui/app.slint#L2635), [`ui/dr-ui/ui/app.slint:276`](../ui/dr-ui/ui/app.slint#L276), [`ui/dr-ui/ui/app.slint:3271`](../ui/dr-ui/ui/app.slint#L3271), [`ui/dr-ui/ui/app.slint:672`](../ui/dr-ui/ui/app.slint#L672), [`ui/dr-ui/ui/app.slint:701`](../ui/dr-ui/ui/app.slint#L701), [`ui/dr-ui/ui/app.slint:710`](../ui/dr-ui/ui/app.slint#L710), [`ui/dr-ui/ui/app.slint:810`](../ui/dr-ui/ui/app.slint#L810), [`ui/dr-ui/ui/collections.slint:75`](../ui/dr-ui/ui/collections.slint#L75), [`ui/dr-ui/ui/gestures.slint:1`](../ui/dr-ui/ui/gestures.slint#L1), [`ui/dr-ui/ui/library.slint:1053`](../ui/dr-ui/ui/library.slint#L1053), [`ui/dr-ui/ui/library.slint:1262`](../ui/dr-ui/ui/library.slint#L1262), [`ui/dr-ui/ui/library.slint:1287`](../ui/dr-ui/ui/library.slint#L1287), [`ui/dr-ui/ui/library.slint:1294`](../ui/dr-ui/ui/library.slint#L1294), [`ui/dr-ui/ui/library.slint:1327`](../ui/dr-ui/ui/library.slint#L1327), [`ui/dr-ui/ui/library.slint:1384`](../ui/dr-ui/ui/library.slint#L1384), [`ui/dr-ui/ui/library.slint:1391`](../ui/dr-ui/ui/library.slint#L1391), [`ui/dr-ui/ui/library.slint:2581`](../ui/dr-ui/ui/library.slint#L2581), [`ui/dr-ui/ui/library.slint:4179`](../ui/dr-ui/ui/library.slint#L4179), [`ui/dr-ui/ui/library.slint:4257`](../ui/dr-ui/ui/library.slint#L4257), [`ui/dr-ui/ui/library.slint:4316`](../ui/dr-ui/ui/library.slint#L4316), [`ui/dr-ui/ui/library.slint:965`](../ui/dr-ui/ui/library.slint#L965), [`ui/dr-ui/ui/library.slint:992`](../ui/dr-ui/ui/library.slint#L992), [`ui/dr-ui/ui/library.slint:998`](../ui/dr-ui/ui/library.slint#L998) | -| FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2991`](../ui/dr-ui/src/lib.rs#L2991), [`ui/dr-ui/src/lib.rs:3629`](../ui/dr-ui/src/lib.rs#L3629), [`ui/dr-ui/src/lib.rs:3820`](../ui/dr-ui/src/lib.rs#L3820), [`ui/dr-ui/src/masks_ui.rs:998`](../ui/dr-ui/src/masks_ui.rs#L998), [`ui/dr-ui/ui/app.slint:128`](../ui/dr-ui/ui/app.slint#L128), [`ui/dr-ui/ui/app.slint:2484`](../ui/dr-ui/ui/app.slint#L2484), [`ui/dr-ui/ui/app.slint:2524`](../ui/dr-ui/ui/app.slint#L2524), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | +| FR-UI-1 | [`core/dr-types/src/settings.rs:1166`](../core/dr-types/src/settings.rs#L1166), [`core/dr-types/src/settings.rs:321`](../core/dr-types/src/settings.rs#L321), [`core/dr-types/src/settings.rs:341`](../core/dr-types/src/settings.rs#L341), [`ui/dr-ui/src/lib.rs:101`](../ui/dr-ui/src/lib.rs#L101), [`ui/dr-ui/src/lib.rs:108`](../ui/dr-ui/src/lib.rs#L108), [`ui/dr-ui/src/lib.rs:2460`](../ui/dr-ui/src/lib.rs#L2460), [`ui/dr-ui/src/lib.rs:3563`](../ui/dr-ui/src/lib.rs#L3563), [`ui/dr-ui/src/lib.rs:3582`](../ui/dr-ui/src/lib.rs#L3582), [`ui/dr-ui/src/masks_ui.rs:1215`](../ui/dr-ui/src/masks_ui.rs#L1215), [`ui/dr-ui/src/settings_ui.rs:160`](../ui/dr-ui/src/settings_ui.rs#L160), [`ui/dr-ui/ui/app.slint:2742`](../ui/dr-ui/ui/app.slint#L2742), [`ui/dr-ui/ui/app.slint:787`](../ui/dr-ui/ui/app.slint#L787), [`ui/dr-ui/ui/app.slint:956`](../ui/dr-ui/ui/app.slint#L956), [`ui/dr-ui/ui/identity.slint:188`](../ui/dr-ui/ui/identity.slint#L188), [`ui/dr-ui/ui/library.slint:1076`](../ui/dr-ui/ui/library.slint#L1076), [`ui/dr-ui/ui/settings.slint:104`](../ui/dr-ui/ui/settings.slint#L104), [`ui/dr-ui/ui/settings.slint:565`](../ui/dr-ui/ui/settings.slint#L565), [`ui/dr-ui/ui/toolrail.slint:156`](../ui/dr-ui/ui/toolrail.slint#L156), [`ui/dr-ui/ui/toolrail.slint:89`](../ui/dr-ui/ui/toolrail.slint#L89) | +| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:1045`](../ui/dr-ui/src/collections_ui.rs#L1045), [`ui/dr-ui/src/collections_ui.rs:1055`](../ui/dr-ui/src/collections_ui.rs#L1055), [`ui/dr-ui/src/collections_ui.rs:116`](../ui/dr-ui/src/collections_ui.rs#L116), [`ui/dr-ui/src/collections_ui.rs:154`](../ui/dr-ui/src/collections_ui.rs#L154), [`ui/dr-ui/src/collections_ui.rs:1552`](../ui/dr-ui/src/collections_ui.rs#L1552), [`ui/dr-ui/src/collections_ui.rs:1569`](../ui/dr-ui/src/collections_ui.rs#L1569), [`ui/dr-ui/src/collections_ui.rs:1821`](../ui/dr-ui/src/collections_ui.rs#L1821), [`ui/dr-ui/src/collections_ui.rs:2926`](../ui/dr-ui/src/collections_ui.rs#L2926), [`ui/dr-ui/src/gestures.rs:1`](../ui/dr-ui/src/gestures.rs#L1), [`ui/dr-ui/src/lib.rs:101`](../ui/dr-ui/src/lib.rs#L101), [`ui/dr-ui/src/library_ui.rs:360`](../ui/dr-ui/src/library_ui.rs#L360), [`ui/dr-ui/src/library_ui.rs:6457`](../ui/dr-ui/src/library_ui.rs#L6457), [`ui/dr-ui/src/library_ui.rs:6602`](../ui/dr-ui/src/library_ui.rs#L6602), [`ui/dr-ui/src/library_ui.rs:7817`](../ui/dr-ui/src/library_ui.rs#L7817), [`ui/dr-ui/ui/app.slint:1038`](../ui/dr-ui/ui/app.slint#L1038), [`ui/dr-ui/ui/app.slint:60`](../ui/dr-ui/ui/app.slint#L60), [`ui/dr-ui/ui/app.slint:652`](../ui/dr-ui/ui/app.slint#L652), [`ui/dr-ui/ui/app.slint:666`](../ui/dr-ui/ui/app.slint#L666), [`ui/dr-ui/ui/controls.slint:605`](../ui/dr-ui/ui/controls.slint#L605), [`ui/dr-ui/ui/gestures.slint:1`](../ui/dr-ui/ui/gestures.slint#L1), [`ui/dr-ui/ui/library.slint:1287`](../ui/dr-ui/ui/library.slint#L1287), [`ui/dr-ui/ui/library.slint:1294`](../ui/dr-ui/ui/library.slint#L1294), [`ui/dr-ui/ui/library.slint:1391`](../ui/dr-ui/ui/library.slint#L1391), [`ui/dr-ui/ui/library.slint:2575`](../ui/dr-ui/ui/library.slint#L2575), [`ui/dr-ui/ui/library.slint:4289`](../ui/dr-ui/ui/library.slint#L4289), [`ui/dr-ui/ui/library.slint:965`](../ui/dr-ui/ui/library.slint#L965), [`ui/dr-ui/ui/library.slint:998`](../ui/dr-ui/ui/library.slint#L998), [`ui/dr-ui/ui/presets.slint:19`](../ui/dr-ui/ui/presets.slint#L19), [`ui/dr-ui/ui/settings.slint:118`](../ui/dr-ui/ui/settings.slint#L118) | +| FR-UI-3 | [`ui/dr-ui/src/develop.rs:2562`](../ui/dr-ui/src/develop.rs#L2562), [`ui/dr-ui/src/develop.rs:2931`](../ui/dr-ui/src/develop.rs#L2931), [`ui/dr-ui/src/library_ui.rs:5486`](../ui/dr-ui/src/library_ui.rs#L5486), [`ui/dr-ui/src/masks_ui.rs:1307`](../ui/dr-ui/src/masks_ui.rs#L1307), [`ui/dr-ui/src/masks_ui.rs:1329`](../ui/dr-ui/src/masks_ui.rs#L1329), [`ui/dr-ui/src/masks_ui.rs:290`](../ui/dr-ui/src/masks_ui.rs#L290), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/ui/app.slint:2307`](../ui/dr-ui/ui/app.slint#L2307), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:747`](../ui/dr-ui/ui/collections.slint#L747), [`ui/dr-ui/ui/masks.slint:961`](../ui/dr-ui/ui/masks.slint#L961), [`ui/dr-ui/ui/widgets.slint:71`](../ui/dr-ui/ui/widgets.slint#L71) | +| FR-UI-4 | [`tools/traceability/src/gestures.rs:1`](../tools/traceability/src/gestures.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1045`](../ui/dr-ui/src/collections_ui.rs#L1045), [`ui/dr-ui/src/collections_ui.rs:1055`](../ui/dr-ui/src/collections_ui.rs#L1055), [`ui/dr-ui/src/collections_ui.rs:116`](../ui/dr-ui/src/collections_ui.rs#L116), [`ui/dr-ui/src/collections_ui.rs:117`](../ui/dr-ui/src/collections_ui.rs#L117), [`ui/dr-ui/src/collections_ui.rs:126`](../ui/dr-ui/src/collections_ui.rs#L126), [`ui/dr-ui/src/collections_ui.rs:154`](../ui/dr-ui/src/collections_ui.rs#L154), [`ui/dr-ui/src/collections_ui.rs:1552`](../ui/dr-ui/src/collections_ui.rs#L1552), [`ui/dr-ui/src/collections_ui.rs:1569`](../ui/dr-ui/src/collections_ui.rs#L1569), [`ui/dr-ui/src/collections_ui.rs:1685`](../ui/dr-ui/src/collections_ui.rs#L1685), [`ui/dr-ui/src/collections_ui.rs:1821`](../ui/dr-ui/src/collections_ui.rs#L1821), [`ui/dr-ui/src/collections_ui.rs:1848`](../ui/dr-ui/src/collections_ui.rs#L1848), [`ui/dr-ui/src/collections_ui.rs:1964`](../ui/dr-ui/src/collections_ui.rs#L1964), [`ui/dr-ui/src/collections_ui.rs:2926`](../ui/dr-ui/src/collections_ui.rs#L2926), [`ui/dr-ui/src/collections_ui.rs:2963`](../ui/dr-ui/src/collections_ui.rs#L2963), [`ui/dr-ui/src/collections_ui.rs:365`](../ui/dr-ui/src/collections_ui.rs#L365), [`ui/dr-ui/src/collections_ui.rs:569`](../ui/dr-ui/src/collections_ui.rs#L569), [`ui/dr-ui/src/collections_ui.rs:589`](../ui/dr-ui/src/collections_ui.rs#L589), [`ui/dr-ui/src/develop.rs:4922`](../ui/dr-ui/src/develop.rs#L4922), [`ui/dr-ui/src/develop.rs:4947`](../ui/dr-ui/src/develop.rs#L4947), [`ui/dr-ui/src/develop.rs:4958`](../ui/dr-ui/src/develop.rs#L4958), [`ui/dr-ui/src/develop.rs:4972`](../ui/dr-ui/src/develop.rs#L4972), [`ui/dr-ui/src/develop.rs:6009`](../ui/dr-ui/src/develop.rs#L6009), [`ui/dr-ui/src/develop.rs:6040`](../ui/dr-ui/src/develop.rs#L6040), [`ui/dr-ui/src/develop.rs:6061`](../ui/dr-ui/src/develop.rs#L6061), [`ui/dr-ui/src/gesture_book.rs:3`](../ui/dr-ui/src/gesture_book.rs#L3), [`ui/dr-ui/src/gestures.rs:1`](../ui/dr-ui/src/gestures.rs#L1), [`ui/dr-ui/src/lib.rs:1724`](../ui/dr-ui/src/lib.rs#L1724), [`ui/dr-ui/src/lib.rs:2130`](../ui/dr-ui/src/lib.rs#L2130), [`ui/dr-ui/src/lib.rs:2367`](../ui/dr-ui/src/lib.rs#L2367), [`ui/dr-ui/src/lib.rs:2946`](../ui/dr-ui/src/lib.rs#L2946), [`ui/dr-ui/src/lib.rs:3669`](../ui/dr-ui/src/lib.rs#L3669), [`ui/dr-ui/src/lib.rs:414`](../ui/dr-ui/src/lib.rs#L414), [`ui/dr-ui/src/lib.rs:443`](../ui/dr-ui/src/lib.rs#L443), [`ui/dr-ui/src/library_ui.rs:5486`](../ui/dr-ui/src/library_ui.rs#L5486), [`ui/dr-ui/src/library_ui.rs:5498`](../ui/dr-ui/src/library_ui.rs#L5498), [`ui/dr-ui/src/library_ui.rs:5568`](../ui/dr-ui/src/library_ui.rs#L5568), [`ui/dr-ui/src/library_ui.rs:5678`](../ui/dr-ui/src/library_ui.rs#L5678), [`ui/dr-ui/src/library_ui.rs:5706`](../ui/dr-ui/src/library_ui.rs#L5706), [`ui/dr-ui/src/library_ui.rs:6590`](../ui/dr-ui/src/library_ui.rs#L6590), [`ui/dr-ui/src/library_ui.rs:6602`](../ui/dr-ui/src/library_ui.rs#L6602), [`ui/dr-ui/ui/app.slint:2028`](../ui/dr-ui/ui/app.slint#L2028), [`ui/dr-ui/ui/app.slint:2265`](../ui/dr-ui/ui/app.slint#L2265), [`ui/dr-ui/ui/app.slint:2531`](../ui/dr-ui/ui/app.slint#L2531), [`ui/dr-ui/ui/app.slint:2642`](../ui/dr-ui/ui/app.slint#L2642), [`ui/dr-ui/ui/app.slint:275`](../ui/dr-ui/ui/app.slint#L275), [`ui/dr-ui/ui/app.slint:628`](../ui/dr-ui/ui/app.slint#L628), [`ui/dr-ui/ui/app.slint:657`](../ui/dr-ui/ui/app.slint#L657), [`ui/dr-ui/ui/app.slint:666`](../ui/dr-ui/ui/app.slint#L666), [`ui/dr-ui/ui/app.slint:754`](../ui/dr-ui/ui/app.slint#L754), [`ui/dr-ui/ui/gestures.slint:1`](../ui/dr-ui/ui/gestures.slint#L1), [`ui/dr-ui/ui/library.slint:1053`](../ui/dr-ui/ui/library.slint#L1053), [`ui/dr-ui/ui/library.slint:1262`](../ui/dr-ui/ui/library.slint#L1262), [`ui/dr-ui/ui/library.slint:1287`](../ui/dr-ui/ui/library.slint#L1287), [`ui/dr-ui/ui/library.slint:1294`](../ui/dr-ui/ui/library.slint#L1294), [`ui/dr-ui/ui/library.slint:1327`](../ui/dr-ui/ui/library.slint#L1327), [`ui/dr-ui/ui/library.slint:1384`](../ui/dr-ui/ui/library.slint#L1384), [`ui/dr-ui/ui/library.slint:1391`](../ui/dr-ui/ui/library.slint#L1391), [`ui/dr-ui/ui/library.slint:2575`](../ui/dr-ui/ui/library.slint#L2575), [`ui/dr-ui/ui/library.slint:4173`](../ui/dr-ui/ui/library.slint#L4173), [`ui/dr-ui/ui/library.slint:4289`](../ui/dr-ui/ui/library.slint#L4289), [`ui/dr-ui/ui/library.slint:965`](../ui/dr-ui/ui/library.slint#L965), [`ui/dr-ui/ui/library.slint:992`](../ui/dr-ui/ui/library.slint#L992), [`ui/dr-ui/ui/library.slint:998`](../ui/dr-ui/ui/library.slint#L998) | +| FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2991`](../ui/dr-ui/src/lib.rs#L2991), [`ui/dr-ui/src/lib.rs:3629`](../ui/dr-ui/src/lib.rs#L3629), [`ui/dr-ui/src/lib.rs:3820`](../ui/dr-ui/src/lib.rs#L3820), [`ui/dr-ui/src/masks_ui.rs:1262`](../ui/dr-ui/src/masks_ui.rs#L1262), [`ui/dr-ui/ui/app.slint:127`](../ui/dr-ui/ui/app.slint#L127), [`ui/dr-ui/ui/app.slint:2491`](../ui/dr-ui/ui/app.slint#L2491), [`ui/dr-ui/ui/app.slint:2531`](../ui/dr-ui/ui/app.slint#L2531), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | | FR-UI-6 | [`ui/dr-ui/ui/widgets.slint:1`](../ui/dr-ui/ui/widgets.slint#L1) | -| FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:195`](../core/dr-pipeline/src/descriptor.rs#L195), [`core/dr-pipeline/src/framing.rs:395`](../core/dr-pipeline/src/framing.rs#L395), [`core/dr-types/src/settings.rs:321`](../core/dr-types/src/settings.rs#L321), [`ui/dr-ui/src/develop.rs:1200`](../ui/dr-ui/src/develop.rs#L1200), [`ui/dr-ui/src/lib.rs:3582`](../ui/dr-ui/src/lib.rs#L3582), [`ui/dr-ui/ui/app.slint:2161`](../ui/dr-ui/ui/app.slint#L2161), [`ui/dr-ui/ui/app.slint:843`](../ui/dr-ui/ui/app.slint#L843), [`ui/dr-ui/ui/settings.slint:565`](../ui/dr-ui/ui/settings.slint#L565), [`ui/dr-ui/ui/toolrail.slint:89`](../ui/dr-ui/ui/toolrail.slint#L89) | -| FR-UI-8 | [`core/dr-catalog/src/collections.rs:654`](../core/dr-catalog/src/collections.rs#L654), [`core/dr-catalog/src/collections.rs:675`](../core/dr-catalog/src/collections.rs#L675), [`core/dr-types/src/place.rs:1`](../core/dr-types/src/place.rs#L1), [`ui/dr-ui/src/derived_sync.rs:188`](../ui/dr-ui/src/derived_sync.rs#L188), [`ui/dr-ui/src/derived_sync.rs:657`](../ui/dr-ui/src/derived_sync.rs#L657), [`ui/dr-ui/src/derived_sync.rs:667`](../ui/dr-ui/src/derived_sync.rs#L667), [`ui/dr-ui/src/derived_sync.rs:749`](../ui/dr-ui/src/derived_sync.rs#L749), [`ui/dr-ui/src/derived_sync.rs:74`](../ui/dr-ui/src/derived_sync.rs#L74), [`ui/dr-ui/src/library.rs:1106`](../ui/dr-ui/src/library.rs#L1106), [`ui/dr-ui/src/library.rs:4556`](../ui/dr-ui/src/library.rs#L4556), [`ui/dr-ui/src/library.rs:5274`](../ui/dr-ui/src/library.rs#L5274), [`ui/dr-ui/src/library.rs:5340`](../ui/dr-ui/src/library.rs#L5340), [`ui/dr-ui/src/library_ui.rs:195`](../ui/dr-ui/src/library_ui.rs#L195), [`ui/dr-ui/src/library_ui.rs:2112`](../ui/dr-ui/src/library_ui.rs#L2112), [`ui/dr-ui/src/library_ui.rs:2301`](../ui/dr-ui/src/library_ui.rs#L2301), [`ui/dr-ui/src/library_ui.rs:2318`](../ui/dr-ui/src/library_ui.rs#L2318), [`ui/dr-ui/src/library_ui.rs:2416`](../ui/dr-ui/src/library_ui.rs#L2416), [`ui/dr-ui/src/library_ui.rs:5513`](../ui/dr-ui/src/library_ui.rs#L5513), [`ui/dr-ui/src/library_ui.rs:5553`](../ui/dr-ui/src/library_ui.rs#L5553), [`ui/dr-ui/src/library_ui.rs:5582`](../ui/dr-ui/src/library_ui.rs#L5582), [`ui/dr-ui/src/library_ui.rs:5845`](../ui/dr-ui/src/library_ui.rs#L5845), [`ui/dr-ui/src/library_ui.rs:6068`](../ui/dr-ui/src/library_ui.rs#L6068), [`ui/dr-ui/src/library_ui.rs:6792`](../ui/dr-ui/src/library_ui.rs#L6792), [`ui/dr-ui/src/library_ui.rs:853`](../ui/dr-ui/src/library_ui.rs#L853), [`ui/dr-ui/src/library_ui.rs:886`](../ui/dr-ui/src/library_ui.rs#L886), [`ui/dr-ui/src/library_ui.rs:932`](../ui/dr-ui/src/library_ui.rs#L932), [`ui/dr-ui/src/place.rs:1`](../ui/dr-ui/src/place.rs#L1) | -| NFR-A11Y-2 | [`ui/dr-ui/tests/ui_controls_are_accessible.rs:1`](../ui/dr-ui/tests/ui_controls_are_accessible.rs#L1), [`ui/dr-ui/ui/adjust.slint:170`](../ui/dr-ui/ui/adjust.slint#L170), [`ui/dr-ui/ui/app.slint:2635`](../ui/dr-ui/ui/app.slint#L2635), [`ui/dr-ui/ui/app.slint:2673`](../ui/dr-ui/ui/app.slint#L2673) | +| FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:195`](../core/dr-pipeline/src/descriptor.rs#L195), [`core/dr-pipeline/src/framing.rs:395`](../core/dr-pipeline/src/framing.rs#L395), [`core/dr-types/src/settings.rs:321`](../core/dr-types/src/settings.rs#L321), [`ui/dr-ui/src/develop.rs:1231`](../ui/dr-ui/src/develop.rs#L1231), [`ui/dr-ui/src/lib.rs:3582`](../ui/dr-ui/src/lib.rs#L3582), [`ui/dr-ui/ui/app.slint:2168`](../ui/dr-ui/ui/app.slint#L2168), [`ui/dr-ui/ui/app.slint:787`](../ui/dr-ui/ui/app.slint#L787), [`ui/dr-ui/ui/settings.slint:565`](../ui/dr-ui/ui/settings.slint#L565), [`ui/dr-ui/ui/toolrail.slint:89`](../ui/dr-ui/ui/toolrail.slint#L89) | +| FR-UI-8 | [`core/dr-catalog/src/collections.rs:555`](../core/dr-catalog/src/collections.rs#L555), [`core/dr-catalog/src/collections.rs:576`](../core/dr-catalog/src/collections.rs#L576), [`core/dr-types/src/place.rs:1`](../core/dr-types/src/place.rs#L1), [`ui/dr-ui/src/derived_sync.rs:188`](../ui/dr-ui/src/derived_sync.rs#L188), [`ui/dr-ui/src/derived_sync.rs:657`](../ui/dr-ui/src/derived_sync.rs#L657), [`ui/dr-ui/src/derived_sync.rs:667`](../ui/dr-ui/src/derived_sync.rs#L667), [`ui/dr-ui/src/derived_sync.rs:749`](../ui/dr-ui/src/derived_sync.rs#L749), [`ui/dr-ui/src/derived_sync.rs:74`](../ui/dr-ui/src/derived_sync.rs#L74), [`ui/dr-ui/src/library.rs:1106`](../ui/dr-ui/src/library.rs#L1106), [`ui/dr-ui/src/library.rs:4556`](../ui/dr-ui/src/library.rs#L4556), [`ui/dr-ui/src/library.rs:5274`](../ui/dr-ui/src/library.rs#L5274), [`ui/dr-ui/src/library.rs:5340`](../ui/dr-ui/src/library.rs#L5340), [`ui/dr-ui/src/library_ui.rs:195`](../ui/dr-ui/src/library_ui.rs#L195), [`ui/dr-ui/src/library_ui.rs:2118`](../ui/dr-ui/src/library_ui.rs#L2118), [`ui/dr-ui/src/library_ui.rs:2307`](../ui/dr-ui/src/library_ui.rs#L2307), [`ui/dr-ui/src/library_ui.rs:2324`](../ui/dr-ui/src/library_ui.rs#L2324), [`ui/dr-ui/src/library_ui.rs:2422`](../ui/dr-ui/src/library_ui.rs#L2422), [`ui/dr-ui/src/library_ui.rs:5519`](../ui/dr-ui/src/library_ui.rs#L5519), [`ui/dr-ui/src/library_ui.rs:5559`](../ui/dr-ui/src/library_ui.rs#L5559), [`ui/dr-ui/src/library_ui.rs:5588`](../ui/dr-ui/src/library_ui.rs#L5588), [`ui/dr-ui/src/library_ui.rs:5851`](../ui/dr-ui/src/library_ui.rs#L5851), [`ui/dr-ui/src/library_ui.rs:6074`](../ui/dr-ui/src/library_ui.rs#L6074), [`ui/dr-ui/src/library_ui.rs:6831`](../ui/dr-ui/src/library_ui.rs#L6831), [`ui/dr-ui/src/library_ui.rs:859`](../ui/dr-ui/src/library_ui.rs#L859), [`ui/dr-ui/src/library_ui.rs:892`](../ui/dr-ui/src/library_ui.rs#L892), [`ui/dr-ui/src/library_ui.rs:938`](../ui/dr-ui/src/library_ui.rs#L938), [`ui/dr-ui/src/place.rs:1`](../ui/dr-ui/src/place.rs#L1) | +| NFR-A11Y-2 | [`ui/dr-ui/tests/ui_controls_are_accessible.rs:1`](../ui/dr-ui/tests/ui_controls_are_accessible.rs#L1), [`ui/dr-ui/ui/adjust.slint:170`](../ui/dr-ui/ui/adjust.slint#L170), [`ui/dr-ui/ui/app.slint:2642`](../ui/dr-ui/ui/app.slint#L2642), [`ui/dr-ui/ui/app.slint:2680`](../ui/dr-ui/ui/app.slint#L2680) | | NFR-A11Y-3 | [`ui/dr-ui/src/histogram.rs:356`](../ui/dr-ui/src/histogram.rs#L356), [`ui/dr-ui/ui/histogram.slint:137`](../ui/dr-ui/ui/histogram.slint#L137), [`ui/dr-ui/ui/library.slint:761`](../ui/dr-ui/ui/library.slint#L761), [`ui/dr-ui/ui/library.slint:909`](../ui/dr-ui/ui/library.slint#L909), [`ui/dr-ui/ui/peaking.slint:1`](../ui/dr-ui/ui/peaking.slint#L1) | | NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:3385`](../ui/dr-ui/src/library.rs#L3385) | -| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1809`](../ui/dr-ui/src/export.rs#L1809), [`ui/dr-ui/src/export.rs:1836`](../ui/dr-ui/src/export.rs#L1836), [`ui/dr-ui/src/export.rs:412`](../ui/dr-ui/src/export.rs#L412), [`ui/dr-ui/src/export.rs:438`](../ui/dr-ui/src/export.rs#L438), [`ui/dr-ui/src/lib.rs:2594`](../ui/dr-ui/src/lib.rs#L2594), [`ui/dr-ui/ui/app.slint:1276`](../ui/dr-ui/ui/app.slint#L1276), [`ui/dr-ui/ui/library.slint:4278`](../ui/dr-ui/ui/library.slint#L4278) | +| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1809`](../ui/dr-ui/src/export.rs#L1809), [`ui/dr-ui/src/export.rs:1836`](../ui/dr-ui/src/export.rs#L1836), [`ui/dr-ui/src/export.rs:412`](../ui/dr-ui/src/export.rs#L412), [`ui/dr-ui/src/export.rs:438`](../ui/dr-ui/src/export.rs#L438), [`ui/dr-ui/src/lib.rs:2594`](../ui/dr-ui/src/lib.rs#L2594), [`ui/dr-ui/ui/app.slint:1248`](../ui/dr-ui/ui/app.slint#L1248), [`ui/dr-ui/ui/library.slint:4251`](../ui/dr-ui/ui/library.slint#L4251) | | NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-export/src/error.rs:1`](../core/dr-export/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1), [`core/dr-xmp/src/lib.rs:798`](../core/dr-xmp/src/lib.rs#L798), [`platform/dr-plat/src/storage.rs:148`](../platform/dr-plat/src/storage.rs#L148), [`ui/dr-ui/src/export.rs:514`](../ui/dr-ui/src/export.rs#L514) | | NFR-OPS-1 | [`platform/dr-plat/src/diagnostics.rs:1`](../platform/dr-plat/src/diagnostics.rs#L1), [`platform/dr-plat/src/state.rs:1`](../platform/dr-plat/src/state.rs#L1) | | NFR-OPS-2 | [`platform/dr-plat/src/crash.rs:1`](../platform/dr-plat/src/crash.rs#L1) | | NFR-OPS-3 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | | NFR-P1 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`tools/bench/src/catalog_open.rs:1`](../tools/bench/src/catalog_open.rs#L1) | | NFR-P13 | [`core/dr-decode/src/preview.rs:121`](../core/dr-decode/src/preview.rs#L121) | -| NFR-P14 | [`core/dr-gpu/src/focus.rs:186`](../core/dr-gpu/src/focus.rs#L186), [`core/dr-gpu/src/focus.rs:1`](../core/dr-gpu/src/focus.rs#L1), [`core/dr-gpu/src/focus.rs:317`](../core/dr-gpu/src/focus.rs#L317), [`core/dr-gpu/src/shaders/focus_peak.wgsl:1`](../core/dr-gpu/src/shaders/focus_peak.wgsl#L1), [`ui/dr-ui/src/develop.rs:3570`](../ui/dr-ui/src/develop.rs#L3570), [`ui/dr-ui/src/lib.rs:1916`](../ui/dr-ui/src/lib.rs#L1916) | +| NFR-P14 | [`core/dr-gpu/src/focus.rs:186`](../core/dr-gpu/src/focus.rs#L186), [`core/dr-gpu/src/focus.rs:1`](../core/dr-gpu/src/focus.rs#L1), [`core/dr-gpu/src/focus.rs:317`](../core/dr-gpu/src/focus.rs#L317), [`core/dr-gpu/src/shaders/focus_peak.wgsl:1`](../core/dr-gpu/src/shaders/focus_peak.wgsl#L1), [`ui/dr-ui/src/develop.rs:3872`](../ui/dr-ui/src/develop.rs#L3872), [`ui/dr-ui/src/lib.rs:1916`](../ui/dr-ui/src/lib.rs#L1916) | | NFR-P3 | [`tools/bench/src/thumbnails.rs:1`](../tools/bench/src/thumbnails.rs#L1) | -| NFR-P5 | [`core/dr-catalog/src/schema.rs:356`](../core/dr-catalog/src/schema.rs#L356), [`ui/dr-ui/src/library.rs:4663`](../ui/dr-ui/src/library.rs#L4663), [`ui/dr-ui/src/library.rs:5776`](../ui/dr-ui/src/library.rs#L5776), [`ui/dr-ui/src/library_ui.rs:4720`](../ui/dr-ui/src/library_ui.rs#L4720), [`ui/dr-ui/src/library_ui.rs:64`](../ui/dr-ui/src/library_ui.rs#L64) | +| NFR-P5 | [`core/dr-catalog/src/schema.rs:356`](../core/dr-catalog/src/schema.rs#L356), [`ui/dr-ui/src/library.rs:4663`](../ui/dr-ui/src/library.rs#L4663), [`ui/dr-ui/src/library.rs:5776`](../ui/dr-ui/src/library.rs#L5776), [`ui/dr-ui/src/library_ui.rs:4726`](../ui/dr-ui/src/library_ui.rs#L4726), [`ui/dr-ui/src/library_ui.rs:64`](../ui/dr-ui/src/library_ui.rs#L64) | | NFR-P9 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/export.rs:992`](../ui/dr-ui/src/export.rs#L992), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1) | | NFR-PORT-1 | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:272`](../core/dr-types/src/lib.rs#L272), [`core/dr-types/src/lib.rs:305`](../core/dr-types/src/lib.rs#L305), [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`platform/dr-plat/src/storage.rs:287`](../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:344`](../platform/dr-plat/src/storage.rs#L344), [`platform/dr-plat/src/volumes.rs:1`](../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62) | | NFR-PORT-2 | [`core/dr-gpu/src/lib.rs:1`](../core/dr-gpu/src/lib.rs#L1) | @@ -154,7 +156,7 @@ _None._ | NFR-R6 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/lib.rs:255`](../core/dr-catalog/src/lib.rs#L255), [`core/dr-catalog/src/recovery.rs:1`](../core/dr-catalog/src/recovery.rs#L1) | | NFR-R7 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) | | NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) | -| NFR-RES-1 | [`core/dr-gpu/src/adjust.rs:1029`](../core/dr-gpu/src/adjust.rs#L1029), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`ui/dr-ui/src/develop.rs:3853`](../ui/dr-ui/src/develop.rs#L3853), [`ui/dr-ui/src/identity_ui.rs:121`](../ui/dr-ui/src/identity_ui.rs#L121), [`ui/dr-ui/src/lib.rs:93`](../ui/dr-ui/src/lib.rs#L93), [`ui/dr-ui/src/memory.rs:1`](../ui/dr-ui/src/memory.rs#L1) | +| NFR-RES-1 | [`core/dr-gpu/src/adjust.rs:1029`](../core/dr-gpu/src/adjust.rs#L1029), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`ui/dr-ui/src/develop.rs:4155`](../ui/dr-ui/src/develop.rs#L4155), [`ui/dr-ui/src/identity_ui.rs:121`](../ui/dr-ui/src/identity_ui.rs#L121), [`ui/dr-ui/src/lib.rs:93`](../ui/dr-ui/src/lib.rs#L93), [`ui/dr-ui/src/memory.rs:1`](../ui/dr-ui/src/memory.rs#L1) | | NFR-RES-2 | [`ui/dr-ui/src/faces.rs:346`](../ui/dr-ui/src/faces.rs#L346), [`ui/dr-ui/src/library.rs:3385`](../ui/dr-ui/src/library.rs#L3385), [`ui/dr-ui/src/library.rs:3576`](../ui/dr-ui/src/library.rs#L3576) | | NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/schema.rs:785`](../core/dr-catalog/src/schema.rs#L785), [`core/dr-gpu/src/lib.rs:93`](../core/dr-gpu/src/lib.rs#L93), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/library.rs:3292`](../ui/dr-ui/src/library.rs#L3292) | | NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) | @@ -167,13 +169,14 @@ _None._ ## Not yet tagged -52 of 183 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built. +53 of 186 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
Show untagged requirements - FR-CAT-14 - FR-CULL-6 - FR-CULL-7 +- FR-DEV-19 - FR-DEV-3g - FR-DSP-2 - FR-DSP-4 diff --git a/ui/dr-ui/src/develop.rs b/ui/dr-ui/src/develop.rs index f55b369..8a1b11f 100644 --- a/ui/dr-ui/src/develop.rs +++ b/ui/dr-ui/src/develop.rs @@ -830,6 +830,30 @@ pub struct DevelopSession { /// "make these three subjects a stop darker" is one gesture rather than /// three. Order is insertion order and nothing reads it, only membership. active_masks: Vec, + /// TRACES: FR-DEV-19a + /// Which part of the selected layer the tools and the edge controls point + /// at. Zero — the base — whenever a layer is selected afresh. + /// + /// An index rather than an id, because it addresses a row the panel is + /// already showing by position, and because a gesture that outlived the + /// part it was aimed at would be a stroke landing somewhere nobody asked + /// for. Clamped on the way in and re-checked on the way out. + active_part: usize, + /// TRACES: FR-DEV-19b + /// The layer and part a stroke in progress is going into. + /// + /// Captured on press and held for the gesture: the panel's selection can + /// change under a finger — a stray tap, a sync arriving — and a stroke + /// that changed target half way through would leave half a mark in each. + painting: Option<(String, usize)>, + /// TRACES: FR-DEV-19b + /// The brush: radius as a fraction of the frame's shorter edge, hardness, + /// and flow. + /// + /// On the session rather than on a layer, because it belongs to the + /// *tool*: somebody who sets a small eraser expects it to still be small + /// the next time they erase, whichever mask they are working on. + brush: (f32, f32, f32), /// TRACES: FR-DEV-8 /// Which repair the panel is describing, if any. /// @@ -987,6 +1011,13 @@ impl DevelopSession { subjects: None, subject_key: 0, active_masks: Vec::new(), + active_part: 0, + painting: None, + brush: ( + dr_pipeline::mask::DEFAULT_BRUSH_RADIUS, + dr_pipeline::mask::DEFAULT_BRUSH_HARDNESS, + dr_pipeline::mask::DEFAULT_BRUSH_FLOW, + ), selected_spot: None, show_overlay: false, active_tab: None, @@ -1903,17 +1934,17 @@ impl DevelopSession { }; for layer in self.graph.masks().active() { - match &layer.source { + match &layer.base().source { MaskSource::Subject { index, .. } => { mix(1); mix(*index as u64); - if layer.morphology.is_compound() { - mix(match layer.morphology { + if layer.base().morphology.is_compound() { + mix(match layer.base().morphology { Morphology::Close => 2, Morphology::Open => 3, _ => 0, }); - mix(layer.morph_radius.to_bits() as u64); + mix(layer.base().morph_radius.to_bits() as u64); } } // Hashed by *name*, and `4` rather than `1` so a category @@ -1925,13 +1956,13 @@ impl DevelopSession { mix(*b as u64); } // Only the compound operations change the field itself. - if layer.morphology.is_compound() { - mix(match layer.morphology { + if layer.base().morphology.is_compound() { + mix(match layer.base().morphology { Morphology::Close => 2, Morphology::Open => 3, _ => 0, }); - mix(layer.morph_radius.to_bits() as u64); + mix(layer.base().morph_radius.to_bits() as u64); } // The refine control changes which pixels are in the mask // at all, so it changes the coverage the field is measured @@ -1939,7 +1970,7 @@ impl DevelopSession { // is already correct. Omitting it here is the bug where // the slider moves and nothing happens until some other // control happens to invalidate the cache. - mix(layer.refine.to_bits() as u64); + mix(layer.base().refine.to_bits() as u64); } _ => mix(0), } @@ -1975,8 +2006,10 @@ impl DevelopSession { let live = self .segmentation .as_ref() - .and_then(|seg| match &layer.source { - MaskSource::Category { name, .. } => seg.category_mask_at(name, layer.refine), + .and_then(|seg| match &layer.base().source { + MaskSource::Category { name, .. } => { + seg.category_mask_at(name, layer.base().refine) + } MaskSource::Subject { index, .. } => { seg.instance_mask(*index as usize).map(Cow::Borrowed) } @@ -1986,10 +2019,11 @@ impl DevelopSession { return live; } - match layer.source { + match layer.base().source { // Resampled where it was written against a different proxy edge; // in the ordinary case the sizes match and this is the decode. MaskSource::Subject { .. } | MaskSource::Category { .. } => layer + .base() .coverage .as_ref() .map(|stored| Cow::Owned(stored.decode_at(width, height))), @@ -2038,8 +2072,10 @@ impl DevelopSession { let (pw, ph) = seg.proxy_size(); for layer in stack.layers_mut() { - let values = match &layer.source { - MaskSource::Category { name, .. } => seg.category_mask_at(name, layer.refine), + let values = match &layer.base().source { + MaskSource::Category { name, .. } => { + seg.category_mask_at(name, layer.base().refine) + } MaskSource::Subject { index, .. } => { seg.instance_mask(*index as usize).map(Cow::Borrowed) } @@ -2059,7 +2095,8 @@ impl DevelopSession { // would describe the layer at some earlier refine, and a mask of // the wrong shape presented as authoritative is worse than the // honest state, which is that this one needs the model run. - layer.coverage = Coverage::encode(&values, pw, ph, RENDERED_LEVELS).map(Arc::new); + layer.base_mut().coverage = + Coverage::encode(&values, pw, ph, RENDERED_LEVELS).map(Arc::new); } stack @@ -2096,10 +2133,10 @@ impl DevelopSession { pw, ph, 128, - morphology_for(layer.morphology), + morphology_for(layer.base().morphology), // Radii are fractions of the shorter edge; the field // is in proxy pixels. - layer.morph_radius * pw.min(ph) as f32, + layer.base().morph_radius * pw.min(ph) as f32, ) .distance } @@ -2322,7 +2359,7 @@ impl DevelopSession { /// `None` for a layer that is not a subject at all (a gradient has no /// instance to re-detect). pub fn subject_instance_index(&self, id: &str) -> Option { - match &self.graph.masks().get(id)?.source { + match &self.graph.masks().get(id)?.base().source { MaskSource::Subject { index, .. } => Some(*index as usize), _ => None, } @@ -2487,7 +2524,7 @@ impl DevelopSession { /// What kind of mask a layer is — "regions", "linear", "radial". pub fn mask_kind(&self, id: &str) -> &'static str { - self.graph.masks().get(id).map_or("", |l| l.source.kind()) + self.part_of(id).map_or("", |p| p.source.kind()) } pub fn mask_inverted(&self, id: &str) -> bool { @@ -2544,7 +2581,7 @@ impl DevelopSession { return Vec::new(); }; let (sw, sh) = self.demosaiced.size(); - crate::gradient::handles(&layer.source, self.graph.framing(), (sw, sh)) + crate::gradient::handles(&layer.base().source, self.graph.framing(), (sw, sh)) } /// Drag one handle of the selected gradient, from `press` to `now`, both @@ -2569,11 +2606,11 @@ impl DevelopSession { let id = self.active_masks.first()?.clone(); let start = match origin { Some(s) => s.clone(), - None => self.graph.masks().get(&id)?.source.clone(), + None => self.graph.masks().get(&id)?.base().source.clone(), }; let moved = crate::gradient::drag(&start, role, press, now, &framing, (sw, sh)); - self.graph.masks_mut().get_mut(&id)?.source = moved; + self.graph.masks_mut().get_mut(&id)?.base_mut().source = moved; // **Nothing recorded here.** A drag delivers a pointer event a frame, // and a history step per frame would make undo walk a gesture back // pixel by pixel. `Edit` coalesces by operation id and a mask's shape @@ -2591,6 +2628,266 @@ impl DevelopSession { .record(&self.graph, Edit::Action(labels::step::MASK_MOVED)); } + // --- editing a mask by hand (FR-DEV-19) -------------------------------- + + /// TRACES: FR-DEV-19a + /// Which part of `id` the edge controls act on. + /// + /// The selected part when this is the layer being edited, and the base + /// otherwise — because a panel that is not showing a layer's parts has not + /// offered anybody a way to choose one, and answering with a part they + /// cannot see would make the same slider mean different things depending + /// on what was selected a moment ago. + fn shaped_part(&self, id: &str) -> usize { + match self.active_masks.as_slice() { + [only] if only == id => self.active_part, + _ => 0, + } + } + + /// The part of `id` the edge controls read. + fn part_of(&self, id: &str) -> Option<&dr_pipeline::mask::MaskPart> { + let index = self.shaped_part(id); + self.graph.masks().get(id)?.part(index) + } + + /// The same, to write through. + fn part_of_mut(&mut self, id: &str) -> Option<&mut dr_pipeline::mask::MaskPart> { + let index = self.shaped_part(id); + self.graph.masks_mut().get_mut(id)?.part_mut(index) + } + + /// Which part of the selected layer the tools point at. + pub fn active_part(&self) -> usize { + self.active_part + } + + /// Point the tools at one part, or at the base when the index is past the + /// end — which is what a part being removed under the selection leaves. + pub fn set_active_part(&mut self, index: usize) { + let parts = self.active_layer().map_or(1, |l| l.parts().len()); + self.active_part = if index < parts { index } else { 0 }; + } + + /// The parts of a layer: id, what to call it, and how it joins. + /// + /// The join of the first is meaningless — there is nothing before it to + /// join to — and the panel shows it as the selection the layer *is* + /// rather than as a row with a chip that does nothing. + pub fn mask_parts(&self, id: &str) -> Vec<(String, String, usize)> { + let Some(layer) = self.graph.masks().get(id) else { + return Vec::new(); + }; + layer + .parts() + .iter() + .map(|p| { + let join = dr_pipeline::mask::Join::ALL + .iter() + .position(|&j| j == p.join) + .unwrap_or(0); + (p.id.clone(), p.source.kind().to_string(), join) + }) + .collect() + } + + /// TRACES: FR-DEV-19a + /// Join a fresh painted part to a layer, returning its index. + /// + /// Painted, because that is the correction a photographer reaches for + /// first and the only source that needs nothing found for it. The other + /// sources arrive when a part can carry its own distance field. + pub fn add_mask_part(&mut self, id: &str, join: usize) -> Option { + use dr_pipeline::mask::{Join, MaskPart}; + let &join = Join::ALL.get(join)?; + let layer = self.graph.masks_mut().get_mut(id)?; + let part_id = layer.next_part_id(); + if !layer.push_part(MaskPart::painted(part_id, join)) { + return None; + } + let index = layer.parts().len() - 1; + self.active_part = index; + self.history + .record(&self.graph, Edit::Action(labels::step::MASK_PART_ADDED)); + Some(index) + } + + /// Take a part back out of a layer. The base is not removable — removing + /// the selection a layer *is* is removing the layer. + pub fn remove_mask_part(&mut self, id: &str, index: usize) { + let Some(layer) = self.graph.masks_mut().get_mut(id) else { + return; + }; + if layer.remove_part(index).is_none() { + return; + } + self.set_active_part(self.active_part.min(index.saturating_sub(1))); + self.history + .record(&self.graph, Edit::Action(labels::step::MASK_PART_REMOVED)); + } + + /// Change how a part joins: added to the mask, or taken out of it. + pub fn set_mask_part_join(&mut self, id: &str, index: usize, join: usize) { + use dr_pipeline::mask::Join; + let Some(&join) = Join::ALL.get(join) else { + return; + }; + let Some(layer) = self.graph.masks_mut().get_mut(id) else { + return; + }; + // The first part joins nothing, so saying how it joins would be a + // control that moves and changes no pixel. + if index == 0 { + return; + } + let Some(part) = layer.part_mut(index) else { + return; + }; + part.join = join; + self.history + .record(&self.graph, Edit::Action(labels::step::MASK_JOINED)); + } + + /// The brush: radius, hardness, flow. + pub fn brush(&self) -> (f32, f32, f32) { + self.brush + } + + /// Set the brush. Radius is a fraction of the frame's shorter edge, so it + /// means the same thing on the phone and on the desktop and at any zoom. + pub fn set_brush(&mut self, radius: f32, hardness: f32, flow: f32) { + self.brush = ( + radius.clamp(0.002, 0.5), + hardness.clamp(0.0, 1.0), + flow.clamp(0.01, 1.0), + ); + } + + /// How many stroke points the selected layer may still record. + /// + /// Asked by the panel so that a mask approaching its budget can say so + /// before a gesture is refused mid-stroke — which is the moment the + /// refusal is least explicable. + pub fn mask_room(&self) -> usize { + self.active_layer().map_or(0, |l| l.room()) + } + + /// TRACES: FR-DEV-19b + /// Begin a stroke at a point in fractions of the shown image. + /// + /// **Paints into the active part, or joins one if that part cannot hold a + /// stroke.** Pressing Paint on a mask the model made is the ordinary way + /// this is reached, and it must not answer with a refusal explaining that + /// a subject is not a brush: the correction the photographer is about to + /// make *is* a new part, so it is made. + /// + /// Returns whether a stroke was started. `false` means the layer is full + /// or there is nothing selected, and the caller should not send moves. + pub fn begin_mask_stroke(&mut self, x: f32, y: f32, erase: bool) -> bool { + let Some(id) = self.active_masks.first().cloned() else { + return false; + }; + if self.active_masks.len() != 1 { + // Several layers share the slider drags; a stroke has one target + // and guessing which of three it is would be worse than refusing. + return false; + } + + let paintable = self + .graph + .masks() + .get(&id) + .and_then(|l| l.part(self.active_part)) + .is_some_and(|p| matches!(p.source, MaskSource::Brush { .. })); + if !paintable { + use dr_pipeline::mask::Join; + let join = if erase { Join::Subtract } else { Join::Union }; + let position = Join::ALL.iter().position(|&j| j == join).unwrap_or(0); + if self.add_mask_part(&id, position).is_none() { + return false; + } + } + + let part = self.active_part; + let (radius, hardness, flow) = self.brush; + // An erase stroke inside a part that subtracts would take away from + // what the part removes, which reads backwards. In a subtracting part + // the brush's two modes are already the right way round. + let subtracting = self + .graph + .masks() + .get(&id) + .and_then(|l| l.part(part)) + .is_some_and(|p| p.join == dr_pipeline::mask::Join::Subtract); + let erase = erase && !subtracting; + + let Some(layer) = self.graph.masks_mut().get_mut(&id) else { + return false; + }; + if !layer.begin_stroke(part, erase, radius, hardness, flow) { + return false; + } + self.painting = Some((id, part)); + self.extend_mask_stroke(x, y); + true + } + + /// Carry the stroke to another point, in fractions of the shown image. + /// + /// The point is mapped into normalised **source** coordinates on the way + /// in, through the same framing map the shader applies — so a stroke stays + /// on the thing it was painted on through a zoom, a pan, a crop and a + /// straighten, and lands in an export at any size where it was drawn. + pub fn extend_mask_stroke(&mut self, x: f32, y: f32) { + let Some((id, part)) = self.painting.clone() else { + return; + }; + let (sw, sh) = self.demosaiced.size(); + let (sx, sy) = self.graph.framing().source_at((x, y), sw, sh); + if let Some(layer) = self.graph.masks_mut().get_mut(&id) { + layer.extend_stroke(part, sx, sy); + } + } + + /// Finish the stroke: one history step for the whole gesture. + /// + /// One step, on release, for the reason a handle drag records once — a + /// stroke is a decision, and undo that walked it back dab by dab would + /// make taking a mark back cost as many presses as making it did. + pub fn end_mask_stroke(&mut self) { + let Some((id, part)) = self.painting.take() else { + return; + }; + let erased = self + .graph + .masks() + .get(&id) + .and_then(|l| l.part(part)) + .and_then(|p| p.strokes().last()) + .is_some_and(|s| s.erase); + if let Some(layer) = self.graph.masks_mut().get_mut(&id) { + layer.end_stroke(part); + } + let step = if erased { + labels::step::MASK_ERASED + } else { + labels::step::MASK_PAINTED + }; + self.history.record(&self.graph, Edit::Action(step)); + } + + /// Abandon a stroke that turned out to be something else — a pinch, or a + /// gesture the window cancelled. Nothing is recorded, because nothing + /// happened as far as the photographer is concerned. + pub fn cancel_mask_stroke(&mut self) { + let Some((id, part)) = self.painting.take() else { + return; + }; + if let Some(layer) = self.graph.masks_mut().get_mut(&id) { + layer.drop_last_stroke(part); + } + } + // --- repairs (FR-DEV-8) ------------------------------------------------ /// TRACES: FR-DEV-8 @@ -2827,13 +3124,19 @@ impl DevelopSession { /// The selected layer's mask rule, for a caller that has to remember what /// a gesture started from. pub fn active_mask_source(&self) -> Option { - self.active_layer().map(|l| l.source.clone()) + self.active_layer().map(|l| l.base().source.clone()) } /// Select exactly one layer for editing, or `None` to return the panel to /// the global chain. Replaces whatever was selected before, including a /// multi-selection — the ordinary, unmodified click. + /// Selecting a layer points the tools at its base again. + /// + /// Without this, selecting a two-part mask after a three-part one would + /// leave the brush aimed at a part that is not there — or, worse, at a + /// different part than the one highlighted in the panel. pub fn set_active_mask(&mut self, id: Option<&str>) { + self.active_part = 0; self.active_masks = id .filter(|id| self.graph.masks().get(id).is_some()) .map(|id| vec![id.to_string()]) @@ -2916,7 +3219,7 @@ impl DevelopSession { .as_ref() .is_some_and(|seg| seg.category_is_refinable(name)) { - layer.refine = dr_segment::STRICTNESS_DEFAULT; + layer.base_mut().refine = dr_segment::STRICTNESS_DEFAULT; } if !self.graph.masks_mut().push(layer) { return None; @@ -3038,7 +3341,7 @@ impl DevelopSession { /// asks first, and a callback that arrives against a layer the user has /// since replaced is ordinary rather than a fault. pub fn set_mask_band(&mut self, id: &str, lo: f32, hi: f32, softness: f32) { - let Some(layer) = self.graph.masks_mut().get_mut(id) else { + let Some(layer) = self.part_of_mut(id) else { return; }; // Built into a temporary first: the arc is read out of the layer and @@ -3061,7 +3364,7 @@ impl DevelopSession { /// TRACES: FR-DEV-10 /// Move a colour range's arc — its centre hue and its half-width. pub fn set_mask_hue(&mut self, id: &str, hue: f32, width: f32) { - let Some(layer) = self.graph.masks_mut().get_mut(id) else { + let Some(layer) = self.part_of_mut(id) else { return; }; // A temporary, for the reason `set_mask_band` gives. @@ -3085,19 +3388,14 @@ impl DevelopSession { /// TRACES: FR-DEV-10 /// Whether the band controls apply to this layer. pub fn mask_is_ranged(&self, id: &str) -> bool { - self.graph - .masks() - .get(id) - .is_some_and(|l| l.source.is_range()) + self.part_of(id).is_some_and(|p| p.source.is_range()) } /// TRACES: FR-DEV-10 /// Whether this layer's band is over colour rather than over brightness. pub fn mask_is_chromatic(&self, id: &str) -> bool { - self.graph - .masks() - .get(id) - .is_some_and(|l| matches!(l.source, MaskSource::Colour { .. })) + self.part_of(id) + .is_some_and(|p| matches!(p.source, MaskSource::Colour { .. })) } /// TRACES: FR-DEV-10 @@ -3108,7 +3406,7 @@ impl DevelopSession { /// one panel control drives both. Zeroes for a layer that has no band, /// which the panel never draws. pub fn mask_band(&self, id: &str) -> (f32, f32, f32) { - match self.graph.masks().get(id).map(|l| &l.source) { + match self.part_of(id).map(|p| &p.source) { Some(MaskSource::Luminance { lo, hi, softness }) => (*lo, *hi, *softness), Some(MaskSource::Colour { chroma_lo, @@ -3123,7 +3421,7 @@ impl DevelopSession { /// TRACES: FR-DEV-10 /// A colour range's arc: centre hue and half-width, both in turns. pub fn mask_hue(&self, id: &str) -> (f32, f32) { - match self.graph.masks().get(id).map(|l| &l.source) { + match self.part_of(id).map(|p| &p.source) { Some(MaskSource::Colour { hue, hue_width, .. }) => (*hue, *hue_width), _ => (0.0, 0.0), } @@ -3155,8 +3453,8 @@ impl DevelopSession { /// Edge transition half-width, in fractions of the shorter edge. pub fn set_mask_feather(&mut self, id: &str, feather: f32) { - if let Some(layer) = self.graph.masks_mut().get_mut(id) { - layer.feather = feather.clamp(0.0, 1.0); + if let Some(part) = self.part_of_mut(id) { + part.feather = feather.clamp(0.0, 1.0); self.history .record(&self.graph, Edit::Control(labels::step::MASK_FEATHER)); } @@ -3171,8 +3469,8 @@ impl DevelopSession { /// `subject_signature`. It still runs no model: the evidence was fitted /// during segmentation and this is a smoothstep over it. pub fn set_mask_refine(&mut self, id: &str, refine: f32) { - if let Some(layer) = self.graph.masks_mut().get_mut(id) { - layer.refine = refine.clamp(0.0, dr_pipeline::mask::MAX_REFINE); + if let Some(part) = self.part_of_mut(id) { + part.refine = refine.clamp(0.0, dr_pipeline::mask::MAX_REFINE); self.history .record(&self.graph, Edit::Control(labels::step::MASK_REFINE)); } @@ -3183,8 +3481,8 @@ impl DevelopSession { let Some(&falloff) = Falloff::ALL.get(index) else { return; }; - if let Some(layer) = self.graph.masks_mut().get_mut(id) { - layer.falloff = falloff; + if let Some(part) = self.part_of_mut(id) { + part.falloff = falloff; self.history .record(&self.graph, Edit::Action(labels::step::MASK_FALLOFF)); } @@ -3195,12 +3493,12 @@ impl DevelopSession { let Some(&morphology) = Morphology::ALL.get(index) else { return; }; - if let Some(layer) = self.graph.masks_mut().get_mut(id) { - layer.morphology = morphology; + if let Some(part) = self.part_of_mut(id) { + part.morphology = morphology; // Picking an operation with no amount set would appear to do // nothing, and the user would reasonably conclude it is broken. - if morphology != Morphology::None && layer.morph_radius <= 0.0 { - layer.morph_radius = 0.006; + if morphology != Morphology::None && part.morph_radius <= 0.0 { + part.morph_radius = 0.006; } self.history .record(&self.graph, Edit::Action(labels::step::MASK_MORPHOLOGY)); @@ -3208,8 +3506,8 @@ impl DevelopSession { } pub fn set_mask_morph_radius(&mut self, id: &str, radius: f32) { - if let Some(layer) = self.graph.masks_mut().get_mut(id) { - layer.morph_radius = radius.clamp(0.0, 1.0); + if let Some(part) = self.part_of_mut(id) { + part.morph_radius = radius.clamp(0.0, 1.0); self.history .record(&self.graph, Edit::Control(labels::step::MASK_MORPH)); } @@ -3218,34 +3516,34 @@ impl DevelopSession { /// Which falloff a layer uses, as an index into `Falloff::ALL`. pub fn mask_falloff(&self, id: &str) -> usize { use dr_pipeline::mask::Falloff; - self.graph.masks().get(id).map_or(0, |l| { + self.part_of(id).map_or(0, |p| { Falloff::ALL .iter() - .position(|&f| f == l.falloff) + .position(|&f| f == p.falloff) .unwrap_or(0) }) } pub fn mask_morphology(&self, id: &str) -> usize { use dr_pipeline::mask::Morphology; - self.graph.masks().get(id).map_or(0, |l| { + self.part_of(id).map_or(0, |p| { Morphology::ALL .iter() - .position(|&m| m == l.morphology) + .position(|&m| m == p.morphology) .unwrap_or(0) }) } pub fn mask_feather(&self, id: &str) -> f32 { - self.graph.masks().get(id).map_or(0.0, |l| l.feather) + self.part_of(id).map_or(0.0, |p| p.feather) } pub fn mask_morph_radius(&self, id: &str) -> f32 { - self.graph.masks().get(id).map_or(0.0, |l| l.morph_radius) + self.part_of(id).map_or(0.0, |p| p.morph_radius) } pub fn mask_refine(&self, id: &str) -> f32 { - self.graph.masks().get(id).map_or(0.0, |l| l.refine) + self.part_of(id).map_or(0.0, |p| p.refine) } /// Whether this layer has a refinement to act on. @@ -3263,7 +3561,11 @@ impl DevelopSession { let Some(layer) = self.graph.masks().get(id) else { return false; }; - let MaskSource::Category { name, .. } = &layer.source else { + let index = self.shaped_part(id); + let Some(part) = layer.part(index) else { + return false; + }; + let MaskSource::Category { name, .. } = &part.source else { return false; }; self.segmentation @@ -3286,9 +3588,9 @@ impl DevelopSession { /// units rather than in pixels — see `MaskSource::Luminance`. pub fn mask_is_shapeable(&self, id: &str) -> bool { use dr_pipeline::mask::MaskSource; - self.graph.masks().get(id).is_some_and(|l| { + self.part_of(id).is_some_and(|p| { matches!( - l.source, + p.source, MaskSource::Subject { .. } | MaskSource::Category { .. } | MaskSource::Regions { .. } @@ -5719,7 +6021,7 @@ mod tests { ); layer.set_param("exposure", ParamId("exposure"), 2.0); if stored { - layer.coverage = Some(Arc::new( + layer.base_mut().coverage = Some(Arc::new( Coverage::encode(&values, pw, ph, RENDERED_LEVELS).expect("a half frame encodes"), )); } @@ -6015,7 +6317,7 @@ mod tests { assert_eq!(stored.len(), 1); assert!( - stored.layers()[0].coverage.is_some(), + stored.layers()[0].base().coverage.is_some(), "the raster the sidecar gave us must go back to the sidecar" ); } @@ -6131,6 +6433,7 @@ mod tests { let coverage = stored .get(&id) .expect("the layer is in the stack") + .base() .coverage .as_ref() .expect("a subject the model found has coverage to store"); @@ -6173,7 +6476,7 @@ mod tests { { let layer = session.graph.masks_mut().get_mut(&id).expect("the layer"); layer.set_param("exposure", ParamId("exposure"), 2.0); - layer.coverage = Some(Arc::new( + layer.base_mut().coverage = Some(Arc::new( dr_pipeline::Coverage::encode( &mirrored, pw, diff --git a/ui/dr-ui/src/gesture_book.rs b/ui/dr-ui/src/gesture_book.rs index d2e203d..751a9ae 100644 --- a/ui/dr-ui/src/gesture_book.rs +++ b/ui/dr-ui/src/gesture_book.rs @@ -36,6 +36,13 @@ pub const GESTURES: &[Gesture] = &[ pointer: "The scroll wheel over it", keys: "", }, + Gesture { + title: "Paint a mask by hand", + section: "Develop", + touch: "Choose Paint or Erase, then drag on the photograph", + pointer: "Choose Paint or Erase, then drag", + keys: "", + }, Gesture { title: "Move a magnified photograph about", section: "Develop", diff --git a/ui/dr-ui/src/labels.rs b/ui/dr-ui/src/labels.rs index 9505e3a..037a4f7 100644 --- a/ui/dr-ui/src/labels.rs +++ b/ui/dr-ui/src/labels.rs @@ -61,6 +61,18 @@ pub mod step { /// A range mask's band moved — its bounds, its softness, or its hue arc. pub const MASK_RANGE: LocalizedKey = LocalizedKey("history.mask_range"); + /// TRACES: FR-DEV-19b + /// A stroke, named for what the hand did rather than for the part it + /// landed in: a photographer takes back the mark they made, and which + /// selection was holding it is not what they were thinking about. + pub const MASK_PAINTED: LocalizedKey = LocalizedKey("history.mask_painted"); + pub const MASK_ERASED: LocalizedKey = LocalizedKey("history.mask_erased"); + /// TRACES: FR-DEV-19a + /// A selection joined to a mask, taken out of it, or joined the other way. + pub const MASK_PART_ADDED: LocalizedKey = LocalizedKey("history.mask_part_added"); + pub const MASK_PART_REMOVED: LocalizedKey = LocalizedKey("history.mask_part_removed"); + pub const MASK_JOINED: LocalizedKey = LocalizedKey("history.mask_joined"); + /// TRACES: FR-DEV-8 /// A repair's own settings changed — its radius, its source, its opacity. pub const SPOT: LocalizedKey = LocalizedKey("history.spot"); @@ -105,6 +117,11 @@ pub mod step { MASK_MORPHOLOGY, MASK_REFINE, MASK_RANGE, + MASK_PAINTED, + MASK_ERASED, + MASK_PART_ADDED, + MASK_PART_REMOVED, + MASK_JOINED, SPOT, SPOT_PLACED, SPOT_MOVED, @@ -294,6 +311,11 @@ fn catalogued(key: &str) -> Option<&'static str> { "history.mask_morphology" => "Mask Grow/Shrink", "history.mask_refine" => "Mask Refine", "history.mask_range" => "Mask Range", + "history.mask_painted" => "Paint Mask", + "history.mask_erased" => "Erase Mask", + "history.mask_part_added" => "Add To Mask", + "history.mask_part_removed" => "Remove From Mask", + "history.mask_joined" => "Change How A Mask Joins", "history.spot" => "Adjust Repair", "history.spot_placed" => "Add Repair", "history.spot_moved" => "Move Repair", diff --git a/ui/dr-ui/src/masks_ui.rs b/ui/dr-ui/src/masks_ui.rs index 9627230..9570f42 100644 --- a/ui/dr-ui/src/masks_ui.rs +++ b/ui/dr-ui/src/masks_ui.rs @@ -30,7 +30,9 @@ use slint::{ComponentHandle as _, Model as _, ModelRc, VecModel}; use crate::develop::{Abandon, DevelopSession, RefinedInstance, Segmented, SessionId}; use crate::segmentation; -use crate::{sync_rows, AppWindow, CategoryRow, GradientHandle, MaskRow, ParamRow, SubjectRow}; +use crate::{ + sync_rows, AppWindow, CategoryRow, GradientHandle, MaskRow, ParamRow, PartRow, SubjectRow, +}; /// What the adjust panel's heading says when the controls are global. /// @@ -238,6 +240,31 @@ pub(crate) fn sync(window: &AppWindow, session: &Rc = match s.active_masks() { + [id] => s + .mask_parts(id) + .into_iter() + .enumerate() + .map(|(i, (part_id, label, join))| PartRow { + id: part_id.into(), + label: label.into(), + join: join as i32, + selected: i == s.active_part(), + base: i == 0, + }) + .collect(), + _ => Vec::new(), + }; + window.set_mask_parts(ModelRc::new(VecModel::from(parts))); + + let (radius, hardness, flow) = s.brush(); + window.set_brush_radius(radius); + window.set_brush_hardness(hardness); + window.set_brush_flow(flow); + window.set_segmented(s.has_segmentation()); window.set_adjust_scope(scope_label(s).into()); sync_handles(window, s); @@ -723,6 +750,141 @@ pub(crate) fn wire( } // --- adding layers ------------------------------------------------------ + // TRACES: FR-DEV-19b + // Painting. Three handlers for one gesture, and the middle one runs per + // pointer report — so it does the least it can: map the point, record it, + // redraw. No sync, because nothing the panel shows changes while a finger + // is down, and rebuilding eight rows a frame is how a brush starts to lag. + { + let weak = window.as_weak(); + let session = session.clone(); + let redraw = redraw.clone(); + window.on_paint_began(move |x, y| { + let Some(w) = weak.upgrade() else { return }; + let erase = w.get_mask_tool() == 2; + if let Some(s) = session.borrow_mut().as_mut() { + s.begin_mask_stroke(x, y, erase); + } + redraw(&w); + }); + } + { + let weak = window.as_weak(); + let session = session.clone(); + let redraw = redraw.clone(); + window.on_paint_moved(move |x, y| { + let Some(w) = weak.upgrade() else { return }; + if let Some(s) = session.borrow_mut().as_mut() { + s.extend_mask_stroke(x, y); + } + redraw(&w); + }); + } + { + let weak = window.as_weak(); + let session = session.clone(); + let redraw = redraw.clone(); + let rows = rows.clone(); + window.on_paint_ended(move || { + let Some(w) = weak.upgrade() else { return }; + if let Some(s) = session.borrow_mut().as_mut() { + s.end_mask_stroke(); + } + // Now the panel is worth rebuilding: the gesture may have joined a + // part to the mask, and the history has a step in it. + sync(&w, &session); + sync_rows(&w, &rows, &session); + redraw(&w); + }); + } + { + let weak = window.as_weak(); + let session = session.clone(); + window.on_brush_changed(move |radius, hardness, flow| { + let Some(w) = weak.upgrade() else { return }; + if let Some(s) = session.borrow_mut().as_mut() { + s.set_brush(radius, hardness, flow); + } + sync(&w, &session); + }); + } + { + let weak = window.as_weak(); + let session = session.clone(); + window.on_mask_tool_picked(move |_tool| { + // The tool itself lives in the interface — it arms a gesture and + // changes no pixel — so nothing is set here. What this does is + // re-sync, because arming the brush is what makes the parts of a + // mask worth showing. + let Some(w) = weak.upgrade() else { return }; + sync(&w, &session); + }); + } + // TRACES: FR-DEV-19a + { + let weak = window.as_weak(); + let session = session.clone(); + let redraw = redraw.clone(); + let rows = rows.clone(); + window.on_mask_part_selected(move |_id, index| { + let Some(w) = weak.upgrade() else { return }; + if let Some(s) = session.borrow_mut().as_mut() { + s.set_active_part(index.max(0) as usize); + } + sync(&w, &session); + sync_rows(&w, &rows, &session); + redraw(&w); + }); + } + { + let weak = window.as_weak(); + let session = session.clone(); + let redraw = redraw.clone(); + let rows = rows.clone(); + window.on_mask_part_added(move |id, join| { + let Some(w) = weak.upgrade() else { return }; + if let Some(s) = session.borrow_mut().as_mut() { + s.add_mask_part(&id, join.max(0) as usize); + } + // Armed, because adding an empty painted part and leaving the + // pointer in "select" would look like the button did nothing — + // the part covers no pixels until something is painted into it. + if w.get_mask_tool() == 0 { + w.set_mask_tool(1); + } + sync(&w, &session); + sync_rows(&w, &rows, &session); + redraw(&w); + }); + } + { + let weak = window.as_weak(); + let session = session.clone(); + let redraw = redraw.clone(); + let rows = rows.clone(); + window.on_mask_part_removed(move |id, index| { + let Some(w) = weak.upgrade() else { return }; + if let Some(s) = session.borrow_mut().as_mut() { + s.remove_mask_part(&id, index.max(0) as usize); + } + sync(&w, &session); + sync_rows(&w, &rows, &session); + redraw(&w); + }); + } + { + let weak = window.as_weak(); + let session = session.clone(); + let redraw = redraw.clone(); + window.on_mask_part_join_picked(move |id, index, join| { + let Some(w) = weak.upgrade() else { return }; + if let Some(s) = session.borrow_mut().as_mut() { + s.set_mask_part_join(&id, index.max(0) as usize, join.max(0) as usize); + } + sync(&w, &session); + redraw(&w); + }); + } { let weak = window.as_weak(); let session = session.clone(); @@ -948,6 +1110,108 @@ mod tests { DevelopSession::open_rgb(&ctx, &rgba, 32, 32, dr_types::Orientation::NORMAL).ok() } + /// TRACES: FR-DEV-19b + /// Pressing Paint on a mask the model made must not answer with a + /// refusal about a subject not being a brush. The correction the + /// photographer is about to make *is* a new part, so it is made. + #[test] + fn painting_on_a_mask_that_cannot_hold_a_stroke_joins_a_part_that_can() { + let Some(mut s) = session() else { + eprintln!("no adapter; skipping"); + return; + }; + let id = s.add_gradient_mask(true).expect("radial"); + s.set_active_mask(Some(&id)); + + assert_eq!(s.mask_parts(&id).len(), 1, "one selection to begin with"); + assert!(s.begin_mask_stroke(0.5, 0.5, false), "the stroke started"); + s.extend_mask_stroke(0.55, 0.5); + s.end_mask_stroke(); + + let parts = s.mask_parts(&id); + assert_eq!(parts.len(), 2, "a painted part was joined"); + assert_eq!(parts[1].1, "brush"); + assert_eq!(s.active_part(), 1, "and the tools point at it"); + } + + /// A stroke is one decision. Recording per pointer report would make + /// taking a mark back cost as many presses as making it did. + #[test] + fn a_whole_stroke_is_one_step_in_the_history() { + let Some(mut s) = session() else { + eprintln!("no adapter; skipping"); + return; + }; + let id = s.add_gradient_mask(true).expect("radial"); + s.set_active_mask(Some(&id)); + let before = s.history_rows().len(); + + assert!(s.begin_mask_stroke(0.4, 0.4, false)); + for i in 0..12 { + s.extend_mask_stroke(0.4 + i as f32 * 0.02, 0.5); + } + s.end_mask_stroke(); + + // Two: joining the part is its own step, and the stroke is the other. + assert_eq!( + s.history_rows().len(), + before + 2, + "a stroke recorded more than one step" + ); + } + + /// A gesture the window cancels leaves nothing behind — including in the + /// history, because as far as the photographer is concerned nothing + /// happened. + #[test] + fn a_cancelled_stroke_leaves_no_mark() { + let Some(mut s) = session() else { + eprintln!("no adapter; skipping"); + return; + }; + let id = s.add_gradient_mask(true).expect("radial"); + s.set_active_mask(Some(&id)); + assert!(s.begin_mask_stroke(0.4, 0.4, false)); + s.extend_mask_stroke(0.5, 0.5); + let steps = s.history_rows().len(); + s.cancel_mask_stroke(); + + assert_eq!(s.history_rows().len(), steps, "nothing was recorded"); + assert!( + s.masks() + .get(&id) + .expect("layer") + .part(1) + .expect("part") + .strokes() + .is_empty(), + "and the abandoned stroke is not in the mask" + ); + } + + /// TRACES: FR-DEV-19a + /// The edge controls point at the part that is selected, so that dragging + /// the feather while a correction is selected does not silently soften the + /// model's mask underneath it. + #[test] + fn the_edge_controls_shape_the_selected_part() { + let Some(mut s) = session() else { + eprintln!("no adapter; skipping"); + return; + }; + let id = s.add_gradient_mask(true).expect("radial"); + s.set_active_mask(Some(&id)); + s.set_mask_feather(&id, 0.03); + + let index = s.add_mask_part(&id, 0).expect("a part was joined"); + assert_eq!(s.active_part(), index); + s.set_mask_feather(&id, 0.0); + + assert_eq!(s.mask_feather(&id), 0.0, "the selected part was shaped"); + s.set_active_part(0); + assert_eq!(s.mask_feather(&id), 0.03, "and the base was left alone"); + } + /// TRACES: FR-DEV-3 | FR-UI-1 /// The fault this pass exists for, stated as a test. /// diff --git a/ui/dr-ui/src/presets.rs b/ui/dr-ui/src/presets.rs index 818e57b..e29d88a 100644 --- a/ui/dr-ui/src/presets.rs +++ b/ui/dr-ui/src/presets.rs @@ -1162,9 +1162,9 @@ mod tests { assert_eq!(back.id, "m1"); assert!((back.opacity - 0.75).abs() < 1e-6, "and with its settings"); assert!( - matches!(back.source, MaskSource::Radial { .. }), + matches!(back.base().source, MaskSource::Radial { .. }), "and its kind: {:?}", - back.source.kind() + back.base().source.kind() ); } diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index a517562..eeeac91 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -1,6 +1,6 @@ import { Theme } from "theme.slint"; import { AdjustPanel, ComposePanel, GroupStrip, ParamRow, TransferPanel, ViewMode } from "adjust.slint"; -import { CategoryRow, GradientHandle, GradientHandles, HandleRole, MaskPanel, MaskRow, SubjectRow } from "masks.slint"; +import { CategoryRow, GradientHandle, GradientHandles, HandleRole, MaskPanel, MaskRow, PartRow, SubjectRow } from "masks.slint"; import { SpotHandle, SpotHandles, SpotPanel, SpotRole } from "spots.slint"; import { CropOverlay } from "crop.slint"; import { HistoryPanel, HistoryRow } from "history.slint"; @@ -1146,7 +1146,35 @@ export component AppWindow inherits Window { /// which meant local masking could be open with picking off — a mode /// entered and doing nothing. Arming a click is part of what local mode /// *is*, so it follows the mode and the existence of something to pick. - property region-picking: root.local-mode && root.segmented; + /// TRACES: FR-DEV-19b + /// What a drag on the photograph does while a mask is selected: 0 selects + /// a subject, 1 paints into the mask, 2 erases from it. + in-out property mask-tool: 0; + /// TRACES: FR-DEV-19a + /// The parts of the selected mask, for the panel. + in property <[PartRow]> mask-parts; + in property brush-radius: 0.05; + in property brush-hardness: 0.5; + in property brush-flow: 1.0; + + callback mask-tool-picked(int); + callback brush-changed(float, float, float); + callback mask-part-selected(string, int); + callback mask-part-join-picked(string, int, int); + callback mask-part-removed(string, int); + callback mask-part-added(string, int); + /// TRACES: FR-DEV-19b + /// A stroke, in fractions of the photograph as it is currently shown. + callback paint-began(float, float); + callback paint-moved(float, float); + callback paint-ended(); + + // Clicking to select a subject and dragging to paint are the same press, + // so exactly one of them is armed at a time. Without this the first dab of + // every stroke would also re-select whatever was under it, and the mask + // being painted would be replaced by the one the click made. + property region-picking: root.local-mode && root.segmented && root.mask-tool == 0; + property painting: root.local-mode && root.mask-tool > 0; /// Whether shift is down, tracked by the develop key scope below. /// @@ -2134,6 +2162,59 @@ in property panel-visible: true; } } + // GESTURE: Paint a mask by hand + // where: Develop + // touch: Choose Paint or Erase, then drag on the + // photograph + // pointer: Choose Paint or Erase, then drag + // why: A model's mask stops inside a shoulder and + // leaks into the hair, and no single edge + // control fixes two errors that go opposite + // ways. The whole stroke is one step in the + // history, so taking a mark back costs one + // press however long it took to make. + // + // TRACES: FR-DEV-19b + // + // In front of the region picker rather than beside it: the + // two want the same press, and `region-picking` is false + // whenever this is armed so only one of them exists at a + // time. + if root.painting && root.total > 0 && root.load-error == "": paint := TouchArea { + x: parent.shown-x; + y: parent.shown-y; + width: parent.shown-w; + height: parent.shown-h; + mouse-cursor: crosshair; + enabled: !root.cropping; + + pointer-event(ev) => { + if ev.kind == PointerEventKind.down { + root.paint-began( + self.mouse-x / max(self.width, 1px), + self.mouse-y / max(self.height, 1px), + ); + } + // A cancelled gesture ends the stroke like a + // release does. The alternative is a stroke left + // open across the next press, which paints a line + // between two places the finger never travelled. + if ev.kind == PointerEventKind.up + || ev.kind == PointerEventKind.cancel { + root.paint-ended(); + } + } + + moved => { + if self.pressed { + root.paint-moved( + self.mouse-x / max(self.width, 1px), + self.mouse-y / max(self.height, 1px), + ); + } + } + } + // TRACES: FR-DEV-8 // Placing a repair, beside the region picker and for the // same reasons: a click and a pan want different handlers, @@ -3061,7 +3142,24 @@ in property panel-visible: true; // the value alone. overlay-hidden <=> root.overlay-hidden; + parts: root.mask-parts; + tool: root.mask-tool; + brush-radius: root.brush-radius; + brush-hardness: root.brush-hardness; + brush-flow: root.brush-flow; + segment(fine) => { root.segment-image(fine); } + tool-picked(i) => { + root.mask-tool = i; + root.mask-tool-picked(i); + } + brush-changed(r, h, f) => { root.brush-changed(r, h, f); } + part-selected(id, i) => { root.mask-part-selected(id, i); } + part-join-picked(id, i, j) => { + root.mask-part-join-picked(id, i, j); + } + part-removed(id, i) => { root.mask-part-removed(id, i); } + part-added(id, j) => { root.mask-part-added(id, j); } mask-selected(id, extend) => { root.mask-selected(id, extend); } mask-removed(id) => { root.mask-removed(id); } mask-refined(id) => { root.mask-refined(id); } diff --git a/ui/dr-ui/ui/masks.slint b/ui/dr-ui/ui/masks.slint index 7e1e9b2..41272ec 100644 --- a/ui/dr-ui/ui/masks.slint +++ b/ui/dr-ui/ui/masks.slint @@ -67,6 +67,26 @@ export struct MaskRow { adjusted: bool, } +/// One selection inside a layer's mask (FR-DEV-19a). +/// +/// A mask is a fold over these, so the list is ordered and the order is what +/// it means: a subtraction placed before the part it was meant to cut into +/// takes nothing away. +export struct PartRow { + /// Identity, opaque to this file — the callbacks carry the index instead, + /// because that is what a row is addressed by on screen. + id: string, + /// What this part selects — "brush", "subject", "radial". + label: string, + /// 0 adds to the mask, 1 takes away from it. Meaningless on the first. + join: int, + /// Whether the brush and the edge controls point at this part. + selected: bool, + /// The selection the layer began as. It joins nothing and cannot be + /// removed — removing it is removing the layer. + base: bool, +} + /// Which part of a gradient a canvas handle drags. /// /// Named by the job rather than by the field, because the two gradients keep @@ -114,6 +134,10 @@ component MaskEntry inherits Rectangle { /// Whether a refine pass is running (for any row — only one runs at a /// time), so this row's button can say so and refuse a second press. in property refining: false; + /// TRACES: FR-DEV-19a + /// What this layer's mask is built from. Empty unless the row is the one + /// selected, since the parts of a mask nobody is editing are noise. + in property <[PartRow]> parts; /// `true` when the click should add this row to the selection rather /// than replace it with just this row. @@ -135,6 +159,14 @@ component MaskEntry inherits Rectangle { callback hue-changed(float, float); callback refined(); + /// TRACES: FR-DEV-19a + /// Point the tools at one part, change how it joins, or take it out. + callback part-selected(int); + callback part-join-picked(int, int); + callback part-removed(int); + /// Join a fresh painted correction: 0 adds, 1 subtracts. + callback part-added(int); + height: layout.preferred-height; background: root.data.selected ? Theme.surface-raised : transparent; border-radius: Theme.radius; @@ -248,6 +280,95 @@ component MaskEntry inherits Rectangle { spacing: Theme.gap-sm; padding-top: Theme.gap-sm; + // TRACES: FR-DEV-19a + // What this mask is built from, in the order it is built. + // + // Shown even when there is only one part, because the row is what + // says the edge controls below belong to a *selection* rather than + // to the whole layer — and because it is where the next one is + // added. A photographer who cannot see that a mask has parts has + // no way to guess that the feather they are dragging moves one of + // them. + for part[i] in root.parts: Rectangle { + height: Theme.touch-target; + background: part.selected ? Theme.surface : transparent; + border-radius: Theme.radius; + + // Declared before the two buttons, so it sits *behind* them: a + // press on the join chip or the cross does its own job, and a + // press anywhere else on the row points the tools at the part. + pick := TouchArea { + mouse-cursor: pointer; + enabled: root.enabled; + clicked => { root.part-selected(i); } + } + + HorizontalLayout { + padding-left: Theme.gap-sm; + padding-right: Theme.gap-sm; + spacing: Theme.gap-sm; + + // One chip, two states, because there are two ways to + // join and a photographer flips between them while + // looking at the picture rather than at a menu. + join := TouchArea { + width: Theme.touch-target; + mouse-cursor: part.base ? MouseCursor.default : MouseCursor.pointer; + enabled: root.enabled && !part.base; + clicked => { root.part-join-picked(i, part.join == 0 ? 1 : 0); } + + Label { + // The first part joins nothing — a chip saying + // "adds" on it would be a control that moves and + // changes no pixel. + text: part.base ? "◆" : (part.join == 0 ? "+" : "−"); + emphasised: join.has-hover || part.selected; + horizontal-alignment: center; + vertical-alignment: center; + } + } + + Caption { + text: part.label; + overflow: elide; + min-width: 0px; + max-width: 140px; + vertical-alignment: center; + horizontal-stretch: 1; + } + + if !part.base: cut := TouchArea { + width: Theme.touch-target; + mouse-cursor: pointer; + enabled: root.enabled; + clicked => { root.part-removed(i); } + + Label { + text: "×"; + emphasised: cut.has-hover; + horizontal-alignment: center; + vertical-alignment: center; + } + } + } + } + + HorizontalLayout { + spacing: Theme.gap-sm; + + Button { + text: "+ Add"; + enabled: root.enabled; + clicked => { root.part-added(0); } + } + + Button { + text: "− Subtract"; + enabled: root.enabled; + clicked => { root.part-added(1); } + } + } + SliderRow { label: "Opacity"; value: root.data.opacity; @@ -432,6 +553,18 @@ component MaskEntry inherits Rectangle { export component MaskPanel inherits Rectangle { in property enabled: true; in property <[MaskRow]> masks; + /// TRACES: FR-DEV-19a + /// The parts of the selected layer. Empty when none is selected, or when + /// more than one is — a stroke has one target, and so does an edge. + in property <[PartRow]> parts; + /// TRACES: FR-DEV-19b + /// What a drag on the photograph does: 0 selects, 1 paints, 2 erases. + in property tool: 0; + /// The brush: radius as a fraction of the frame's shorter edge, hardness, + /// and flow. + in property brush-radius: 0.05; + in property brush-hardness: 0.5; + in property brush-flow: 1.0; in property <[SubjectRow]> subjects; in property <[CategoryRow]> categories; @@ -467,6 +600,15 @@ export component MaskPanel inherits Rectangle { callback mask-band-changed(string, float, float, float); callback mask-hue-changed(string, float, float); + /// TRACES: FR-DEV-19b + callback tool-picked(int); + callback brush-changed(float, float, float); + /// TRACES: FR-DEV-19a + callback part-selected(string, int); + callback part-join-picked(string, int, int); + callback part-removed(string, int); + callback part-added(string, int); + callback add-gradient(bool); /// `true` asks for the colour range rather than the brightness one. callback add-range(bool); @@ -496,6 +638,51 @@ export component MaskPanel inherits Rectangle { if !root.enabled: Caption { text: "No image"; } + // TRACES: FR-DEV-19b + // **In the panel and not in the tool rail.** The rail's entries arm a + // gesture for the whole photograph; a brush has no meaning without a + // mask to paint into, and a rail entry that lit up with nothing + // selected would either do nothing or silently make a layer. Here the + // strip can simply be absent until there is something to paint on. + if root.enabled && root.parts.length > 0: Segmented { + options: ["Select", "Paint", "Erase"]; + selected: root.tool; + picked(i) => { root.tool-picked(i); } + } + + if root.enabled && root.tool > 0: SliderRow { + label: "Size"; + value: root.brush-radius; + default-value: 0.05; + minimum: 0.005; + maximum: 0.4; + precision: 3; + changed(v) => { root.brush-changed(v, root.brush-hardness, root.brush-flow); } + reset => { root.brush-changed(0.05, root.brush-hardness, root.brush-flow); } + } + + if root.enabled && root.tool > 0: SliderRow { + label: "Hardness"; + value: root.brush-hardness; + default-value: 0.5; + minimum: 0.0; + maximum: 1.0; + precision: 2; + changed(v) => { root.brush-changed(root.brush-radius, v, root.brush-flow); } + reset => { root.brush-changed(root.brush-radius, 0.5, root.brush-flow); } + } + + if root.enabled && root.tool > 0: SliderRow { + label: "Flow"; + value: root.brush-flow; + default-value: 1.0; + minimum: 0.05; + maximum: 1.0; + precision: 2; + changed(v) => { root.brush-changed(root.brush-radius, root.brush-hardness, v); } + reset => { root.brush-changed(root.brush-radius, root.brush-hardness, 1.0); } + } + // --- the region map --------------------------------------------- // // Segmentation is a thing the user starts. Half a second of watershed @@ -752,6 +939,13 @@ export component MaskPanel inherits Rectangle { morph-radius-changed(v) => { root.mask-morph-radius-changed(mask.id, v); } band-changed(lo, hi, soft) => { root.mask-band-changed(mask.id, lo, hi, soft); } hue-changed(h, w) => { root.mask-hue-changed(mask.id, h, w); } + // Only the selected row is given any, so seven unselected stacks + // of parts are not built and drawn behind the one being worked on. + parts: mask.selected ? root.parts : []; + part-selected(i) => { root.part-selected(mask.id, i); } + part-join-picked(i, j) => { root.part-join-picked(mask.id, i, j); } + part-removed(i) => { root.part-removed(mask.id, i); } + part-added(j) => { root.part-added(mask.id, j); } } // No caption saying which chain the sliders below are pointed at.