Commit Graph
30 Commits
Author SHA1 Message Date
dtourolle 8a1d9c8642 Find the canvas tools by the condition they are gated on now
The download fix renamed the canvas gates to root.has-photo, and the
canvas-order test still searched for the old spelling, so it panicked
before checking anything. The order it guards is unchanged.
2026-09-26 11:58:30 -04:00
dtourolle d489a34190 Drive develop, the grid, the sidebar and People from the keyboard
An audit of every action by view against the keys the handlers bind left
develop without zoom, pan, fit or a way back to the grid, the grid without
select-none, thumbnail size or keywording, People with no key at all, and
the export and copy sheets without Enter. It also found the reverse gap
FR-UI-5 forbids: pick and reject had no route but P, X and U, and the
2026-09-19 amendment's judging in develop had not been built.

Develop: Ctrl+= and Ctrl+Plus zoom in and Ctrl+- out about the middle of the
view, Ctrl+0 fits and Ctrl+1 goes to 1:1, Shift and an arrow pan a magnified
view, G goes back to the grid, Ctrl+Y redoes, and Enter keeps a crop that hid
a mask. 0-5, P, X and U rate and flag the open photograph without moving on,
with stars and Pick/Reject in the top bar as the pointer and touch route.
= and - nudge the control last moved by a hundredth of its travel; the
framing sliders, perspective included, now count as "last moved", so R puts
them back as well. J turns the selected mask part's join chip.

Grid: Ctrl+D and Ctrl+Shift+A clear the selection, = and - resize the
thumbnails, Ctrl+K opens keywording, and Flag in the selection bar gives
pick and reject a pointer and touch route. Sidebar: Enter commits a
collection's name, and Enter or Escape hands the keyboard back to the grid,
where it used to go nowhere until something was clicked. People: Up and Down
walk the rail, F2 puts the name field under the keys, and Escape or Back now
leave the screen the way its back button does instead of doing nothing.
Sheets: Enter does what the export or copy sheet's button does.

The choices follow Lightroom where it has one. No new key steals typing: the
grid's and People's keys live on focus holders that are not ancestors of any
text field, and the sheets' Enter comes after a focused field has had it.
Every binding is tagged beside its handler, and the gate added in the
previous commit holds the two to each other.
2026-09-24 23:42:26 -04:00
dtourolle 8f3df7b68d Format the intersect panel test as rustfmt lays it out
The chained lookup in the_intersect_button_joins_a_part_that_intersects was
one line past rustfmt's width, so fmt --check failed on the branch. Split
as rustfmt wants it; no behaviour changes.
2026-09-24 21:25:48 -04:00
dtourolle 5f0b7ac799 Offer intersection in the mask panel: an Intersect button and a third chip state
The pipeline could now keep only where two selections agree, but the panel
had no way to ask for it: the part row's chip flipped between + and -, and
the buttons under the parts joined an added or a subtracted correction.

An "∩ Intersect" button joins a painted part that intersects, and the chip
on a part row cycles + -> - -> ∩ and round, so an existing part can be
turned into an intersection without being repainted. Both go through the
same session calls as before, indexing Join::ALL, whose first two entries
kept their places. The chip is now a tagged gesture, so it is in the
gesture book.
2026-09-24 21:25:48 -04:00
dtourolle 59605f9fbb Split masks_ui::wire into one function per section
wire() registered every mask-panel callback in one 778-line function
behind section comments. Each of the seven fenced sections (computing
the region map, refining a subject's mask, dragging a gradient,
selecting on the photograph, the stack, the edge treatment, adding
layers) becomes its own function, called from wire() in the original
order with the section's own comment kept as its doc comment. The
"adding layers" section was itself over 300 lines and had no further
section markers inside it, so it is split at its own natural seam
between painting/viewing a mask (wire_layers_paint) and working the
parts and add-mask buttons (wire_layers_parts); the second half gets an
introductory doc line since there was no comment of its own to reuse.
Locals declared just for one section's closures (running, refining,
dragging) move into that section's function instead of staying in
wire().
2026-09-20 17:24:42 +02:00
dtourolle 4574c35236 Let a part be left out of a mask without being taken out of it
A layer built from parts was missing the one control a correction most
often wants: seeing what it did. The question a subtracted gradient
raises is whether it took only the sky, and the question a stroke raises
is whether it filled the shoulder — and the only way to ask either was
to remove the part and look, which answered the question and lost the
part. The layer's own ring answers a different question, about the
adjustment, and hiding eight layers to check one correction is not an
A/B anybody performs.

So a part carries `hidden`. It is an edit and a history step, as the
layer's switch is, and it is folded into the render fingerprint because
hiding a part changes the mask as surely as removing it does. Where the
mask is built the shown parts are walked rather than the parts, which
is what makes a hidden base hand the fold to the first part that is
shown — and a revealed layer whose every part is hidden clears its slice
rather than leaving whatever the last rasterisation put there to be
read back. `covers` asks the same shown parts, so a layer whose only
adding part is hidden costs no slice at all.

In the sidecar the key is `hidden`, in the part's block or, for the
base, in the mask block — under a word that cannot be confused with the
layer's `enabled`, which has always meant the layer. Absent means shown,
so no file written before the switch existed reads any differently.

