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.
798 lines
34 KiB
Rust
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",
|
|
}
|
|
}
|