Files
DarkRoom/core/dr-pipeline/src/spot.rs
T
dtourolleandClaude Opus 5 8ab9440190 Put the repair tool on the photograph
A third chip beside Crop and Local, and the mode strip's own comment
predicted the shape: a mode that arms a gesture on the canvas and scopes
the column. Click a mark to cover it, drag the disc to move the repair,
drag the source circle to say where the patch comes from, Delete to remove
it. The source starts two and a half radii towards the middle of the
frame, which is FR-DEV-8's automatic placement in its cheap form — dust
sits on skies and skies are smooth, so it is usually right and always one
drag from fixed.

Two things are drawn deliberately. The circles are the size the repairs
actually are, because whether a disc covers a speck is the whole judgement
being made and a fixed-size dot would say nothing about it; the reach
around them is padded to a touch target so a spot on a dust mark can still
be picked up on a phone. And only the selected repair shows its source: a
dusty sky carries a dozen, and two dozen circles with nothing saying which
belongs to which is less information rather than more.

The panel edits what is stored while the canvas draws what is mapped, and
the two are pushed separately for that reason — a slider deriving its
value from the drawn radius would move differently at different zoom
levels. It is also the one panel built from SliderRow rather than a live
track: a repair has no OpId to coalesce a drag under, so a row that fires
once per gesture is what keeps undo one step per decision.

Verified as far as this environment allows: the strip renders and the
column re-scopes, photographed under XWayland. Synthetic clicks do not
reach this application, so the gestures are as-written rather than
as-felt, and docs/spot-removal.md says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 20:58:13 +02:00

797 lines
34 KiB
Rust