The row wears the same ring the layer does, one row down, because it is
the same question about a smaller thing.
2026-09-12 01:08:10 +02:00
dtourolle 9cc52fd72b Bind the two develop gestures that were described and not bound
FR-DEV-16's book said resetting a control and hiding a mask layer were
reachable by pointer and by finger, and stopped there. The reason was
honest: the generated rows have no focus, so "reset the focused control"
named a thing the panel could not point at. But a photographer at the
keyboard means something narrower than focus. They mean the slider they
just dragged too far, and that is a thing the panel can remember.

So the Adjustments global keeps the last control moved — two indices,
written where the panel forwards the change and cleared when the next
photograph opens, so a reset cannot reach back into the previous edit
through an index that happens to be shared. R puts it back, through the
same callback the track's double-click takes, and is silent until
something has moved.

The mask layer needs no such notion, because the panel already has a
selection: the rows the edge controls point at. H hides or shows those,
through the path the ring at the head of the row takes, so it is an edit
and a history step exactly as the ring is. A mixed selection goes to
shown, since the layer nobody can see is the one being asked about.

Both tags now carry the key, and the book says so.
2026-09-12 01:08:09 +02:00
dtourolle a87139b838 Give every mask an eye and a colour, and put the brush where the mask is
The first build of seeing a mask showed the selected layer's, in one global
style, from a strip at the top of the panel. It answered the wrong question and
answered it somewhere nobody looked. What a photographer asks of two masks is
how they meet — where the sky's edge sits against the building's — and that
needs both on screen at once, in colours that can be told apart.

So each row of the stack has an eye, drawn in the colour its mask is shown in,
and each mask has six swatches to choose that colour from. Several can be open
at once; a new one comes up open, in the first colour nothing else is using.
The style — tint, alpha, outline — is the one setting that stays global, above
the stack, because three styles at once are three pictures that cannot be read
against each other. Alpha now draws every shown mask, each in its colour, on
black. In the pipeline a `Reveal` is a list of `(layer, colour)` rather than
one layer, and every reveal block carries its own colour.

The brush moves too. Select, Paint and Erase and the three sliders under them
sat at the top of the panel, appeared only once a row was selected, and said
nothing about which mask they acted on — so "how do I paint" and "how do I
correct the model's outline" both had the same answer and nobody found it.
They sit under the selected mask's parts now, beside the swatches, and on a
subject or a category the hint says what a stroke there does: it becomes a
part of this mask, joined to the model's, and can be taken out again.

Eyes and colours are viewing state, on the session and not on the layer, so a
photograph reopened has every eye closed — the stored-mask round-trip test
asserts it.
2026-09-11 19:03:51 +02:00
dtourolle ac0aea70ec Show a mask as soon as it is made
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m52s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 39m53s
Build and test / Layer separation (push) Successful in 1m0s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 4s
Traceability / Requirement traces (push) Successful in 46s
Build and test / Android (aarch64) (push) Successful in 25m19s
Choosing a category is asking what it selected, and for a subject or a
category that question had no other answer on screen: the model's outline is
not derivable from anything visible, a fresh layer carries no adjustment to
judge it by, and the list it was chosen from says "architecture 23%" without
saying which 23%. The control that draws the mask existed but had to be found
and pressed, a panel's height away from the list the choice was made in.

So a new layer arrives with its mask showing, from the resting position only.
Somebody who has chosen the alpha or the outline keeps it, and nothing re-arms
in the background — every caller is a press that asked for a new mask.

That makes the canvas depend on how a layer arrived, which is correct and
worth stating: a session that has just made a mask draws a frame that a
session which read the same mask out of a sidecar does not. Viewing state is
not edit state and does not travel in a file, and
`a_stored_mask_renders_exactly_what_the_model_rendered` now says so at both
ends.
2026-09-10 20:56:11 +02:00
dtourolle 5d175cc668 Let a press on the photograph reach the tool that was armed for it
The brush did nothing, and neither did three other things nobody had tried
lately: clicking a subject on the photograph to select it, placing a repair,
and sampling a neutral. All four are TouchAreas over the canvas, and all four
sat behind the pan/zoom area, which is full-canvas and enabled for everything
but a crop. It took every press in the viewport and they were never offered
one.

Slint hit-tests siblings front-to-back (`send_mouse_event_to_item` visits
children `TraversalOrder::FrontToBack`), a TouchArea answers `GrabMouse` on any
press it is enabled for, and the first grab aborts the traversal. Front means
*last declared*. Each of the four carried a comment saying it sat "above the
pan/zoom area so a click reaches it first" — true of the order they were
written in, and backwards.

Nothing about the geometry decides this, so nothing about the geometry could
have fixed it. The pan area is declared first now, as the backstop it always
meant to be, and the rule it leaves behind is that the general case goes above
the specific ones. `GradientHandles` is the other end of that rule and is why
dragging a handle has worked all along while everything between it and the pan
area did not.

The order is asserted in a test, because this is a fault that compiles, passes
every other test, and silently removes four tools at once.
2026-09-10 20:56:10 +02:00
dtourolle 76ad667fd6 Offer a mask that is nothing but a hand
Every route to a layer began with a selection — a gradient, a band, a subject,
a category — and painting was reachable only by making one of those and
joining a painted part to it. So the answer to "brush a correction onto this
corner of the sky" was "add a radial gradient you do not want, then paint into
that", which is not an answer.

