web: let a photo's repairs read as the marks they are, not as a field of rings

HEAL draws every repair it holds as the circle the shader fills — the brush's
own size at the moment it was laid — so a photo with two repairs has two rings
and a photo with twenty has twenty. Each one is loud enough to be the loudest
thing on the picture, and none of them is the one the user is looking for: the
speck that was mended, and the patch borrowed to mend it, are both inside the
ring that covers them. What the ring is for is the spot that is about to be
taken hold of, and there is only ever one of those.

A repair now wears its circle only while it is the spot in hand — the one the
pointer is over, the one just laid, which is the one the user is watching, or
the one a press chose, which is the one wearing the ×. The moment the pointer
walks elsewhere the ring goes, and what is left is the pair the renderer works
with: the patch it borrowed, still dashed, and a soft print of the place it
mended. The ring comes back under the pointer, which is where the spot is taken
hold of again; the grab was always the geometry — the circle plus a few pixels
of slop — and never depended on the ring being drawn, so an idle spot is as easy
to move as a ringed one (measured: a 60,40px drag on an idle spot moved it
970.2,390.7 -> 1030.3,430.7).

The run a stroke left is the mark of that stroke, so the spots the band stands
for keep their boxes and give up their own edges as before, and no print is laid
under the band: a row of blurred discs under one translucent band is a second,
blurrier band. The one spot of the run that is in hand wears its circle again
over the band, because that is the spot a press would take hold of.

MOSAIC keeps its rings. Its spots are not repairs to be placed and moved — the
circle is the only thing that says where the cover is, and the cover is the
edit.

ponytail: the print is a blurred translucent disc (14% grey, a soft dark
shadow, 1px of blur) rather than a tint read off the pixels under it, so it
reads on a photo of any tone without a second pass over the render — the
upgrade path, if a print that sits on the repaired pixels themselves is wanted,
is the shader the repair already runs through. The ring under the pointer is
reported from the pointer's own move events rather than from a hit test per
frame, so the one case it does not cover is the pointer that has not moved since
the repair landed; that case is covered by the spot just laid being in hand, and
a twitch of the mouse covers it everywhere else. The idle/hover split is HEAL's
only: MOSAIC's spots keep the border they always had, which is the asymmetry
this tool set already had about moving them.

