fix(studio): a repair wears its band only while the pointer is on it, so the photo is not left under a wash of the tool's own

This commit is contained in:
2026-10-12 20:39:54 +07:00
parent cb198ed9fe
commit 6d437591a0
2 changed files with 47 additions and 16 deletions
+15 -12
View File
@@ -744,7 +744,12 @@ button.hint:disabled { cursor: default; }
wash turns into an opaque stripe over a warm one.
A stroke is one path, so where it crosses itself its translucency is laid once
rather than twice — that doubling-up is exactly what a row of overlapping
circles looks like. */
circles looks like.
The wash is a mark over the photo, though, and over a smooth sky a repair
reads as a colour of its own that the photo does not have — the one thing a
repair must never look like. So it is worn only while the pointer is on that
run (the mark names the repair in hand) and the photo is left bare of it the
rest of the time. */
.brush-band {
position: absolute;
inset: 0;
@@ -760,22 +765,20 @@ button.hint:disabled { cursor: default; }
stroke-linecap: round;
stroke-linejoin: round;
}
/* No run in hand here: the band is the tool's record, not the photo's own
colour, so it steps out of the way of what it was drawn over. */
.brush-band path[data-idle] { opacity: 0; }
/* A spot the band stands for: its box on the photo is still the spot's, its own
edge is the band's now. */
.brush-spot[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 a faint print of the mark it made — 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; }
and a print of the mark it made is a wash of the tool's own over the photo —
over a smooth sky, a repair that reads as a colour the photo does not have.
So an idle spot shows nothing at all: it is the ring under the pointer that
says the spot can be taken hold of again, and the photo carries only the
repair itself until then. */
.brush-spot[data-idle] { opacity: 0; }
/* 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. */
+32 -4
View File
@@ -90,14 +90,16 @@ const wmBoxOf = (mark: WmMark, fontPx: number) => {
// after a reload, an undo, or a copy of the recipe to another photo: a stroke
// is not stored anywhere.
const strokeRuns = (spots: { x: number; y: number; r: number }[], box: { width: number; height: number }) => {
const paths: { d: string; w: number }[] = [];
const paths: { d: string; w: number; spots: number[] }[] = [];
const joined = spots.map(() => false);
let run: number[] = [];
const flush = () => {
if (run.length > 1) {
const w = 2 * spots[run[0]].r * box.width;
const at = (i: number) => `${spots[i].x * box.width} ${spots[i].y * box.height}`;
paths.push({ d: `M ${run.map(at).join(' L ')}`, w });
// The run's own spots travel with the band, so the band can be shown for a
// run the pointer is on and left off for the runs it is not.
paths.push({ d: `M ${run.map(at).join(' L ')}`, w, spots: run.slice() });
for (const i of run) joined[i] = true;
}
run = [];
@@ -703,6 +705,22 @@ export function ImageStage({
})()
: null;
// Whether a run's own band is the one in hand: the pointer is on one of the
// spots the run is made of, or a press has chosen one of them. A band is the
// tool's mark over the photo and not the photo, so every other run's band —
// and every other repair's print — stays off it, and what a repair looks like
// is what the renderer made of it.
const bandOn = (b: { spots: number[] }) => {
if (selSpot !== null && b.spots.includes(selSpot)) return true;
if (!brushPos || !box) return false;
return b.spots.some((i) => {
const s = brushSpots[i];
return (
!!s && Math.hypot((brushPos.fx - s.x) * box.width, (brushPos.fy - s.y) * box.height) <= Math.max(4, s.r * box.width)
);
});
};
const brushDown = (e: React.PointerEvent<HTMLDivElement>) => {
// The stage's own hand, under the brush: the middle button and the space
// bar are how the photo is moved out of the way when the brush is down, and
@@ -1637,7 +1655,12 @@ export function ImageStage({
run is made of, joined into the one mark the drag painted.
What is drawn is the recipe's own spots, so the band is what
survives a reload, an undo and a copy of the recipe to another
photo, where a stroke of its own would not. */}
photo, where a stroke of its own would not. It, like the print
of a spot, is the tool's own record and not part of the photo:
it is worn while the pointer is on that run — the mark says
which repair is in hand — and given up again the moment the
pointer leaves, so what a repair left on the photo is read off
the photo and not off a wash laid over it. */}
{bands?.dst.paths.length ? (
<svg
className="brush-band"
@@ -1647,7 +1670,12 @@ export function ImageStage({
aria-hidden="true"
>
{bands.dst.paths.map((b, i) => (
<path key={i} d={b.d} style={{ strokeWidth: b.w }} />
<path
key={i}
d={b.d}
style={{ strokeWidth: b.w }}
data-idle={bandOn(b) ? undefined : '1'}
/>
))}
</svg>
) : null}