Paint sits beside Linear and Radial and makes a layer whose base is a brush.
It covers nothing until a stroke lands in it, so pressing it arms the brush
and shows the mask as well: a row that appeared and changed no pixel, with the
pointer still in "select", is indistinguishable from a button that did nothing.
2026-09-10 20:28:09 +02:00
dtourolle c045702a47 Show the photographer the mask they are shaping
Nobody can refine an edge they are not being shown. The only thing drawn on
the canvas was the region overlay — a false-coloured picture of what the model
*detected* — which knows nothing of a layer's feather, its falloff, its
morphology, its invert or its opacity, and nothing at all about a gradient, a
range or a stroke. Every control added for mask editing therefore acted on
something invisible, which is why the whole feature reads as absent rather
than as unfinished.

A layer's finished mask now draws over the photograph in one of three styles:
a tint for whether the right thing is selected, an alpha for where the edge
is, an outline for whether that edge is registered against the detail the
other two hide.

The hard part is not the shader. A selection with no adjustment on it changes
no pixel, so it is not active, so it holds no slice of the mask array and is
never rasterised — and that is exactly the layer somebody wants to look at,
for the whole of the time between choosing a subject and deciding what to do
to it. So `MaskStack::rendered` is `active()` plus the layer being looked at,
and the rasteriser, the composer and the distance-field builder all index by
position in it. Which is also why the design's "two uniforms, no recompile" is
not available: a uniform can select a slot, it cannot conjure one.

The reveal is never on the graph. It reaches the pipeline as an argument to
`compose_revealing`, and `compose_for` — which the exporter, the thumbnail and
the neutral probe all call — has no way to ask for one. A flag on the graph
would have been shorter, would have type-checked, and would have been one
forgotten reset away from a red tint baked into an exported file.

And the tools that shape a mask now arm. `Masking.tool` is an `in` property
only Rust may write, and the handler wrote nothing back, so the strip reported
"Select" however many times Paint was pressed and the paint area was never
enabled — the brush, the parts and the whole of FR-DEV-19b reachable from no
control in the application.

The region overlay stands down while a mask is being shown, and its button now
says what it hides: two overlays that look alike and mean different things is
worse than either.
2026-09-10 20:28:06 +02:00
dtourolle 404fea47a8 Wrap the lines the merge resolution left long
Benchmarks / CPU and I/O (per commit) (push) Successful in 2m59s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 33m34s
Build and test / Layer separation (push) Successful in 55s
Traceability / Requirement traces (push) Successful in 42s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Successful in 23m43s
`cargo fmt --check` failed the desktop job, on three files and for one reason:
routing the mask handlers through the `Masking` global was done by substituting
the call prefix, which is a text edit rather than a Rust one. It left
`window.global::<Masking>().on_part_join_picked(...)` on a line that had been
short enough as `window.on_mask_part_join_picked(...)` and no longer was.

Formatting only. The whitespace-stripped source is identical in the two `ui/`
files; the third differs by the trailing commas rustfmt adds when it breaks a
call across lines.

The matrix moves with it, because the tags shift by a few lines and the check
compares line numbers.
2026-09-08 08:57:32 +02:00
dtourolle 0a5eab0487 Wire the develop panels through globals, so a second copy is one line
Every panel in the develop column declared its inputs and its callbacks and
had `app.slint` bind each one to a property or a callback on the window root.
That is fine while a panel is drawn once. N9 draws them a second time, in the
portrait dock, and the wiring is what would have to be copied: `MaskPanel`
alone ran to forty lines of forwarding, and a callback added to one copy and
not the other compiles, renders, and simply does nothing on the layout nobody
was looking at.

So the wiring moved to Slint globals. A panel reads the global and calls the
global; Rust hooks the global instead of the window; and the instantiation in
the column is now the panel's name and a pair of braces — every one of the ten
children of the column, with no property that differs by placement left to
supply.

There is a global per panel family rather than one for all of them, and the
reason is an import cycle. Each panel's model struct — `ParamRow`, `MaskRow`,
`HistogramView` — is declared in the panel's own file, so a single global
holding `[MaskRow]` and `[ParamRow]` would have to live in a file importing
`masks.slint` and `adjust.slint` while both imported the global back, which
Slint rejects. Breaking that needs six model declarations relocated, which is a
change to the data model and not to the plumbing this is about. A global beside
the panel it serves also lets each name drop the prefix it was carrying only
because the window root is one flat namespace: `root.spot-radius` is
`Repair.radius`, and `root.peaking-on` is `Peaking.showing`.

`session.slint` is new and holds the two facts every family needs and none of
them owns: whether there is an open photograph to edit, and which mode the view
is in, with the three readings of the mode derived once instead of at each of
the dozen places that tested one. `ViewMode` moves there from `adjust.slint`,
where it was only ever a lodger.

Nothing on screen changes. What is not here: the tool rail and the status strip
still take their properties at the instantiation, because they are drawn once
and N9 does not copy them; the preset sheet's own state stays on the window,
because the library grid opens the same sheet and a global cannot bind the
window's state — which is why `Transfer.open-presets` is handled in
`presets.rs`, beside the summary it already had to compute.
2026-09-07 20:01:01 +02:00
dtourolle df741a8a49 Let one mask be built from more than one selection, and paint into it
A mask the model draws arrives approximately right — stopping inside a
shoulder, leaking into the hair — and FR-DEV-3's edge controls move the
*whole* boundary, so no value of feather or dilation fixes two errors that
go opposite ways. What fixes them is a second selection joined to the first,
and a layer that held exactly one source had nowhere to put one. The brush
the core has had all along was reachable from no control in the application.

A layer is now an ordered list of parts. Each names a source and how it
joins the mask before it — added to it, or taken out of it — and carries its
own edge treatment, because a model's soft coverage and a stroke painted
where it stopped short do not want the same feather. Invert and opacity stay
on the layer, where the composed shader already reads them.

