Merge branch 'worktree-agent-a22a049c461818dbe' into integration

# Conflicts:
#	core/dr-pipeline/tests/mask_sidecar.rs
This commit is contained in:
2026-08-22 13:23:34 +02:00
14 changed files with 2352 additions and 196 deletions
+240 -2
View File
@@ -41,8 +41,10 @@
use std::f32::consts::PI;
use std::fmt::Write as _;
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation, Scale, Unit,
WidgetDemand, WidgetKind,};
use crate::descriptor::{
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation, Scale,
Unit, WidgetDemand, WidgetKind,
};
use crate::operation::Affects;
pub const ID: OpId = OpId("framing");
@@ -597,6 +599,105 @@ impl Framing {
.normalised()
}
/// TRACES: FR-DEV-3
/// Where an output point comes from in the source, both in normalised
/// `0..1` coordinates.
///
/// **This is [`Self::wgsl_prologue`] evaluated on the CPU**, for the one
/// caller that cannot run the shader: an interface hit-testing a control
/// drawn *on the photograph*. A gradient's handles are stored in source
/// coordinates and dragged in output ones, and the two are separated by
/// the crop, the zoom, the pan, the straightening and the turns — so a
/// handle that mapped through anything less would drift off the mask the
/// moment the view moved, which is exactly the fault masks are rasterised
/// in source space to avoid.
///
/// The two must agree step for step. They are kept together in this file,
/// and `the_cpu_map_matches_the_prologue_step_for_step` below pins the
/// correspondence so a change to one that is not made to the other fails
/// rather than showing up as a mask that is subtly wrong only when
/// straightened.
pub fn source_at(&self, out: (f32, f32), src_w: u32, src_h: u32) -> (f32, f32) {
let (ax, fx) = self.aspects(src_w, src_h);
let rect = self.visible_rect();
// Into the crop rect, then into the framed image's own centred space.
let uv = (rect.x + out.0 * rect.width, rect.y + out.1 * rect.height);
let mut p = ((uv.0 - 0.5) * fx, uv.1 - 0.5);
if self.angle != 0.0 {
let rad = self.angle * PI / 180.0;
let (s, c) = (rad.sin(), rad.cos());
p = (p.0 * c - p.1 * s, p.0 * s + p.1 * c);
}
let (turns, flip_h, flip_v) = self.effective();
p = match turns {
1 => (p.1 * ax, -p.0 / fx),
2 => (-p.0, -p.1),
3 => (-p.1 * ax, p.0 / fx),
_ => p,
};
if flip_h {
p.0 = -p.0;
}
if flip_v {
p.1 = -p.1;
}
(p.0 / ax + 0.5, p.1 + 0.5)
}
/// Where a source point lands on the output — [`Self::source_at`] run
/// backwards.
///
/// Outside `0..1` when the point is cropped away or panned off screen,
/// which is the honest answer: the caller draws a handle there and clips
/// it, rather than being handed a clamped position that claims the mask is
/// somewhere it is not.
pub fn output_at(&self, src: (f32, f32), src_w: u32, src_h: u32) -> (f32, f32) {
let (ax, fx) = self.aspects(src_w, src_h);
let mut p = ((src.0 - 0.5) * ax, src.1 - 0.5);
let (turns, flip_h, flip_v) = self.effective();
if flip_v {
p.1 = -p.1;
}
if flip_h {
p.0 = -p.0;
}
p = match turns {
1 => (-p.1 * fx, p.0 / ax),
2 => (-p.0, -p.1),
3 => (p.1 * fx, -p.0 / ax),
_ => p,
};
if self.angle != 0.0 {
let rad = -self.angle * PI / 180.0;
let (s, c) = (rad.sin(), rad.cos());
p = (p.0 * c - p.1 * s, p.0 * s + p.1 * c);
}
let uv = (p.0 / fx + 0.5, p.1 + 0.5);
let rect = self.visible_rect();
(
(uv.0 - rect.x) / rect.width.max(1e-6),
(uv.1 - rect.y) / rect.height.max(1e-6),
)
}
/// The x components of `aspect` and `frame_aspect`, whose y is always 1.
///
/// The pair the prologue puts in scope, and the distinction that makes a
/// quarter turn exact: `aspect` measures the source, `frame_aspect`
/// measures the frame the user is looking at, and a turn is where the two
/// meet.
fn aspects(&self, src_w: u32, src_h: u32) -> (f32, f32) {
let ax = src_w.max(1) as f32 / src_h.max(1) as f32;
(ax, if self.swaps_axes() { 1.0 / ax } else { ax })
}
/// Uniform values the generated prologue reads.
///
/// A fixed-size block in a fixed slot, like the camera matrix: the
@@ -1591,4 +1692,141 @@ mod tests {
}
}
}
// ---- the CPU coordinate map (source_at / output_at) -------------------
//
// These matter because the map has no other check on it. The shader's
// version is verified by the picture looking right; this one is read by
// hit-testing, where being wrong means a handle that grabs nothing and
// nothing on screen says why.
/// A 3:2 frame. Square would hide every aspect fault in here.
const SRC: (u32, u32) = (600, 400);
fn close(a: (f32, f32), b: (f32, f32), what: &str) {
assert!(
(a.0 - b.0).abs() < 1e-4 && (a.1 - b.1).abs() < 1e-4,
"{what}: {a:?} != {b:?}"
);
}
#[test]
fn an_unedited_frame_maps_an_output_point_to_itself() {
// The neutral prologue is `uv_src = uv`, and a map that quietly
// introduced an aspect factor here would put every mask a little off
// on every unedited photograph — the case that is never looked at
// twice.
let f = Framing::new();
for out in [(0.0, 0.0), (0.5, 0.5), (0.25, 0.8), (1.0, 1.0)] {
close(f.source_at(out, SRC.0, SRC.1), out, "neutral");
}
}
#[test]
fn the_map_round_trips_through_every_transform_at_once() {
// Handles are drawn with `output_at` and dragged with `source_at`, so
// a discrepancy between them is a handle that jumps away from the
// pointer on the first press. Every stage is on, because the faults
// that survive are the ones only a composition exposes — an aspect
// applied on one leg and not the other cancels under a bare rotation.
for turns in 0..4 {
let mut f = Framing::new();
f.set_crop(CropRect {
x: 0.1,
y: 0.2,
width: 0.6,
height: 0.5,
});
f.set_view(CropRect {
x: 0.3,
y: 0.25,
width: 0.4,
height: 0.4,
});
f.set_param(ANGLE, -7.5);
f.rotate_quarters(turns);
f.set_param(FLIP_H, 1.0);
f.set_param(FLIP_V, 1.0);
for out in [(0.0, 0.0), (0.5, 0.5), (0.2, 0.9), (0.95, 0.05)] {
let src = f.source_at(out, SRC.0, SRC.1);
close(f.output_at(src, SRC.0, SRC.1), out, "round trip");
}
}
}
#[test]
fn a_stored_orientation_is_part_of_the_map() {
// The baseline reaches the prologue through `effective`, so it has to
// reach this the same way. A portrait frame the camera stored sideways
// is the common case, and a map that ignored the tag would place every
// handle on a photograph that is not the one on screen.
let mut f = Framing::new();
f.set_baseline(dr_types::Orientation {
quarter_turns: 1,
flip_h: false,
flip_v: false,
});
// The output's top-left comes from the source's bottom-left under a
// clockwise quarter turn.
close(f.source_at((0.0, 0.0), SRC.0, SRC.1), (0.0, 1.0), "turned");
close(
f.output_at((0.0, 1.0), SRC.0, SRC.1),
(0.0, 0.0),
"turned back",
);
}
#[test]
fn zooming_in_narrows_what_an_output_point_reaches() {
// The property the handles depend on: the same place on screen is a
// *different* source point once the view moves, so a handle drawn from
// stored geometry has to be re-placed on every frame of a pan. If this
// were independent of the view the handles would sit still while the
// photograph slid under them.
let mut f = Framing::new();
let wide = f.source_at((0.25, 0.25), SRC.0, SRC.1);
f.set_view(CropRect {
x: 0.25,
y: 0.25,
width: 0.5,
height: 0.5,
});
let close_in = f.source_at((0.25, 0.25), SRC.0, SRC.1);
assert!(close_in.0 > wide.0 && close_in.1 > wide.1, "{close_in:?}");
close(close_in, (0.375, 0.375), "zoomed");
}
#[test]
fn the_cpu_map_matches_the_prologue_step_for_step() {
// The two are the same function written twice, and nothing but this
// stops them drifting apart. It checks the *shape* — that the
// permutation the prologue emits for each turn is the one implemented
// above — because the alternative is running WGSL in a unit test.
let mut f = Framing::new();
f.rotate_quarters(1);
assert!(
f.wgsl_prologue()
.contains("p = vec2<f32>(p.y * aspect.x, -p.x / frame_aspect.x);"),
"the one-turn permutation moved; `source_at` must move with it"
);
let mut f = Framing::new();
f.rotate_quarters(3);
assert!(
f.wgsl_prologue()
.contains("p = vec2<f32>(-p.y * aspect.x, p.x / frame_aspect.x);"),
"the three-turn permutation moved; `source_at` must move with it"
);
// And the sampler's last step, which lives in `operation.rs` and is
// the half of the map this file does not emit.
assert!(
crate::operation::sample_source(false).contains("p / aspect + vec2<f32>(0.5)"),
"the sampler's return to texture coordinates moved"
);
}
}
+27 -8
View File
@@ -533,25 +533,44 @@ pub enum MaskSource {
/// A linear gradient — the graduated-filter mask.
///
/// Geometry is in **normalised output coordinates**, so it survives a crop
/// or an export at another size. Storing pixels would make a mask that
/// silently moves when the frame changes.
/// Geometry is in **normalised source coordinates**, so it survives a crop,
/// a zoom or an export at another size: the mask is rasterised in source
/// space and sampled through the framing map, exactly as a subject mask is.
/// Storing pixels would make a mask that silently moves when the frame
/// changes.
///
/// # Two spaces, and why both are here
///
/// The **centre** is a point, so it is a plain `0.0..=1.0` fraction of each
/// axis — the same coordinates a click carries.
///
/// The **angle and the width are measurements**, and a fraction of the
/// width is not the same length as a fraction of the height on any frame
/// that is not square. So they are in the frame's *isotropic* units: y
/// spans `0..1` and x spans `0..aspect`, which is what makes 45° actually
/// 45° and a circle actually round. `frame_delta` in `mask.wgsl` is the
/// one place that conversion happens, and it must stay the only one.
Linear {
/// Midpoint of the ramp, `0.0..=1.0` in each axis.
centre: (f32, f32),
/// Radians, measured from the +x axis.
/// Radians, measured clockwise from the +x axis in frame units.
/// Coverage increases in this direction.
angle: f32,
/// Distance from full effect to none, in normalised units. Zero is a
/// hard edge.
/// Distance from full effect to none, in frame units. Zero is a hard
/// edge.
width: f32,
},
/// A radial gradient — the classic vignette-shaped local adjustment.
///
/// The same two spaces as [`Self::Linear`]: a `0..1` centre, and semi-axes
/// measured in the frame's isotropic units so equal radii draw a circle.
Radial {
centre: (f32, f32),
/// Semi-axes, normalised. Two of them, because a face is an ellipse
/// and forcing a circle makes the user compensate with a crop.
/// Semi-axes, in frame units. Two of them, because a face is an
/// ellipse and forcing a circle makes the user compensate with a crop.
radii: (f32, f32),
/// Rotation of the ellipse, radians.
angle: f32,
/// Fraction of the radius over which the edge falls off.
feather: f32,
+1 -1
View File
@@ -562,7 +562,7 @@ fn encode_output(c: vec3<f32>) -> vec3<f32> {{
/// Split out because it is the join between the coordinate stage and the
/// colour stage, and because the choice it makes — an exact integer load, or
/// a filtered sample — is the one thing the free-angle case changes.
fn sample_source(interpolate: bool) -> &'static str {
pub(crate) fn sample_source(interpolate: bool) -> &'static str {
if interpolate {
" // Back to texture coordinates.
let uv_src = p / aspect + vec2<f32>(0.5);