Merge branch 'worktree-agent-a22a049c461818dbe' into integration
# Conflicts: # core/dr-pipeline/tests/mask_sidecar.rs
This commit is contained in:
@@ -43,7 +43,9 @@ struct MaskParams {
|
||||
// `falloff_code` on the Rust side.
|
||||
falloff: u32,
|
||||
|
||||
// Geometry, in normalised output coordinates. Meaning depends on `mode`.
|
||||
// Geometry. Meaning depends on `mode`. The centre is in normalised 0..1
|
||||
// coordinates; every distance below it is in the isotropic frame units
|
||||
// `frame_delta` establishes.
|
||||
centre: vec2<f32>,
|
||||
// Linear: (cos, sin) of the ramp direction. Radial: semi-axes.
|
||||
axis: vec2<f32>,
|
||||
@@ -128,9 +130,23 @@ fn region_mask(px: vec2<i32>) -> f32 {
|
||||
return total / n;
|
||||
}
|
||||
|
||||
// Offset from a gradient's centre, in the frame's own **isotropic** units:
|
||||
// y spans 0..1 and x spans 0..aspect, so a step of the same length means the
|
||||
// same distance whichever way it points.
|
||||
//
|
||||
// Without this the geometry lives in raw 0..1, where one axis is compressed
|
||||
// against the other by the aspect ratio — so a 45° ramp is not at 45° on
|
||||
// anything but a square frame, and a radial with equal radii draws an ellipse.
|
||||
// Both faults are invisible in the stored numbers and obvious the moment a
|
||||
// handle is dragged on a photograph, which is what this exists for.
|
||||
fn frame_delta(uv: vec2<f32>) -> vec2<f32> {
|
||||
let aspect = vec2<f32>(f32(p.width) / f32(max(p.height, 1u)), 1.0);
|
||||
return (uv - p.centre) * aspect;
|
||||
}
|
||||
|
||||
fn linear_mask(uv: vec2<f32>) -> f32 {
|
||||
// Signed distance along the ramp direction, from the centre.
|
||||
let d = dot(uv - p.centre, p.axis);
|
||||
let d = dot(frame_delta(uv), p.axis);
|
||||
if (p.softness <= 0.0) {
|
||||
return select(0.0, 1.0, d >= 0.0);
|
||||
}
|
||||
@@ -140,7 +156,7 @@ fn linear_mask(uv: vec2<f32>) -> f32 {
|
||||
fn radial_mask(uv: vec2<f32>) -> f32 {
|
||||
let ca = cos(-p.angle);
|
||||
let sa = sin(-p.angle);
|
||||
let d = uv - p.centre;
|
||||
let d = frame_delta(uv);
|
||||
// Into the ellipse's own frame, then normalised by its semi-axes so the
|
||||
// problem becomes a unit circle.
|
||||
let local = vec2<f32>(d.x * ca - d.y * sa, d.x * sa + d.y * ca);
|
||||
|
||||
@@ -189,3 +189,135 @@ fn the_mask_covers_the_same_fraction_at_every_size() {
|
||||
"and that share is the left half: {small:.3}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gradient geometry
|
||||
//
|
||||
// The mask is rasterised over the source frame, whose two axes are not the
|
||||
// same length. A gradient measured in raw 0..1 fractions therefore means a
|
||||
// different distance horizontally than vertically — so a circle comes out an
|
||||
// ellipse and an angle is not the angle asked for. Neither shows in the stored
|
||||
// numbers, and both are what a photographer is looking straight at while
|
||||
// dragging a handle.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A flat grey frame at an arbitrary shape, brightened wherever `stack` covers.
|
||||
fn render_gradient(ctx: &GpuContext, stack: &MaskStack, w: u32, h: u32) -> Vec<u8> {
|
||||
let data: Vec<u8> = (0..w * h).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||||
let source = DemosaicedImage::from_rgba8(ctx, &data, w, h).expect("upload");
|
||||
|
||||
let mut masks = MaskPass::new(ctx).expect("mask pass");
|
||||
let array = masks.render(stack, None, None, w, h).expect("rasterise");
|
||||
|
||||
let shader = compose_full(&ops::chain(), &Framing::new(), ColourSpace::Srgb, stack);
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust
|
||||
.render_masked(&source, &shader, w, h, Some(array))
|
||||
.expect("render");
|
||||
adjust.export_pixels().expect("readback").0
|
||||
}
|
||||
|
||||
fn brightened(pixels: &[u8], w: u32, x: u32, y: u32) -> bool {
|
||||
pixels[((y * w + x) * 4) as usize] > 160
|
||||
}
|
||||
|
||||
fn gradient(source: MaskSource) -> MaskStack {
|
||||
let mut stack = MaskStack::new();
|
||||
let mut layer = MaskLayer::new("g1", source);
|
||||
layer.set_param("exposure", ParamId("exposure"), 2.0);
|
||||
stack.push(layer);
|
||||
stack
|
||||
}
|
||||
|
||||
/// A radial with equal radii must be round on the screen, not on the numbers.
|
||||
#[test]
|
||||
fn a_radial_with_equal_radii_is_a_circle_on_a_wide_frame() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
// 3:2. On a square frame this test cannot fail, which is why it is not one.
|
||||
const W: u32 = 96;
|
||||
const H: u32 = 64;
|
||||
|
||||
// 0.3 of the frame's height. In pixels that is 19 either way — the x axis
|
||||
// spans 0..1.5 in the same units, so the fraction is smaller and the
|
||||
// distance is the same.
|
||||
let pixels = render_gradient(
|
||||
&ctx,
|
||||
&gradient(MaskSource::Radial {
|
||||
centre: (0.5, 0.5),
|
||||
radii: (0.3, 0.3),
|
||||
angle: 0.0,
|
||||
// Hard, so "covered" is a question with an answer rather than a
|
||||
// ramp to pick a threshold out of.
|
||||
feather: 0.0,
|
||||
}),
|
||||
W,
|
||||
H,
|
||||
);
|
||||
|
||||
let (cx, cy) = (W / 2, H / 2);
|
||||
assert!(brightened(&pixels, W, cx, cy), "the centre must be covered");
|
||||
|
||||
for (dx, dy, what) in [(15u32, 0u32, "right"), (0, 15, "down")] {
|
||||
assert!(
|
||||
brightened(&pixels, W, cx + dx, cy + dy),
|
||||
"15px {what} of centre is inside a 19px radius"
|
||||
);
|
||||
}
|
||||
for (dx, dy, what) in [(24u32, 0u32, "right"), (0, 24, "down")] {
|
||||
assert!(
|
||||
!brightened(&pixels, W, cx + dx, cy + dy),
|
||||
"24px {what} of centre is outside it — before the aspect \
|
||||
correction the horizontal reach was 28px and this passed only \
|
||||
downwards"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// And a ramp at 45° must be at 45° where the photographer sees it.
|
||||
#[test]
|
||||
fn a_diagonal_ramp_runs_at_the_angle_it_was_given() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
const W: u32 = 96;
|
||||
const H: u32 = 64;
|
||||
|
||||
let pixels = render_gradient(
|
||||
&ctx,
|
||||
&gradient(MaskSource::Linear {
|
||||
centre: (0.5, 0.5),
|
||||
angle: std::f32::consts::FRAC_PI_4,
|
||||
width: 0.0,
|
||||
}),
|
||||
W,
|
||||
H,
|
||||
);
|
||||
|
||||
// Coverage increases along (cos 45°, sin 45°), so the half-way line runs
|
||||
// from lower-left to upper-right through the centre: equal steps right and
|
||||
// down stay on the covered side, and the two sides of it disagree.
|
||||
let (cx, cy) = (W / 2, H / 2);
|
||||
assert!(
|
||||
brightened(&pixels, W, cx + 16, cy + 16),
|
||||
"down and to the right of the line is inside"
|
||||
);
|
||||
assert!(
|
||||
!brightened(&pixels, W, cx - 16, cy - 16),
|
||||
"up and to the left of it is outside"
|
||||
);
|
||||
// The diagonal itself, sampled a few pixels either side. Without the
|
||||
// aspect correction the line comes out at 34 degrees and both of these
|
||||
// land on the same side of it.
|
||||
assert!(
|
||||
brightened(&pixels, W, cx + 18, cy - 14),
|
||||
"just below the 45 degree line is inside"
|
||||
);
|
||||
assert!(
|
||||
!brightened(&pixels, W, cx + 14, cy - 18),
|
||||
"just above it is not"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -568,6 +568,9 @@ fn show_a_sidecar() {
|
||||
"m2",
|
||||
MaskSource::Linear {
|
||||
centre: (0.5, 0.25),
|
||||
// The constant rather than four digits of it: clippy rejects the
|
||||
// literal, and a quarter turn written as a number is a quarter
|
||||
// turn nobody can see at a glance.
|
||||
angle: std::f32::consts::FRAC_PI_2,
|
||||
width: 0.4,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user