The sidecar grows `[part]` blocks and nothing else. A layer of one part
writes exactly the bytes it always did; a mask block with no part blocks
after it reads back as one part; and a stroke, a join or a source this build
cannot read costs that part rather than the layer. So every sidecar in every
library still parses to the edit it always was.

On the device the parts fold into the layer's one slice, so eight layers
still cost eight channels: union is a `max` blend and subtraction is the
erase blend the brush already used. A part is drawn into a scratch texture
before it is joined, and that is not incidental — an erase stroke means a
hole in *that part*, not a hole in the mask, and drawn straight onto the
accumulator it would punch through the subject underneath. A layer of one
part skips all of it and takes the path it always took.

In the interface: a part list under the selected layer with a chip saying
which way each joins, Add and Subtract beside it, a Select/Paint/Erase strip
with the brush's size, hardness and flow, and a drag on the photograph that
paints. Pressing Paint on a mask that cannot hold a stroke joins a part that
can, rather than explaining that a subject is not a brush. A whole stroke is
one step in the history.

The edge controls now shape the part that is selected rather than the layer,
which is the one behaviour change to an existing control: with a correction
selected, the feather slider softens the correction and leaves the model's
mask alone.
2026-09-07 20:00:40 +02:00
dtourolle 68ebf5d78b Let a mask start from a tone or a colour, not only a shape
Every local adjustment began from a shape: painted, drawn with a handle, or
found by a model. So the only way to hold back a sky was to draw a line near
where it ended, and the only way to warm skin was to paint round it — both of
which put the edit's edge where the photographer put a gesture rather than
where the picture changes. A gradient across a treeline halos, and an
adjustment traced round a face stops on the outline of a hand.

MaskSource grows two variants that select by what a pixel *is*. Luminance
carries two bounds on the perceptual tone scale plus a softness; Colour carries
an arc of hue, a range of chroma, and one softness for every edge of both. Five
floats and three, so they diff, sync and merge per field under FR-NC-9 exactly
as a gradient's geometry does — the property a stored raster has none of, and
the reason the model's coverage had to sit beside its source rather than inside
it.

The pixels are the shader's business and nowhere else's. `mask.wgsl` takes the
demosaiced source as a sixth binding and two new modes read it: decode, balance,
pull a clipped photosite back to neutral, apply the camera matrix, then weigh
the band. Nothing crosses to the CPU but the numbers and the matrix, and each
mask texel averages its own footprint in the source, so a band lands on the tone
an area is rather than on whichever texel a proxy grid happened to land on.

The photograph it measures is the one the camera recorded, before this edit. A
band over the edited result would slide out from under the edit as the edit was
made — raising the highlights would change which pixels counted as highlights,
and the slider would chase its own mask.

Feather, falloff and morphology stay off a range layer, which is what
`shapeable` already meant. All three are functions of the signed distance from
a boundary, and a range has no boundary to be at a distance from; its edge is
the softness of its own band, in the band's units. Offering them would be four
controls that move and change nothing.
2026-09-06 19:01:48 +02:00
dtourolleandClaude Opus 5 4efab496c2 Put the refinement on a slider, per layer
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m27s
Benchmarks / Frame budget (on demand) (push) Skipped
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Desktop (Linux) (push) Failing after 1h14m30s
Build and test / Layer separation (push) Successful in 43s
Traceability / Requirement traces (push) Failing after 46s
Build and test / Android (aarch64) (push) Successful in 1h8m56s
The evidence was being gathered and spent immediately at one strictness
nobody could see or change. This makes it a control.

`MaskLayer::refine` is shaping, like the feather, and it lives on the layer
for the layer's reason: two layers may sit on the same category and want
different amounts of it, and the model ran once for both.

## What the segmentation now stores

`CategorySummary` keeps the **coarse** mask and the `Refinement` beside it,
rather than a refined mask. That is what gives the control an off position
that is bit-for-bit the model's own weighting, and what stops a strictness
change from needing the model again.

`category_mask_at` borrows at zero and wherever no refinement could be
fitted, so a layer nobody has touched costs nothing over the old path.

The fit moved to the far side of the orientation permutation. The verdict is
a per-pixel field over the same grid as the mask it gates, so fitting it
upright would mean permuting a proxy-sized buffer afterwards to match — a
second rotation, and a second chance to get one wrong. One logit cell is the
same number of pixels either way: the letterbox scales by the longer edge and
a permutation does not change which edge that is.

The two frames became a `Frames` struct rather than six parameters. This
function reads the picture twice for opposite purposes — the model needs it
upright or it recognises far less, the refinement needs the sensor's grid —
and a transposed pair produces a plausible mask over slightly the wrong
pixels, which is the failure this module is most prone to.

## It rebuilds the field, and the signature says so

`refine` is mixed into `subject_signature`. Unlike a feather, which is read
off a field that is already correct, this changes which pixels are in the
mask at all — so it changes the coverage the field is measured from. Omitting
it is the bug where the slider moves and nothing happens until some unrelated
control invalidates the cache.

That puts it in the same cost class as a close or an open, which is why the
row takes `SliderRow::changed` — already once-per-gesture, since that row
takes `SliderTrack`'s `committed` internally — rather than a live stream.

The slider is offered only where there is something to move: a category
source, *and* a refinement the frame actually gave enough to fit. A control
that moves and does nothing is worse than an absent one.

