Files
DarkRoom/core/dr-pipeline/src/spot.rs
T
dtourolle 84fade99ec Put the developer docs under docs/dev and index the folder for users first
docs/ had 26 developer documents flat beside the manual, and the two
audiences are very differently sized: most readers want the manual and
the gesture reference, a few want the register, the designs and the
measurements. The manual and gestures.md stay at the top; everything for
someone changing the code moves to docs/dev/, and the two documents that
name their own successors — the v0.1 milestone and the UI-refinement plan
— go to docs/dev/archive/ rather than being deleted, since both are still
cited. docs/README.md is the index, users first.

Every reference follows: code comments, Cargo manifests, the workflows,
the pre-commit hook, the bench and traceability tools (which locate the
repo root by docs/dev/requirements.md now), packaging, the Docker READMEs,
CLAUDE.md, CONTRIBUTING.md and the README. The matrix links one level
deeper and is regenerated. Links out of the moved documents into the tree
gain a level; a link checker over every Markdown file finds none broken.
2026-09-20 21:16:03 +02:00

798 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/dev/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/dev/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/dev/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 {
output_scale: 1,
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",
}
}