//! 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 { 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, } 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 { 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 { 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 { 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> { let active: Vec<&Spot> = self.active().collect(); let mut rounds: Vec> = Vec::new(); let mut current: Vec = 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) -> vec3 { let last = vec2(textureDimensions(source)) - vec2(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(0.5); let base = floor(q); let f = q - base; let i0 = clamp(vec2(base), vec2(0), last); let i1 = clamp(i0 + vec2(1), vec2(0), last); let s00 = textureLoad(source, vec2(i0.x, i0.y), 0).rgb; let s10 = textureLoad(source, vec2(i1.x, i0.y), 0).rgb; let s01 = textureLoad(source, vec2(i0.x, i1.y), 0).rgb; let s11 = textureLoad(source, vec2(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(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(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(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 { 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", } }