## Two defaults that are deliberately different

A layer added from the panel starts at 4.0, because a category's edges are
twenty proxy pixels wide before anything is done to them and a photographer
adding a sky mask wants the sky rather than the sky plus every chimney in it.

A layer read from a sidecar with no `refine` key starts at **zero**. A file
written before this control existed has to render as it did then, and a
default of 4 on absence would quietly re-grade every stored category mask in
the catalogue. `a_categorys_refine_strictness_survives_and_defaults_off`
holds both halves, and `an_out_of_range_refine_is_clamped` holds the file to
the scale — past the top of it every colour fails and the mask deletes
itself, which reads as lost work rather than as a bad file.

`MAX_REFINE` is dr-pipeline's own constant mirroring
`dr_segment::STRICTNESS_MAX`, following `Falloff` and `Morphology`: this
crate holds the description of an edit and must not depend on the crate that
runs a model. dr-ui is where the two meet, and the only place that converts.

Verified: fmt clean, clippy --workspace -D warnings clean, 488 dr-pipeline
and 60 dr-segment tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 18:30:40 +02:00
dtourolleandClaude Opus 5 7f350e8c4c Offer the categories in the masks panel
Everything before this was reachable only from an example that writes PPMs.
This is the part a photographer can touch: a list under the subjects, click
one, get a mask layer for every pixel of that category.

## Under the subjects, and the order is the argument

Clicking the photograph is how a local adjustment usually starts, so the
things the model *found* come first. A category is the move you reach for
deliberately — grade the sky, not this one bird — and putting it second says
so without a word of explanation.

Coverage is shown for the same reason a subject's score is: it tells the
photographer whether a category is worth a click before they spend one
finding out.

## Not a new tab, which is what was asked for

A develop tab is derived from `Attribute`, not declared — the tabs exist
because operations claim an attribute, and no amount of Slint adds one. A
seventh attribute would have meant duplicating every adjustment once per
category, and the combinatorics get silly by the third.

Reached through masks instead, a category composes with every adjustment
that already exists, and inherits feather and falloff rather than needing
its own. What was described as "per-category sliders with feather and decay"
is exactly what this is; only the door is different.

## Verified as far as it can be here

Compiles, populates, round-trips, 560 dr-ui tests green. **Not clicked** —
synthetic input is blocked on this setup, so how it looks and feels is
unverified and wants a human at the window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 14:02:34 +02:00
dtourolleandClaude Sonnet 5 b3641b5307 Let a subject's mask be refined, and let several masks be edited at once
Two related gaps in the mask panel, from the same conversation: a subject's
outline is only ever as sharp as the whole-frame pass that found it, and a
change meant for several layers had to be dragged once per layer.

## Refine mask

A "Refine mask" button on a subject layer re-runs detection on a padded crop
around that instance's own box instead of the whole frame — the subject
reaches the model at its own size rather than squeezed into the model's fixed
640x640 window alongside everything else in the photograph. `RefineJob`
mirrors `SegmentationJob`'s split (built on the session, run off it, adopted
back), and the crop itself is rendered through `Framing::set_view` — the same
ephemeral viewport the interactive zoom already uses to render a region above
proxy resolution, so no new render path and no change to the model's own
input size was needed. `dr-segment` is untouched: `Tiling::Whole` already
treats whatever buffer it is handed as the one window.

The result is still downsampled onto the shared proxy grid every instance's
mask lives on, but from a sharper source than the whole-frame pass ever saw
for that subject, which is what the edge actually reads out of.

## Multi-select

`active_mask: Option<String>` is now `active_masks: Vec<String>`. A plain
click still replaces the selection; a control- or command-click toggles one
layer in or out of it. `set_param` and `reset_op` fan out to every selected
layer, each set to the exact value the slider now shows rather than offset by
however far it already was — one slider, one reading, applied everywhere
selected. Dragging a gradient's on-canvas handle is deliberately not
extended to multi-select: several gradients have no single geometry a shared
handle could move, so `gradient_handles` stays empty unless exactly one
layer is selected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 13:17:07 +02:00
dtourolleandClaude Opus 5 7fcb8dc107 Give the develop column room, and the mask a way out of the way
Three faults reported together, and they share a shape: each is something the
panel decided on the photographer's behalf.

**The column was 280px on every screen.** That width was chosen for a tablet,
where the column is a large fraction of the display and every pixel of it is
taken from the photograph. On a desktop window the mode strip alone — two
modes, a separator, "All", and a chip per attribute the operation set declares
— does not fit, so it scrolled sideways. A control you have to pan to reach is
one you do not know is there. 380px when the window is classed expanded, 280px
when it is not; driven off `layout-class` because reading the window width
inside the layout that sets it is a binding loop.

**The overlay could not be hidden.** An earlier "Overlay" button was removed
for a good reason — it *armed* the overlay, so local mode could be entered and
still show nothing. Hiding is the opposite need and was never served: a mask is
judged against the photograph beneath it, and that photograph is exactly what
the overlay covers. `overlay-hidden` is kept separate from `overlay-on` so a
recompute cannot switch the overlay back on under someone who just turned it
off.

**The masks were coarse because the model saw the subject small.** The graph's
input is a fixed 640x640 and every frame is letterboxed into it, so a bird
200px across in a 1600px proxy reaches the model at 80px. `Tiling::Grid` has
been implemented and tested since the segmentation spike and defaulted off,
because it costs one inference per tile — 2.8s for a 3x2 grid against 470ms.
Now offered as "Look closer (slower)", which says what it costs, rather than
spending it on every image or on none.

