Draw the repairs, before anything sharpens what they removed

A spot set now composes detail passes of its own, one per round, and they
go ahead of every operation's kernel. That placement is the decision worth
recording: a sharpening pass reads a neighbourhood, so sharpening a dust
mark before removing it smears its edge into pixels the repair's disc does
not cover, and what survives is a faint over-sharpened ring around an
otherwise perfect patch. It also disagrees with ARCH §5.2, which draws
spot removal after clarity — docs/spot-removal.md §5.1 is where that is
argued out.

Every length reaching the shader is in render pixels, converted here where
the framing is in scope. Both the centre and the source go through
`Framing::output_at` — the same map the fused pass applies to every pixel
— so a rotated photograph rotates the offset with no trigonometry, and the
radius is found by mapping a point one radius above the centre and
measuring, rather than by multiplying by a ratio this function has no
business knowing about. The tests turn and crop the frame and expect the
mark to stay gone, which is the property that arrangement buys.

compose_full now takes the spot set, because a photograph with a repair
and no sharpening still has a detail stage: a fused pass that encoded its
own output there would quantise twice and bind to a texture of the wrong
format. compose_detail_for takes the source size for the same kind of
reason — a RenderScale describes the region on screen, and a spot is
stored against the photograph.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 20:17:51 +02:00
co-authored by Claude Opus 5
parent 6997c0f7ac
commit 5323608051
17 changed files with 597 additions and 49 deletions
+20 -6
View File
@@ -467,7 +467,13 @@ pub fn compose_with_framing(
framing: &Framing,
output: ColourSpace,
) -> ComposedShader {
compose_full(ops, framing, output, &MaskStack::new())
compose_full(
ops,
framing,
output,
&MaskStack::new(),
&crate::spot::SpotSet::new(),
)
}
/// TRACES: FR-DEV-3
@@ -487,6 +493,7 @@ pub fn compose_full(
framing: &Framing,
output: ColourSpace,
masks: &MaskStack,
spots: &crate::spot::SpotSet,
) -> ComposedShader {
// Active *point* operations. A neighbourhood operation is filtered out
// here rather than asked for a fragment it cannot write: it reads pixels
@@ -506,11 +513,18 @@ pub fn compose_full(
// than from a flag the caller sets, because a caller that got the flag
// wrong would produce a shader whose storage format does not match the
// texture bound to it.
let output_mode = if ops.iter().any(|o| o.is_active() && o.detail().is_some()) {
OutputMode::LinearWorking
} else {
OutputMode::Encoded
};
//
// TRACES: FR-DEV-8
// The repairs count too, and they are the reason this takes a spot set at
// all: a photograph with a spot on it and no sharpening still has a detail
// stage, and a fused pass that encoded its own output there would quantise
// twice and be bound to a texture of the wrong format.
let output_mode =
if ops.iter().any(|o| o.is_active() && o.detail().is_some()) || !spots.is_neutral() {
OutputMode::LinearWorking
} else {
OutputMode::Encoded
};
// Whether an operation has taken over the rendering. Decided from the
// operations for the same reason `output_mode` is: a caller that got it