//! TRACES: FR-DEV-8
//! Spot removal — the marks a photographer paints out, as parameters.
//!
//! A spot is a disc over something unwanted, a source offset saying where the
//! replacement comes from, and the handful of numbers that decide how the two
//! are blended. No pixels are stored, here or anywhere: the shader draws the
//! repair from these numbers every time the photograph is rendered, which is
//! what makes it non-destructive, cheap to sync, and undoable
//! (`docs/spot-removal.md`).
//!
//! # Why this is not an operation
//!
//! [`crate::Operation`] is `ParamId -> f32`, and the generic machinery built on
//! that — the develop panel, the sidecar, the presets — works precisely because
//! it is true. A spot list is neither scalar nor of fixed length, so it lives
//! beside `ops` in [`crate::EditGraph`], as `framing`, `masks` and `film`
//! already do for the same reason. The trait says as much where it refuses a
//! downcast for film tables: a thing that is not a slider should not pretend to
//! be one.
//!
//! # Units, once, for all of a spot's lengths
//!
//! `centre` is in **normalised source coordinates**, the space every mask uses,
//! so a spot survives a crop, a straighten, a zoom and an export at another
//! size with no arithmetic to keep it where the dust was.
//!
//! Every *length* — the radius, the feather, the source offset — is in the
//! frame's **isotropic units**, where y spans `0..1` and x spans `0..aspect`.
//! That is [`crate::mask::MaskSource::Radial`]'s convention and it is chosen
//! here for the same reason: only in those units is a disc a disc. Normalised
//! coordinates would make a spot on a 3:2 frame an ellipse half again wider
//! than it is tall, and the source offset would point somewhere other than
//! where the photographer dragged it.
//!
//! It is deliberately *one* unit for all three. A radius in shorter-edge
//! fractions beside an offset in frame units agrees on a landscape frame and
//! silently disagrees on a portrait one, which is the kind of bug that stays
//! invisible until somebody rotates a photograph.
//!
//! # What is not decided here
//!
//! How a spot is drawn. That is `dr-gpu`, from the passes [`crate::detail`]
//! composes — this module holds the state and the two pieces of arithmetic
//! nobody downstream should have to repeat: where a spot's source is
//! ([`Spot::source`]), and which spots may share a pass ([`SpotSet::rounds`]).
use crate::operation::{canonical_bits, hash_bytes, mix, Helper, FNV_OFFSET};
/// The most spots one edit holds.
///
/// Past a few dozen marks the answer is to clean the sensor, and a bound is
/// what keeps a sidecar a file a human can still read. Pushing past it refuses
/// rather than dropping the oldest — the rule [`crate::mask::MaskStack::push`]
/// follows, for the reason it gives: work the user can see on screen must not
/// vanish without being told.
pub const MAX_SPOTS: usize = 64;
/// The furthest a source may be dragged from what it repairs, in frame units.
///
/// Half the frame's height is well past any repair a photographer makes, and it
/// bounds something that is otherwise unbounded: a detail pass declares how far
/// it reads from the pixel it writes, and for a spot that is the offset plus
/// the radius. An unbounded offset is an unbounded halo, which is a pass the
/// tile scheduler cannot plan (ARCH §5.3, `docs/spot-removal.md` §5.3).
pub const MAX_SOURCE_DISTANCE: f32 = 0.5;
/// The radius a new spot starts at, in frame units.
///
/// About 25 px on the short edge of a 24 MP frame — a dust mark. Small enough
/// that the first click on a speck usually covers it, large enough to be worth
/// clicking at all.
pub const DEFAULT_RADIUS: f32 = 0.012;
/// The smallest radius a spot may be dragged to, in frame units.
///
/// Not zero: a spot with no radius repairs nothing and reads as the tool being
/// broken rather than as a spot being small.
pub const MIN_RADIUS: f32 = 0.001;
/// The largest radius a spot may be dragged to, in frame units.
///
/// A repair wider than half the frame is not a repair, and the same bound on
/// the radius as on the offset keeps the halo arithmetic honest.
pub const MAX_RADIUS: f32 = 0.5;
/// The fraction of the radius over which a new spot's edge falls away.
///
/// Soft by default because the common repair is dust on a gradient sky, where
/// a hard edge shows as a disc even when the colour underneath it is right.
pub const DEFAULT_FEATHER: f32 = 0.35;
/// How far a new repair's source starts from what it repairs, in radii.
///
/// Clear of the disc it is replacing — a source overlapping its own
/// destination would copy the mark it is removing — and close enough that on a
/// smoothly varying background it is the same background. Two and a half puts
/// a full radius of untouched photograph between the two edges.
const SOURCE_ARM: f32 = 2.5;
/// The grid every stored length and coordinate is rounded to, as a divisor.
///
/// The same value and the same reasoning as [`crate::mask::Stroke`]'s grid:
/// values are snapped on the way in *and* written at that precision, so a
/// sidecar round trip is exact rather than nearly exact, and two devices that
/// placed the same spot produce the same line instead of a diff of noise in the
/// sixth decimal — which under per-field merge (FR-NC-9) is a conflict over
/// nothing.
const SPOT_GRID: f32 = 10_000.0;
/// Round to the stored grid. See [`SPOT_GRID`].
fn snap(v: f32) -> f32 {
(v * SPOT_GRID).round() / SPOT_GRID
}
/// TRACES: FR-DEV-8
/// How a spot's patch meets what is already there.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SpotMode {
/// Copy the source's texture and take the destination's colour and
/// brightness from the boundary. The right answer for dust on a sky, and
/// the default because that is the overwhelming majority of spots.
#[default]
Heal,
/// Copy the source, unaltered.
///
/// Kept because heal is wrong on an edge: a spot straddling a horizon
/// healed by interpolating its boundary smears the horizon's contrast
/// across the disc, and the honest tool then is a straight copy from a
/// matching part of the frame. FR-DEV-8 asks for both for this reason.
Clone,
}
impl SpotMode {
/// The name this mode is stored under. Stable: it is in every sidecar.
pub fn name(self) -> &'static str {
match self {
Self::Heal => "heal",
Self::Clone => "clone",
}
}
pub fn from_name(name: &str) -> Option<Self> {
match name {
"heal" => Some(Self::Heal),
"clone" => Some(Self::Clone),
_ => None,
}
}
}
/// TRACES: FR-DEV-8
/// One repair: what is covered, what covers it, and how the two meet.
#[derive(Debug, Clone, PartialEq)]
pub struct Spot {
/// Stable across devices — see [`Spot::derive_id`].
pub id: String,
/// What is being covered, in normalised source coordinates.
pub centre: (f32, f32),
/// Where the replacement comes from, as a displacement from `centre` in
/// frame units.
///
/// A vector rather than a second point, so that nudging a spot half a pixel
/// carries its source along instead of asking the photographer to place it
/// again. Moving the source alone is an edit to this.
pub offset: (f32, f32),
/// The radius of the disc, in frame units.
pub radius: f32,
/// The fraction of the radius over which the edge falls away, `0.0..=1.0`.
/// Zero is a hard disc.
pub feather: f32,
/// How much of the patch is laid down, `0.0..=1.0`.
///
/// Below one the repair is partial, which is how a mark is *reduced* rather
/// than removed — worth having for a blemish that is part of the subject
/// rather than dirt on the sensor.
pub opacity: f32,
pub mode: SpotMode,
/// Whether this spot draws.
///
/// Kept rather than deleted so a photographer can see what a repair was
/// doing without losing it, exactly as [`crate::mask::MaskLayer::enabled`]
/// does for a layer.
pub enabled: bool,
}
impl Spot {
/// A spot covering `centre`, sourced `offset` away, clamped to what the
/// renderer can express.
///
/// The id is derived from the position — see [`Spot::derive_id`]. A caller
/// adding to a set should go through [`SpotSet::place`], which is what
/// resolves the case of two spots landing on the same point.
pub fn new(centre: (f32, f32), offset: (f32, f32), radius: f32) -> Self {
let centre = (snap(centre.0), snap(centre.1));
Self {
id: Self::derive_id(centre),
centre,
offset: clamp_offset(offset),
radius: snap(radius.clamp(MIN_RADIUS, MAX_RADIUS)),
feather: DEFAULT_FEATHER,
opacity: 1.0,
mode: SpotMode::default(),
enabled: true,
}
}
/// TRACES: FR-DEV-8
/// Where a new repair reads from, before anybody has looked at it.
///
/// **FR-DEV-8 asks for automatic source placement, and this is the cheap
/// half of it.** The good half searches the photograph for a patch whose
/// surroundings match — a compute dispatch scoring candidate offsets, and
/// one small readback when the spot is created (`docs/spot-removal.md`
/// §8). This is what stands in for it, and it is worth having on its own
/// terms rather than as a placeholder: dust sits on skies, skies are
/// smooth, and a patch two and a half radii away is nearly always the same
/// sky.
///
/// Towards the centre of the frame, because that is the direction with the
/// most photograph in it: a mark near an edge sourced outwards reads from
/// the border, or from outside it, where `spot_tap` clamps and the repair
/// smears. A mark *at* the centre has no such direction and is sent right,
/// which is as good as any other bearing and is at least predictable.
///
/// The result is what gets stored, and it is never recomputed: a repair
/// whose source moved on its own when the file was reopened would be an
/// edit changing itself, and non-destructive editing means the sidecar
/// decides what the picture is.
pub fn default_offset(centre: (f32, f32), radius: f32, aspect: f32) -> (f32, f32) {
let aspect = if aspect > 0.0 { aspect } else { 1.0 };
// In frame units, where a direction is a direction: normalised
// coordinates would bend the bearing by the aspect ratio and send a
// source off at an angle nobody chose.
let to_centre = ((0.5 - centre.0) * aspect, 0.5 - centre.1);
let length = to_centre.0.hypot(to_centre.1);
let direction = if length > 1e-4 {
(to_centre.0 / length, to_centre.1 / length)
} else {
(1.0, 0.0)
};
let distance = radius * SOURCE_ARM;
(direction.0 * distance, direction.1 * distance)
}
/// TRACES: FR-NC-9
/// The id a spot at `centre` is given: a short base-36 hash of the position
/// it was placed at.
///
/// **Derived rather than counted**, which is the opposite of what
/// [`crate::mask::MaskStack::next_id`] does, and the difference is worth
/// stating. A layer is a thing a user names and reorders, so a sequence is
/// natural. A spot is not named, and two devices editing the same
/// photograph offline would each mint `spot3` for different marks — after
/// which the merge in [`crate::sidecar`] would treat two repairs as one and
/// quietly keep whichever revision was higher.
///
/// From the position, two devices that removed *the same piece of dust*
/// agree on the id and the merge resolves them as one spot — which is
/// exactly right, because it is one spot. Two devices that removed
/// different marks disagree, and both survive.
///
/// The id is minted once, at placement, and never re-derived: dragging a
/// spot moves the repair, it does not make a different one.
pub fn derive_id(centre: (f32, f32)) -> String {
let mut h = FNV_OFFSET;
h = mix(h, u64::from(canonical_bits(snap(centre.0))));
h = mix(h, u64::from(canonical_bits(snap(centre.1))));
base36(h)
}
/// Where this spot reads from, in normalised source coordinates.
///
/// `aspect` is the source's width over its height. The offset is in frame
/// units and the answer is in normalised ones, and this is the only place
/// that conversion happens on this side — a caller doing it itself would be
/// the second place, and the two would eventually disagree about which axis
/// carries the aspect.
pub fn source(&self, aspect: f32) -> (f32, f32) {
let aspect = if aspect > 0.0 { aspect } else { 1.0 };
(
self.centre.0 + self.offset.0 / aspect,
self.centre.1 + self.offset.1,
)
}
/// This spot's centre in frame units, where a disc is a disc.
pub fn frame_centre(&self, aspect: f32) -> (f32, f32) {
(self.centre.0 * aspect, self.centre.1)
}
/// How far the source is from what it repairs, in frame units.
pub fn distance(&self) -> f32 {
self.offset.0.hypot(self.offset.1)
}
/// Whether this spot changes the photograph.
///
/// A spot with no offset reads the pixel it is writing: a clone copies a
/// pixel onto itself and a heal interpolates a boundary difference that is
/// zero everywhere, so both are the identity and both would cost a pass. A
/// spot just placed and not yet given a source is in exactly that state,
/// which is why this is asked per spot rather than per set.
pub fn is_active(&self) -> bool {
self.enabled && self.radius > 0.0 && self.opacity > 0.0 && self.distance() > f32::EPSILON
}
/// Move the whole repair, source and all, to a new centre.
///
/// The id does not move with it — see [`Spot::derive_id`].
pub fn set_centre(&mut self, centre: (f32, f32)) {
self.centre = (snap(centre.0), snap(centre.1));
}
/// Move the source, leaving what is being repaired where it is.
pub fn set_offset(&mut self, offset: (f32, f32)) {
self.offset = clamp_offset(offset);
}
pub fn set_radius(&mut self, radius: f32) {
self.radius = snap(radius.clamp(MIN_RADIUS, MAX_RADIUS));
}
pub fn set_feather(&mut self, feather: f32) {
self.feather = snap(feather.clamp(0.0, 1.0));
}
pub fn set_opacity(&mut self, opacity: f32) {
self.opacity = snap(opacity.clamp(0.0, 1.0));
}
/// Fold this spot into a running hash, for the detail stage's cache key.
///
/// Every value is a parameter — a position a finger left, a number from a
/// sidecar — never a float that came back from the GPU, which is what makes
/// hashing the bit patterns sound rather than reckless (ARCH §6.13). The id
/// is in it too: two spots that swapped ids are a different edit to sync
/// even though they draw the same picture.
pub(crate) fn hash(&self, h: u64) -> u64 {
let mut h = hash_bytes(h, self.id.as_bytes());
for v in [
self.centre.0,
self.centre.1,
self.offset.0,
self.offset.1,
self.radius,
self.feather,
self.opacity,
] {
h = mix(h, u64::from(canonical_bits(v)));
}
h = hash_bytes(h, self.mode.name().as_bytes());
mix(h, u64::from(self.enabled))
}
}
/// An offset clamped to what the halo bound allows, snapped to the grid.
///
/// Clamped along its own direction rather than per axis, so a drag towards a
/// corner stops at the bound instead of sliding along it — a per-axis clamp
/// would turn a diagonal drag into an L-shaped one under the finger.
fn clamp_offset(offset: (f32, f32)) -> (f32, f32) {
let distance = offset.0.hypot(offset.1);
if distance > MAX_SOURCE_DISTANCE {
let scale = MAX_SOURCE_DISTANCE / distance;
(snap(offset.0 * scale), snap(offset.1 * scale))
} else {
(snap(offset.0), snap(offset.1))
}
}
/// A hash as base-36 digits.
///
/// Short because it becomes a sidecar key that a human reads while debugging an
/// edit that went wrong. Six digits is two thousand million ids against the
/// sixty-four an edit may hold, so a collision is not a thing that happens by
/// accident — and [`SpotSet::place`] resolves it anyway when it does.
fn base36(mut h: u64) -> String {
const DIGITS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz";
let mut out = String::with_capacity(6);
for _ in 0..6 {
out.push(DIGITS[(h % 36) as usize] as char);
h /= 36;
}
out
}
/// TRACES: FR-DEV-8
/// Every repair on one photograph, in the order they were made.
///
/// The order is not decoration: it decides which spots may share a pass
/// ([`Self::rounds`]) and which repair sits on top where two overlap.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SpotSet {
spots: Vec<Spot>,
}
impl SpotSet {
pub fn new() -> Self {
Self::default()
}
pub fn spots(&self) -> &[Spot] {
&self.spots
}
pub fn len(&self) -> usize {
self.spots.len()
}
pub fn is_empty(&self) -> bool {
self.spots.is_empty()
}
/// Whether this set draws nothing, and the detail stage may skip it
/// entirely.
pub fn is_neutral(&self) -> bool {
!self.spots.iter().any(Spot::is_active)
}
pub fn get(&self, id: &str) -> Option<&Spot> {
self.spots.iter().find(|s| s.id == id)
}
pub fn get_mut(&mut self, id: &str) -> Option<&mut Spot> {
self.spots.iter_mut().find(|s| s.id == id)
}
/// The spots that draw, in order.
pub fn active(&self) -> impl Iterator<Item = &Spot> {
self.spots.iter().filter(|s| s.is_active())
}
/// Add a spot, returning its id, or `None` if the set is full.
///
/// Full **refuses** rather than dropping the oldest: sixty-four repairs are
/// sixty-four decisions, and silently discarding the first to make room for
/// the sixty-fifth would undo work the photographer can see on screen.
///
/// A spot placed on top of an existing one is given a distinct id by
/// salting the hash, so the set never holds two spots under one name. This
/// is rare by construction — the same point to a ten-thousandth of the
/// frame — and it is the one case [`Spot::derive_id`]'s determinism cannot
/// resolve on its own.
pub fn place(&mut self, mut spot: Spot) -> Option<String> {
if self.spots.len() >= MAX_SPOTS {
log::warn!("spots: {MAX_SPOTS} is the limit; refusing to place another");
return None;
}
let mut salt: u64 = 0;
while self.spots.iter().any(|s| s.id == spot.id) {
salt += 1;
let mut h = FNV_OFFSET;
h = mix(h, u64::from(canonical_bits(spot.centre.0)));
h = mix(h, u64::from(canonical_bits(spot.centre.1)));
spot.id = base36(mix(h, salt));
}
let id = spot.id.clone();
self.spots.push(spot);
Some(id)
}
/// Remove one repair.
pub fn remove(&mut self, id: &str) -> Option<Spot> {
let index = self.spots.iter().position(|s| s.id == id)?;
Some(self.spots.remove(index))
}
pub fn clear(&mut self) {
self.spots.clear();
}
/// TRACES: FR-DEV-8
/// The active spots grouped into passes, as indices into the order
/// [`Self::active`] yields.
///
/// # Why grouping is needed at all
///
/// A detail pass reads one texture and writes another, so every spot in one
/// pass reads the photograph as it stood *before* that pass. A spot whose
/// source sits on an earlier spot's destination therefore copies the very
/// mark the earlier spot was removing, and the mark reappears a few hundred
/// pixels away — which reads as the tool being broken rather than as two
/// repairs that disagree.
///
/// The fix is not a pass per spot: sixty-four dispatches for a frame that
/// needs one is a frame budget spent on a case that almost never arises.
/// Instead a spot joins the round being built unless its source disc
/// intersects the destination disc of a spot already in that round, in
/// which case it opens a new one. Spots scattered over a sky with their
/// sources beside them — the overwhelming majority — come out as a single
/// round.
///
/// Only the round being built is consulted. Earlier rounds have already
/// been applied by the time a later one runs, so reading their destinations
/// is not a hazard: it is the repaired photograph, which is exactly what a
/// source should see.
///
/// Destinations overlapping destinations is not a hazard either — both
/// write the same output and the later spot lands on top, which is the
/// order the photographer made them in.
pub fn rounds(&self, aspect: f32) -> Vec<Vec<usize>> {
let active: Vec<&Spot> = self.active().collect();
let mut rounds: Vec<Vec<usize>> = Vec::new();
let mut current: Vec<usize> = Vec::new();
for (index, spot) in active.iter().enumerate() {
let source = spot.source(aspect);
let source_frame = (source.0 * aspect, source.1);
let conflicts = current.iter().any(|&earlier| {
let other = active[earlier];
let dest = other.frame_centre(aspect);
let reach = spot.radius + other.radius;
let dx = source_frame.0 - dest.0;
let dy = source_frame.1 - dest.1;
dx * dx + dy * dy < reach * reach
});
if conflicts {
rounds.push(std::mem::take(&mut current));
}
current.push(index);
}
if !current.is_empty() {
rounds.push(current);
}
rounds
}
/// Fold the set into a running hash, for the detail stage's cache key.
///
/// The order is in it: two spots swapped is a different grouping in
/// [`Self::rounds`] and a different picture where they overlap.
pub(crate) fn hash(&self, mut h: u64) -> u64 {
for spot in &self.spots {
h = spot.hash(h);
}
mix(h, self.spots.len() as u64)
}
}
/// The id the generated passes are labelled and prefixed with.
///
/// Not an operation id — no `ops/*.yaml` declares it and nothing in the chain
/// answers to it — for the same reason [`crate::detail`]'s resolve pass has one
/// of its own: a label reading `spot/round0` sends a reader to this module
/// rather than to whichever operation happened to lend its name.
pub const SPOT_ID: &str = "spot";
/// Bilinear sampling, which the generated preamble does not offer.
///
/// `tap` takes an integer offset from the pixel being written, and a repair
/// reads from wherever its source is — a fractional position in render space,
/// because the offset was stored as a fraction of the frame and multiplied up.
/// Sampling it nearest-neighbour would make a repair jitter by a pixel as the
/// view is zoomed, which on a face is the difference between a repair and a
/// smudge.
pub const SPOT_HELPERS: &[Helper] = &[Helper {
name: "spot_tap",
source: "\
// A bilinear sample at an arbitrary position, clamped to the edge.
//
// Clamped rather than zero-filled, exactly as `tap` is: a source dragged partly
// off the frame must read the pixels that exist rather than fade into black,
// which would draw a dark crescent inside the repair.
fn spot_tap(p: vec2<f32>) -> vec3<f32> {
let last = vec2<i32>(textureDimensions(source)) - vec2<i32>(1);
// Pixel centres sit at half-integers, so the texel below and left of a
// position is `floor(p - 0.5)`. Getting this wrong shifts every repair by
// half a pixel — invisible in a test that checks a mean, obvious on a face.
let q = p - vec2<f32>(0.5);
let base = floor(q);
let f = q - base;
let i0 = clamp(vec2<i32>(base), vec2<i32>(0), last);
let i1 = clamp(i0 + vec2<i32>(1), vec2<i32>(0), last);
let s00 = textureLoad(source, vec2<i32>(i0.x, i0.y), 0).rgb;
let s10 = textureLoad(source, vec2<i32>(i1.x, i0.y), 0).rgb;
let s01 = textureLoad(source, vec2<i32>(i0.x, i1.y), 0).rgb;
let s11 = textureLoad(source, vec2<i32>(i1.x, i1.y), 0).rgb;
return mix(mix(s00, s10, f.x), mix(s01, s11, f.x), f.y);
}",
}];
/// TRACES: FR-DEV-8
/// How many points around a disc's rim a heal samples.
///
/// The membrane in [`SPOT_BODY`] is an interpolation of the boundary
/// difference, so this is the resolution of the boundary it sees. Twenty-four
/// puts a sample every fifteen degrees, which on a disc of any size a
/// photographer draws is finer than the tone it is interpolating.
///
/// A uniform rather than a constant in the source, so tuning it uploads a
/// buffer instead of recompiling — and so a future control could trade it for
/// speed on a large repair without a second shader.
pub const RIM_SAMPLES: f32 = 24.0;
/// The WGSL every spot pass runs. See [`SpotSet::passes`] for the record layout
/// it reads, which is where the meaning of each lane is written down.
const SPOT_BODY: &str = "\
// Each repair is two records: the disc it covers, and where it reads from.
let repairs = instance_count / 2u;
// The centre of this pixel. Half-integer, because a disc of radius 1.5 centred
// on a pixel should cover that pixel whole rather than half of it.
let here = vec2<f32>(f32(coord.x) + 0.5, f32(coord.y) + 0.5);
for (var i = 0u; i < repairs; i = i + 1u) {
let disc = instances[i * 2u];
let src = instances[i * 2u + 1u];
// The rejection test. It is what every pixel outside every repair pays,
// and a repair covers a few thousand pixels of a few million.
let delta = here - disc.xy;
let dist = length(delta);
if (dist >= disc.z) {
continue;
}
// One inside the solid core, falling to zero at the rim. `disc.w` is where
// the fall begins, worked out on the CPU so the shader never divides by a
// feather that might be zero.
let cover = (1.0 - smoothstep(disc.w, disc.z, dist)) * src.z;
if (cover <= 0.0) {
continue;
}
// The same displacement within the disc, read from beside it: the patch is
// a translation of the photograph, so its texture arrives unrotated and
// unscaled.
//
// `replacement`, not `patch`: WGSL reserves that word, and a reserved
// keyword in generated code is a compile error a long way from its cause.
var replacement = spot_tap(src.xy + delta);
// Heal: carry the source's texture, but the destination's tone.
//
// What a clone gets wrong is not the texture, it is the level. Dust on a
// gradient sky is cloned from a patch a little lighter or darker than the
// hole it fills, and the repair reads as a disc even though every grain in
// it is right. The fix is the difference between the two neighbourhoods,
// interpolated across the disc — a membrane, in the sense the Poisson
// literature means, approximated here in closed form rather than solved.
//
// Solving it properly is tens of Jacobi iterations, and an iteration in
// this architecture is a dispatch: sixty dispatches to remove a dust spot
// is not a frame budget. Interpolating the boundary difference by inverse
// square distance costs one loop over the rim and no state at all, and on
// the case that actually matters — a smooth background, where the
// difference around the rim is near enough constant — it lands on the same
// answer the solve would.
if (src.w > 0.5) {
var weighted = vec3<f32>(0.0);
var total = 0.0;
let samples = i32(rim_samples);
for (var k = 0; k < samples; k = k + 1) {
// Offset by half a step so no sample sits exactly on an axis,
// where a rim that crosses a hard edge would align with it.
let angle = (f32(k) + 0.5) * 6.283185307 / rim_samples;
let arm = vec2<f32>(cos(angle), sin(angle)) * disc.z;
// What the photograph says here, minus what the source says at the
// matching point of its own rim.
let boundary = spot_tap(disc.xy + arm) - spot_tap(src.xy + arm);
// Inverse square distance, floored so a pixel that lands on a
// sample is a large weight rather than an infinite one.
let w = 1.0 / max(dot(here - (disc.xy + arm), here - (disc.xy + arm)), 1.0);
weighted = weighted + boundary * w;
total = total + w;
}
replacement = replacement + weighted / max(total, 1e-6);
}
c = mix(c, replacement, cover);
}";
impl SpotSet {
/// TRACES: FR-DEV-8 | FR-DSP-1
/// The passes that draw these repairs at this size.
///
/// Shaped like [`crate::detail::DetailStage::passes`] and called in the
/// same place for the same reason, but deliberately not an implementation
/// of it: that trait belongs to operations, and a spot set is not one.
///
/// # Everything the shader sees is in render pixels
///
/// The conversion happens here, where the [`crate::Framing`] is in scope,
/// and never in WGSL. That is what keeps the shader ignorant of crops,
/// zooms, rotations and flips: a repair's centre and its source both go
/// through [`crate::Framing::output_at`] — the same map the fused pass
/// applies to every pixel — so a rotated photograph rotates the offset with
/// no trigonometry here at all, and a repair panned off screen lands
/// outside the target and draws nothing.
///
/// The radius goes through the same map rather than being multiplied by a
/// ratio: a point one radius above the centre is mapped too, and the
/// distance between the two answers *is* the radius in render pixels.
/// Anything cheaper would need this function to know that the framing is a
/// similarity, which is not its business to know.
///
/// # The record layout
///
/// Two `vec4`s per repair, because a repair does not fit in one:
///
/// | | x | y | z | w |
/// |---|---|---|---|---|
/// | 0 | centre x | centre y | radius | where the edge starts falling |
/// | 1 | source x | source y | opacity | 1 for heal, 0 for clone |
pub fn passes(
&self,
framing: &crate::Framing,
source: (u32, u32),
scale: crate::detail::RenderScale,
) -> Vec<crate::detail::DetailPass> {
use crate::detail::DetailPass;
let (sw, sh) = (source.0.max(1), source.1.max(1));
let aspect = sw as f32 / sh as f32;
let (rw, rh) = scale.render_size();
let (rw, rh) = (rw as f32, rh as f32);
let to_px = |uv: (f32, f32)| (uv.0 * rw, uv.1 * rh);
let active: Vec<&Spot> = self.active().collect();
let mut passes = Vec::new();
for (round, group) in self.rounds(aspect).into_iter().enumerate() {
let mut storage: Vec<[f32; 4]> = Vec::with_capacity(group.len() * 2);
let mut reach: f32 = 0.0;
for index in group {
let spot = active[index];
let centre = to_px(framing.output_at(spot.centre, sw, sh));
let from = to_px(framing.output_at(spot.source(aspect), sw, sh));
// One radius along y in frame units is one radius along y in
// normalised coordinates, which is why the probe point is built
// this way rather than from the offset.
let rim = (spot.centre.0, spot.centre.1 + spot.radius);
let rim_px = to_px(framing.output_at(rim, sw, sh));
let radius = (rim_px.0 - centre.0).hypot(rim_px.1 - centre.1);
// Where the edge begins to fall away. Always at least half a
// pixel inside the rim: a disc with a genuinely hard edge
// aliases into a visible polygon, and half a pixel of ramp is
// finer than any feather control can ask for anyway.
let inner = (radius * (1.0 - spot.feather)).min(radius - 0.5).max(0.0);
storage.push([centre.0, centre.1, radius, inner]);
storage.push([
from.0,
from.1,
spot.opacity,
match spot.mode {
SpotMode::Heal => 1.0,
SpotMode::Clone => 0.0,
},
]);
// How far this pass reads from a pixel it writes: across to the
// source, plus the disc it reads there. Stated honestly even
// though it is large — an understated radius shows as a seam at
// every tile boundary, which reads as a driver bug (ARCH §5.3).
let across = (from.0 - centre.0).hypot(from.1 - centre.1);
reach = reach.max(across + radius);
}
if storage.is_empty() {
continue;
}
passes.push(DetailPass {
label: round_label(round),
radius: reach.ceil() as u32,
wgsl: SPOT_BODY.to_string(),
uniforms: vec![crate::operation::Uniform {
name: "rim_samples",
value: RIM_SAMPLES,
}],
storage,
});
}
passes
}
}
/// A static label for round `n`.
///
/// Static because a pass label is a `&'static str`, and rounds past the few
/// named here are rare enough — each one needs a source deliberately placed
/// over an earlier repair — that sharing a label between them costs nothing but
/// a slightly vaguer line in a profiler.
fn round_label(round: usize) -> &'static str {
match round {
0 => "round0",
1 => "round1",
2 => "round2",
3 => "round3",
_ => "round",
}
}