The tiling choice enters the segmentation signature. A mask stores the
signature its region ids index into, and a tiled run finds different instances
in a different order; sharing a signature would silently reinterpret a layer
built against the coarse pass — a wrong mask rather than a stale one, and
nothing announces it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 10:54:35 +02:00
dtourolleandClaude Opus 5 c75849040c Format the tree the way the gate asks for it
`cargo fmt --check` is a required step and had drifted across 45 files. Most of
it arrived this week: several operations were written in parallel worktrees and
merged by hand, and a hand-merge resolves conflicts without ever running the
formatter over the result.

No behaviour changes — this is `cargo fmt --all` and nothing else, kept as its
own commit so the next reader can skip it wholesale rather than search it for
one that matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 21:16:34 +02:00
dtourolle 610f679881 Reconcile the three branches
Two faults textual merge could not see. Both agents added a `mod tests` to
masks_ui.rs — the module boundary was an artefact of them being written
apart, and the tests do not overlap, so they fold into one. And `segment`
lost its context argument when the work moved to a worker, which a test
written on another branch still passed.
2026-08-22 13:33:55 +02:00
dtourolle 5bcd0e0269 Merge branch 'worktree-agent-a22a049c461818dbe' into integration
# Conflicts:
#	core/dr-pipeline/tests/mask_sidecar.rs
2026-08-22 13:23:34 +02:00
dtourolleandClaude Opus 5 96a7b405c2 Say which photograph the sliders are pointed at
Selecting a mask layer silently re-points about thirty controls at that layer's
chain. Same panel, same order, same sliders, different meaning — and the only
thing that said so was a sentence in the panel above, which a photographer
reaching for the exposure slider has no reason to read. An exposure change
lands on the whole frame when it was meant for a face, or the reverse; both are
silent, and both are discovered later. `ui-navigation.md` §1.1 calls it the
dangerous one and it is: the others in that document cost time, this one costs
work.

The remedy is the classic one for a modal fault — make the mode visible — and
the application already had the pattern. Crop arms a canvas interaction, draws
an overlay, gives the column one job and is left by the control that entered
it. Local masking is the same animal built as a peer panel, and that is what
created the ambiguity. So `crop-mode` stops being a bare boolean and becomes
one value of a three-state mode, which is the point: two modes could both be on
before, and now that is not a state the interface can be in rather than one it
is tested against.

**One strip, not two.** The mode control was going to sit beside the group
strip that filters the adjustments, which is two controls above one column
answering the same question — what am I working on. They are one control now,
`Crop · Local │ All · Light · Colour`, which is the shape Lightroom Mobile's
bottom strip has for the same reason. The two halves are different kinds of
state and are drawn differently: a mode is a chip that fills with the accent
when it is on, a group is a word with a rule under it. That difference is what
lets both be read at once, which they routinely are — picking Light while a
mask is selected filters *that layer's* chain and does not leave the mode.
Dropping the scope on a group press would be the same fault coming back from
the other end, and would make Light mean two things depending on where it was
pressed.

The strip stays pinned above the develop column rather than moving to the top
of the canvas as the document proposed. The half that filters the column
belongs to the column, and the photograph is the subject. The canvas keeps one
button, which now names the mode it leaves rather than saying "Done" — that was
unambiguous with one mode and would not be with two — because the column can be
closed on a narrow window and no mode may be inescapable.

Entering a mode is a side effect, so Rust owns it rather than the strip writing
the property: crop drops the zoom, local turns the overlay on, and leaving
clears the selection. That last one is the fix. The "Overlay" and "Select"
toggles are gone because they armed things that are simply what the mode *is* —
a mode that has to be switched on separately is one you can enter and have do
nothing. Escape and the Android back gesture join `back_step` as one
`LeaveMode` rather than a second exit concept, and the mode is left before the
zoom is: it was entered later, and it is the bigger step back.

The heading is where the scope goes. Not a caption beside the panel, the
heading *of* the panel that changed — `ADJUST` becomes the layer's name, the
same string the selected row in the stack shows. That is the difference between
describing a hazard and removing it.

**Handles on the photograph.** A linear or radial mask could be created and
then not moved, so a radial sat at the centre of the frame at its default size
for ever. Three faults stood in the way of drawing one.

The first is that a gradient did not render at all until the model had run. The
rasteriser was built on the way out of `segment` and the array's size was read
*off* the segmentation, so a gradient added to an unsegmented photograph
produced nothing — silently, in the same way exports and thumbnails once did:
the shader still emits the layer's block and the empty placeholder multiplies it
by zero. The proxy size is a property of the photograph. Both are derived from
it now, and deliberately at the same size rather than by coincidence, because a
subject's distance field is sampled against that array.

The second is hit-testing. A handle is drawn in output coordinates and stored
in source ones, and between them lie the crop, the zoom, the pan, the
straightening and the turns. `Framing::source_at` is `wgsl_prologue` evaluated
on the CPU, kept in that file beside it so that keeping the two in step is one
file's problem — a handle mapped through anything less drifts off the mask the
moment the view moves, which is exactly what masks are rasterised in source
space to avoid.

The third is that a drag is a displacement, not a destination. Each handle
answers to the movement of the pointer since the press, applied to where the
mask was when the press landed. Snapping the handle to the pointer instead
jerks it by up to half a touch target on the first press, and the target is
finger-sized because a tablet has no hover to reveal a control and no modifier
to qualify it.

