Hold the repairs a photographer makes, and say which may share a pass

A spot is a disc, a source offset and four numbers, and it lives beside
`ops` for the reason `masks` and `film` do: the operation trait is
ParamId -> f32, and a list of repairs is neither scalar nor fixed.

Two decisions here are not obvious. The id is derived from the position
rather than counted, because two devices editing offline would each mint
`spot3` for different marks and the sidecar merge would then treat two
repairs as one — from the position, two devices that removed the same
piece of dust agree, and two that removed different ones do not. And
every length is in the frame's isotropic units, not a mixture of those
and shorter-edge fractions: one unit for the radius, the feather and the
offset agrees on a landscape frame and on a portrait one, where a mixture
only agrees on the first.

`rounds` is the arithmetic that keeps a source from reading a
destination. Every spot in one pass reads the photograph as it stood
before that pass, so a spot sourcing from an earlier spot's destination
would copy the mark that spot was removing. Grouping is not a pass per
spot — that is sixty-four dispatches for a case that almost never arises
— it is a new round only when the sources actually collide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 19:50:00 +02:00
co-authored by Claude Opus 5
parent 8d8d6491ad
commit 97479a0512
6 changed files with 794 additions and 18 deletions
+17 -6
View File
@@ -73,7 +73,7 @@ pub struct Spot {
/// What covers it, as an offset from `centre` in **frame units**
/// (y spans 0..1, x spans 0..aspect — the mask convention).
pub offset: (f32, f32),
/// Fraction of the source frame's shorter edge.
/// The radius of the disc, in frame units.
pub radius: f32,
/// Fraction of `radius` over which the edge falls away. 0 is hard.
pub feather: f32,
@@ -88,10 +88,20 @@ pub struct Spot {
is a length that means something different in the preview and in the export
([`RenderScale`]'s whole documentation is this argument). `centre` is normalised
source, so a crop, a zoom, a pan and a rotation move the spot with the
photograph and no arithmetic is needed to keep it there. `radius` is a fraction
of the **source** frame's shorter edge rather than of the rendered region, so
cropping does not resize a spot that was already placed — a dust mark is a fact
about the sensor, not about the composition.
photograph and no arithmetic is needed to keep it there.
Every *length* — radius, feather, offset — is in the frame's **isotropic
units**, `MaskSource::Radial`'s convention, where y spans `0..1` and x spans
`0..aspect`. 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. They are
lengths against the *source* frame rather than the rendered region, so cropping
does not resize a spot already placed — a dust mark is a fact about the sensor,
not about the composition.
One unit for all three, deliberately. 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 a bug that stays invisible until somebody rotates a
photograph.
**`offset` is a vector, not a second point.** Dragging the destination moves the
source with it, which is what a photographer expects when they nudge a spot half
@@ -130,7 +140,8 @@ Bounds, following `mask.rs`'s example of bounding what a sidecar can grow to:
|---|---|---|
| `MAX_SPOTS` | 64 | Beyond a few dozen the answer is to clean the sensor. Refuses rather than dropping, as `MaskStack::push` does. |
| `MAX_SOURCE_DISTANCE` | 0.5 | Frame units. Bounds the halo in §5.3, which is otherwise unbounded. |
| `DEFAULT_RADIUS` | 0.012 | About 25 px on a 24 MP frame's short edge — a dust mark. |
| `DEFAULT_RADIUS` | 0.012 | Frame units — about 25 px on a 24 MP frame's short edge, which is a dust mark. |
| `MIN_RADIUS` / `MAX_RADIUS` | 0.001 / 0.5 | Not zero, because a spot that repairs nothing reads as a broken tool; not larger, because the halo bound has to mean something. |
| `DEFAULT_FEATHER` | 0.35 | Fraction of the radius. Soft enough that a heal on a gradient sky has no visible boundary. |
## 5. Where it runs