Verified: heal-idle-probe.cjs (new, 23 checks) 23 PASS / 0 FAIL on :5199 and on
:8090 after deploy — a press on the speck lays one repair and that repair is in
hand, so it wears its circle (rgba(255,255,255,0.85)); a repair the pointer has
left is idle with the ring given up (rgba(0,0,0,0)), the print of the place it
mended left (rgba(127,127,127,0.14)) and the patch it borrowed still dashed and
in the same place (640.3,297.9 vs 640.4,297.9); the ring comes back under the
pointer; a press chooses it, puts its × on the photo and the × stays while the
pointer walks off; laying the next repair takes the choice and the × off the
first and gives its ring up; hovering the second leaves the first idle; a drag
still takes hold of an idle spot; a run of 13 spots shows the band, no print
under it and no ring while the pointer is away, and the spot of the run under
the pointer wears its circle again; a MOSAIC spot keeps its ring; 0 page errors.
brush-edit-probe.cjs 33/0 — its "every spot of the run gave up its own edge" now
reads "but the one in hand", which is the rule this commit adds, and its "a spot
with no neighbour keeps its own circle" passes off the repair just laid being in
hand. heal-zoom-drag-probe 28/0 (the wheel, the pan, the × and taking hold of a
repair, at the fit and at x1.52, unchanged), heal-blotch-lab 12, heal-edge-lab 9,
heal-seam-lab 10, heal-skia-lab 28, heal-search-lab 15, heal-probe 49,
heal-zoom-geom 5, heal-zoom-probe 8, mosaic-skia-lab 27, mosaic-probe 51 — all
green on :8090. Regression: landing-test 172/0, pro-gate-test 27/0,
award-column-probe 18/0, otp-code-probe 10/0, tone-curve-probe 42/0, rc=0.
Backend npm test 180 passed, 0 failed. npx tsc --noEmit clean.
This commit is contained in:
2026-09-24 08:10:27 +07:00
parent 53554ce42a
commit 01af863fd8
2 changed files with 59 additions and 6 deletions
+18
View File
@@ -493,6 +493,24 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
edge is the band's now. */
.brush-spot[data-band],
.brush-source[data-band] { border-color: transparent; box-shadow: none; }
/* A repair the pointer is not on and no press has chosen: the ring would be one
more circle on a photo that already carries as many repairs as the user laid,
so an idle spot is the patch it borrowed and a faint print of the place it
mended — soft, because it is a record of an edit rather than something to
aim at, and it is the ring under the pointer that says the spot can be taken
hold of again. */
.brush-spot[data-idle] {
border-color: transparent;
background: rgba(127, 127, 127, 0.14);
box-shadow: 0 0 7px 2px rgba(0, 0, 0, 0.25);
filter: blur(1px);
}
/* A run the band already stands for needs no print of its own under it. */
.brush-spot[data-idle][data-band] { background: none; box-shadow: none; filter: none; }
/* In hand, one of the run's spots is the circle it would be taken hold of by, so
it wears that circle again over the band — the ring is what says the spot is
under the pointer. */
.brush-spot[data-band]:not([data-idle]) { border-color: rgba(255, 255, 255, 0.85); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.45); }
/* The × on the spot the pointer chose. It sits on the spot's corner and is the
one thing on this layer the pointer is meant to click, so it takes the
pointer back off the paint. */
+41 -6
View File
@@ -448,6 +448,12 @@ export function ImageStage({
// a press that changed nothing costs none.
const grabRef = useRef<{ i: number; part: 'dst' | 'src' } | null>(null);
const [selSpot, setSelSpot] = useState<number | null>(null);
// The spot in hand: the one the pointer is over, or the repair just laid,
// which is the one the user is looking at. A HEAL spot wears its ring only
// while it is in hand or chosen; the rest of them fall back to the patch they
// borrowed and a faint print of what they mended, so a photo with a dozen
// repairs is not a photo with a dozen rings.
const [handSpot, setHandSpot] = useState<number | null>(null);
const undoPushedRef = useRef(false);
// The same decode the eyedropper keeps, under the same rule: one bitmap per
@@ -519,7 +525,10 @@ export function ImageStage({
brushSpots.filter((_, j) => j !== i),
true
);
// The indices move up behind the one that went, so neither the choice nor
// the spot under the pointer is a spot any more.
setSelSpot(null);
setHandSpot(null);
};
// The band each stroke left, read off the spots the recipe holds — the holes
@@ -613,6 +622,10 @@ export function ImageStage({
return;
}
const stroke = strokeRef.current;
// Which repair is the one in hand, for the circle it wears: the spot under
// the pointer. Only while nothing is being painted or dragged, so a stroke
// does not flicker the rings it runs over.
if (!stroke) setHandSpot(p ? (spotUnder(p)?.i ?? null) : null);
if (!stroke || !p) return;
const last = stroke.pts[stroke.pts.length - 1];
// The spacing is a fraction of the brush, measured in the photo's own
@@ -659,7 +672,12 @@ export function ImageStage({
return src ? [{ x: p.fx, y: p.fy, r: brushR, sx: src.sx, sy: src.sy }] : [];
});
onBrushSpots(spots);
}, [brushR, brushTool, previewReader, onBrushSpots]);
// The repair just laid is the one in hand: the pointer has not moved since,
// so no move has reported a hover, and without this the ring of the spot the
// user is looking at is the one thing that would not be there. It gives the
// ring up again the moment the pointer walks anywhere.
if (spots.length) setHandSpot(brushSpots.length + spots.length - 1);
}, [brushR, brushSpots.length, brushTool, previewReader, onBrushSpots]);
// The wheel over the brush is the stage's own, as it is everywhere else in
// the app: a repair is aimed at a zoom, and the wheel is the gesture a user
@@ -673,7 +691,10 @@ export function ImageStage({
// The × belongs to the tool that drew it: arming the other brush puts the
// spot down.
useEffect(() => setSelSpot(null), [brushTool]);
useEffect(() => {
setSelSpot(null);
setHandSpot(null);
}, [brushTool]);
// The img's box at scale 1 — the natural size fitted into the wrap, never
// enlarged. Computed rather than measured so the transform cannot feed back
@@ -1164,10 +1185,12 @@ export function ImageStage({
{/* FX's brush — HEAL or MOSAIC. The layer swallows the pointer the way
the eyedropper's does, so a click paints instead of panning the
photo and a drag paints a run; the circle rides the cursor at the
size a spot will be, the wheel changes that size, and every spot
already laid down keeps its own circle on the picture. A HEAL spot
size a spot will be, and the wheel changes that size. A HEAL spot
shows the pair the renderer works with — the circle it fills and
the patch it borrowed; a MOSAIC spot has nothing to show beside
the patch it borrowed — and wears that circle only while it is the
spot in hand: the repairs already on the photo read as the marks
they left rather than as a field of rings, and the ring comes back
under the pointer. A MOSAIC spot has nothing to show beside
itself, since what is under it is gone. */}
{brushTool && !cropEditing && box ? (
<div
@@ -1188,12 +1211,16 @@ export function ImageStage({
brushDown(e);
}}
onPointerMove={brushMove}
onPointerLeave={() => setBrushPos(null)}
onPointerLeave={() => {
setBrushPos(null);
setHandSpot(null);
}}
onPointerUp={brushUp}
onPointerCancel={() => {
strokeRef.current = null;
panRef.current = null;
setBrushTrail([]);
setHandSpot(null);
}}
>
{/* The stroke being painted: the run the pointer has drawn so
@@ -1263,6 +1290,13 @@ export function ImageStage({
// photo — it is still the spot the shader works from — and gives
// up only its own edge, which is the whole of what a row of
// circles was showing.
//
// A HEAL spot that is neither in hand nor chosen is idle: it
// keeps the patch it borrowed and a faint print of the place it
// mended, and gives up the ring, which comes back under the
// pointer — that is where the spot is taken hold of again, so
// nothing about it is lost.
const idle = brushTool === 'heal' && i !== selSpot && i !== handSpot;
return (
<span className="brush-set" data-key="brush-set" data-i={i} key={i}>
{'sx' in s ? (
@@ -1277,6 +1311,7 @@ export function ImageStage({
className="brush-spot"
data-key="brush-spot"
data-band={bands?.dst.joined[i] ? '1' : undefined}
data-idle={idle ? '1' : undefined}
style={at(s.x, s.y)}
/>
</span>