A ramp gets three handles — centre, width, angle. An ellipse gets three too:
centre and one per semi-axis, the major one carrying the direction as well as
the length, because where an axis is put says both. It had a fourth, and it is
gone: standing off the shape by a fixed distance, the rotation arm began
outside the photograph at the size a new radial is created at, so the first
thing anyone saw was a control they could not reach without first shrinking the
mask.

Two faults here were found by looking at the screen rather than at the source,
both of the kind that cannot be found any other way. A `1px` rule with a size
and no position is *centred* by Slint, so the seam between the photograph and
the column was a hairline down the middle of the panel, through the histogram
and every slider under it — twice, once in `app.slint` and once in
`AdjustPanel`. And handing Slint a fresh model for the handles on every pointer
event made the repeater rebuild its items, taking the `TouchArea` holding the
gesture with them: the handle jumped once and then went dead under a finger
that was still down. `develop.rs` carries the same warning about the parameter
rows, where it broke slider drags; the model is rewritten in place now.

The tests worth having are the ones about ambiguity and about the map. That the
same row reads the frame's value, then the layer's, then the frame's again is
§1.1 in one assertion. That dragging a handle onto another gradient's matching
handle *produces* that gradient closes the loop between the two directions of
the framing map, through a view that is cropped, zoomed, panned, straightened
and quarter-turned at once — a one-legged map is invisible when the framing is
neutral, because then both legs are the identity.

Not done here: the histogram still reports the whole frame while the sliders
edit a layer. That disagreement is real and is N3's, which this unblocks. The
strip has room for a Brush entry beside Crop and Local when the painted masks
land in the core, and it needs nothing here but the canvas interaction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 13:20:41 +02:00
dtourolleandClaude Opus 5 c0e1179936 Find the subjects without stopping the window
"Find subjects" took the UI thread for two thirds of a second on a 22 MP
frame — a proxy render, a readback and a YOLO pass through `ort` — and for
that time the interface was simply gone. The panel apologised for it rather
than hiding it: a "Looking…" label, and a 16 ms `single_shot` so the label
reached the screen before the freeze began, with a comment saying the obvious
fix needed the develop session restructured and was not being taken.

The obstacle was never `Send`. `DevelopSession` is `Send` — the device, the
source texture and the passes all are. What cannot go to a worker is the
`Rc<RefCell<Option<DevelopSession>>>` that every callback in the window
reaches through, and the window has to keep reaching through it while the work
runs. Handing the session over would freeze the interface exactly as
thoroughly as blocking on it did.

So the work takes a copy of what it needs instead. A `SegmentationJob` is the
device, the demosaiced source behind an `Arc`, and the name of the session
that asked. Taking one is two `Arc` bumps; running one is 495 ms on this
desktop; none of it touches the session, and there is deliberately no
`&mut DevelopSession` in scope for a caller to hold across it. The proxy
render travels with it rather than staying behind — a `GpuContext` and a
texture handle are both `Send`, and the model was never the only expensive
half. So does building the mask rasteriser, which is a shader compile:
adopting the result was costing 23 ms, a dropped frame on the one redraw the
user is waiting for, and the rasteriser is needed exactly when the subjects
arrive and never before. What is left on the UI thread is a microsecond.

The answer comes back through a channel a `slint::Timer` polls, which is the
shape `apply_when_ready` already uses for a sidecar fetch.

**A result can outlive the photograph it describes.** Two thirds of a second
is long enough to press the button, think better of it and swipe to the next
frame — and the result landing then would fill the panel with subjects that
are not in the picture, drawing outlines around a dog two photographs back.
Nothing downstream can tell: the masks rasterise and the overlay draws either
way. So every session is minted with an id, a job carries the id it was taken
from, and `delivery` compares the two before anything is applied. An id
rather than a counter beside the session slot, because that slot is written
from four places in `lib.rs` and the fifth would be the one that forgot.

A discard touches nothing on the way out. `segmenting` belongs to whichever
photograph is open now, which may well have a run of its own going, and
clearing it would re-enable a button that is correctly insensitive.

One run at a time, and abandonment is what stops that being a trap. A job
left over from a photograph the user has left is displaced rather than waited
for — otherwise the next frame's "Find subjects" would do nothing for the
length of a run nobody wants, which is the wait this exists to remove. `ort`
offers no way into the inference, so abandoning is checked at the seams there
are: before the job starts, and between the readback and the model. Abandoned
early it costs nothing, abandoned mid-inference it costs the run it was
already committed to, and either way the answer is dropped at the channel.

`DevelopSession::segment` survives as a test-only convenience. Left public it
is precisely the shape that put two thirds of a second on the UI thread in the
first place, and the next caller would reach for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 12:26:11 +02:00
dtourolle e7dbdeb21f Keep the overlay on the photograph when the view moves
The overlay is a source-space picture; the canvas beside it shows whatever
the crop, the zoom and the pan selected out of that same space. Drawn whole
it stayed frame-sized while the photograph moved underneath, so zooming in
left a map of the whole picture stretched over a detail of it.

It now reports the visible rectangle as a clip, which the compositor
applies for nothing. Resampling on the CPU instead would mean rebuilding a
megapixel image on every frame of a drag, and putting it on the GPU would
add a second texture to keep in step with the view.

Pushed from the render path rather than the panel's sync: a pan changes no
mask and no row, so nothing else needs to run, and rebuilding the row
models on every frame of a drag would be waste.

Straightening is handled by rotating the image. A quarter turn or a flip
permutes the axes and a clip rectangle cannot say that — noted where it
happens rather than left to be discovered. The proper fix is to run the
overlay through the same shader prologue the photograph goes through, which
is the right answer and a larger one than this.

Four tests, and the one that matters asserts the clip *narrows* when zoomed
— which is precisely what it failed to do.
2026-08-22 08:53:02 +02:00
dtourolle 37f63edbf9 Take the watershed out of the product path
It does not work on a photograph, so nothing should offer it. `Segmentation`
is now one model pass and what it recognised: no region field, no merge
tree, no label upload, no granularity slider, and no readback of the whole
proxy to build a graph that collapses.

A click means "the object under the cursor". The region-selection path went
with the hierarchy it indexed — including the shift-click add/subtract,
which has no meaning for a whole object and would have been a modifier that
silently did nothing.

The passes, the hierarchy and the semantic prior stay in `dr-gpu` and
`dr-segment`, tested and documented. It is the *merge criterion* that
fails — the saddle is the minimum gradient along a boundary, so one weak
pixel merges two regions and real gradient noise puts a weak pixel on every
boundary. That is one function to replace, and the evidence for replacing it
is worth keeping. What is gone is the wiring, the option, and the control
that offered a user a choice with no outcome.

`MaskSource::Regions` remains in the pipeline: it is tested, it round-trips
through the sidecar, and a stored layer that names regions must still load
and be reported stale rather than failing to parse.
2026-08-22 08:39:17 +02:00
dtourolle 6163b63895 Put the edge controls where the mask is
Feather, falloff, grow/shrink/close/open and their amount, on the selected
layer. All four read the one distance field, so all four are live — nothing
recomputes except a compound morphology, and the session keys on that
separately so a feather drag rebuilds nothing.

Shown only for sources that go through the distance field. A gradient
carries its own falloff in its geometry, and offering a second one would be
two controls fighting over the same edge.

Picking an operation seeds a small amount if none is set. Selecting "Grow"
and seeing nothing happen would read as a broken control rather than as a
radius of zero.
2026-08-22 08:39:17 +02:00
dtourolle ee10097435 Mask the subject the model found, not the regions underneath it
The watershed hierarchy does not survive a photograph, so local masking
stops depending on it. A layer can now be one recognised object, and the
object's own coverage is the mask.

`Options::watershed` defaults off. It costs ~80 ms plus a full-resolution
readback to produce a ladder that collapses, and paying that on every
photograph buys a control that misleads. Kept switchable rather than
deleted: the passes and the hierarchy are correct in themselves and it is
the merge criterion that fails, which is a change to one function.

Masks now rasterise in **source** space at proxy resolution and are sampled
by the composed shader after the framing map. That fixes a real bug: they
were rasterised in output space, so zooming slid the photograph underneath
a mask that stayed pinned to the viewport, and cropping moved every
adjustment to a different part of the picture. Doing it this way also
leaves the framing map in exactly one place — a second copy in the mask
shader would have been a second thing to keep in step, failing only when
straightened.

A subject is stored as identity, not pixels: the mask is megabytes and is
reproducible by running the same model over the same image, so the sidecar
carries the index, the class and the score, and the session carries the
pixels. The class is there to be checked — if instance 3 comes back a "car"
where it was a "dog", something changed and the layer is stale rather than
silently masking the wrong thing.

The overlay now draws instances and is transparent everywhere else. The
region version covered every pixel and so hid the photograph it was drawn
over; the question it exists to answer is whether an outline follows the
subject, which you can only answer by seeing both.

`examples/local.rs` is the worked example: subject in colour with the rest
monochrome, and the subject lifted out of its background. Run on a 5472x3648
CR2 it finds two people and two cars, and the colour-pop keeps her hat and
hair while the wall and grass behind go grey.
2026-08-22 08:39:17 +02:00
dtourolle 9b4f0815e5 Show the regions, click one, and adjust it
The local panel sits above the adjust panel because it decides what those
sliders act on; below it, a photographer would set an exposure and only
then discover which scope it landed in. Selecting a layer re-scopes the
existing controls to that layer's chain — there is no second set of
sliders, and there must not be, or every operation added to `ops/` would
need a local twin.

The overlay is drawn over the canvas rather than blended into the render,
because it is a diagnostic and not an edit: it must not reach the
histogram, an export, or the texture handed to the compositor. Nearest-
neighbour always — the map's values are *names*, so smoothing between
region 4 and region 9 invents a colour belonging to neither and softens
exactly the edge the overlay exists to show.

Picking gets its own touch area above the pan handler. Panning wants
press-drag-release and picking wants a click; interleaving them in one
handler is how a drag ends up selecting a region the user was scrolling
past. Shift is tracked as window state because a TouchArea's click carries
no modifiers.

Three states a layer can be in are worth distinguishing, and each has a
different remedy: stale needs re-segmenting, "no adjustment yet" needs a
slider moved, and the ordinary case needs nothing said. A bare selection
renders nothing and looks identical to a broken mask, which is the first
thing a new user will hit.

Known rough edge, commented where it happens: segmentation blocks the UI
thread for about half a second. Moving it to a worker needs the develop
session — GPU resources behind a RefCell shared with every callback — to
be reachable from another thread, which is a restructuring rather than a
change to the call. The button says "Finding regions…" first so the stall
is announced rather than looking like a hang.
2026-08-22 08:39:17 +02:00