67f15bffb7c73884fbc571f0bb84fc42eabd83b7
8
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
0d9910efc6 | Merge branch 'worktree-agent-a89309a856c8f4947' into integration | ||
|
|
d64a61d677 |
Give the tone curve a curve for each colour channel
The declaration in ops/tone_curve.yaml has claimed per-channel curves since it was written — it is the justification for the operation carrying both `tone` and `colour`. Only the master curve existed. This is the other three. The master runs first and the channels grade its result. Both orders are real images and they differ visibly, so the choice is made and written down rather than left to the loop: a point placed on the blue curve should act on the tone the photographer can see, which is what the master has already produced. The other order anchors the grade to tones the master is about to move, so adjusting contrast slides a warm shadow up into the midtones. Every id that existed before today is spelled exactly as it was. The master curve keeps `p2_y` and the new curves take `r_`, `g_` and `b_` prefixes, so a sidecar written when there was one curve loads, means what it meant, and renders the same shader — asserted on the generated source, not on the parameter values. Nothing needed a version check because nothing was renamed. Each curve reaches the shader only when it has been moved off the diagonal, so an S-curve and no colour work generates what it generated when this operation held ten parameters instead of forty, down to the uniform names. The monotonicity guarantee is enforced per curve: a coincident pair on blue divides by zero exactly as thoroughly as one on the master. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5bcd0e0269 |
Merge branch 'worktree-agent-a22a049c461818dbe' into integration
# Conflicts: # core/dr-pipeline/tests/mask_sidecar.rs |
||
|
|
c75863c93f |
Give a gradient the angle it was asked for
A linear mask at 45° was not at 45°, and a radial with equal radii was an ellipse. Both on every photograph that is not square, which is all of them. The geometry is stored in normalised coordinates so that a mask survives a crop, a zoom and an export at another size — that part was right. What was wrong is that a *distance* was being measured in those coordinates too, and a fraction of the width is not the same length as a fraction of the height. So `dot(uv - centre, axis)` measured the ramp in a space one of whose axes is squashed against the other by the aspect ratio, and the iso-lines came out sheared: on a 3:2 frame a ramp asked for at 45° arrives at about 34°. Nothing announces it. The stored numbers are exactly what was written, the shader is doing exactly what it says, and the only place the fault exists is between the photographer's intent and the picture. It has been invisible so far because there is no way yet to place a gradient by eye — the handles that make it visible are what turned it up. So distances and angles move into the frame's own isotropic units: y spans `0..1` and x spans `0..aspect`, which makes a circle round and 45° a real diagonal. The centre stays a plain fraction of each axis, because it is a point and a point has no such problem — and because that is the space a click arrives in. `frame_delta` is the one conversion and must stay the only one; the mask array's own dimensions carry the aspect, so it costs no uniform. The sidecar format does not change. What changes is what the numbers mean, and the only geometry in the wild is a default that has never been movable. The two tests are at 96×64 rather than square, which is the whole point: on a square target this bug cannot be reproduced, and every existing mask test was square. Both fail without the conversion — the radial reaching 28px sideways where it reaches 19px down, and the diagonal landing on the wrong side of the line it is supposed to lie along. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c396a22dfd |
Paint a mask without ever rasterising one on the CPU
The last line of FR-DEV-3, and the mask ARCH §5.4 was written for. darktable rasterises drawn masks on the CPU and users call the result unworkable; the architecture's answer is that a stroke arrives as *parameters* and the device draws it. This is that, from the model through the sidecar to the pixels — but not the finger: the canvas is somebody else's change, and this leaves it a seam rather than reaching into it. **A stroke is a swept disc along a polyline**, plus erase, radius, hardness and flow. `MaskSource::Brush` holds an ordered list of them, and the order is the mask: an erase after an add takes it away and the same pair reversed does not. Nothing about it is pixels, which is what makes a mask that costs a line of text, diffs by the gesture, and survives a crop, a straighten and an export at any size — the properties a stored raster has none of, and the same argument the region ids were chosen for. Two things keep the point count honest. While the finger is down, a position closer to the last than an eighth of the radius is dropped: a touch screen reports 120 a second, so a finger held still for five seconds is six hundred points in the same place, and simplification would only remove them once the gesture had ended — after every frame in between had drawn all of them. When it ends, Douglas–Peucker at an eighth of the radius removes what a disc that wide cannot express: a swept circle moved by r/8 moves its own edge by r/8, which is inside the soft part of any brush. Coordinates snap to a ten-thousandth of the frame on the way in *and* are written at that precision, so a round trip is exact rather than nearly exact — a file that drifts in the sixth decimal every save is a per-field merge conflict a day, over nothing. **Cost is why the strokes are not drawn by the full-screen triangle the other masks use.** A swept disc is the minimum distance to any of its segments, so a stroke over the whole frame costs `pixels × segments` and both terms grow together — the quadratic that is darktable's problem moved onto the GPU rather than solved. Each stroke is instead drawn over its own bounding box, grown by the radius, so the rasteriser never invokes the shader for a pixel the stroke cannot reach: `area(box) × segments`, which for a dab or a swipe is a small fraction of the frame. A gesture past 256 points continues as a second stroke for the same reason, since a shorter stroke has a smaller box. Add and erase are `dst + a(1 - dst)` and `dst(1 - a)`, which are exactly a source-over and a one-minus-source blend — so they are blend state, not arithmetic, and no pass ever reads the slice it is writing. That is what permits one draw per stroke at all. Within a stroke the coverage is the *minimum* distance over its segments rather than a sum: a path that crosses itself must not build up where it did, or every circle and every scribble would be blotchy wherever consecutive dabs overlap, which is everywhere. Not a distance field, deliberately. `dr-segment`'s transform documents the two conditions that make CPU work right there — once per mask edit, over input already CPU-side — and a stroke fails both: it changes while the finger moves, and its input is a handful of coordinates that never needed to be pixels. It also needs no transform, because the distance to a swept disc is closed form. A stroke is the one mask whose distance field is known without computing one. An unpainted brush layer is inactive rather than empty, which is not an optimisation: `invert` turns empty into everything, so a layer created with invert already set would apply its adjustment to the whole photograph before a single stroke was made. That is the loud, confident kind of wrong this codebase refuses everywhere else a mask can go missing, and there is a rendered test for it. The tests read pixels back off a device rather than checking that the two halves agree with each other. What they pin down is what is silent when wrong: the y flip between mask space and clip space, which a centred stroke would not notice; a bounding box not grown by the radius, which makes a tap draw nothing at all; an aspect ratio ignored, which makes a dab an ellipse on any frame that is not square; a stroke doubling back and building up; and an erase that lost its place in the order and put back paint the user had taken off. Not done here: the interaction. The canvas needs to begin, extend and end a stroke on the active layer, and `DevelopSession::rasterise_masks` still returns early without a segmentation — it takes the proxy size from one, and a brush needs no model to have run over the photograph first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
924a837389 |
Group the panel by what operations say they are about
A strip of groups over the adjust panel — Light, Colour, Detail — derived from the attributes the operations declare. `adjust.slint` names none of them: the strings arrive resolved and the panel only draws them, so a new operation joins the right group by saying what it is and this file does not change (FR-DEV-3a). A group nothing carries is not offered, so a tab never opens onto nothing. Geometry is left out because its one operation prefers an on-canvas widget and is skipped by the row builder — a Geometry tab would be empty while `GeometryPanel` holds the real controls. The strip appears only when there is more than one group to choose between; a single tab is a control with one option. The selected group is underlined rather than filled. The accent means *modified* everywhere else in this interface, and spending it on "which tab" would blunt the one signal the panel has. **The trap, and it nearly bit again.** `op_index` on a row counts over every capability, not over the ones a filter kept — it is how a row routes back to the core. Renumbering it while filtering would make a slider drive a different operation, which looks like a rendering fault rather than a routing one. `rows_filtered` keeps `enumerate` over the full list and only `group_head` is a position within the emitted rows; a test moves a value through a filtered row and checks it lands where it was asked to. Six tests, including that a nonsense index falls back to showing everything rather than to showing nothing. |
||
|
|
7421837c8a |
Let an operation say what it is about, so the panel can group without naming
Tool tabs need a taxonomy, and the taxonomy was the problem: a table in `ui/` mapping operation to tab breaks FR-DEV-3a, and a `group:` field risks what `ui-refinement.md` condemned `starts-group` for — the core deciding where the panel draws things. `Attribute` threads the needle. It says what an operation *is* — tone, colour, detail, optics, geometry, effect — which is the same category as `ParamKind` and squarely on the core's side of ARCH §4.3a's line. What is drawn, where it sits and whether it is visible stay the frontend's. There is no attribute for "the third tab", the enum's order is declaration order rather than screen order, and a frontend may render these as tabs, as headings, or ignore them. The payoff is that a tab strip can be *derived*: the groups are the attributes present in the capability list, so the interface names no operation and needs no table to keep in step. An operation joins the right group by declaring what it is, which is the one thing its author is well placed to say. Plural, because the tone curve is genuinely both — an RGB curve is tonal and the per-channel curves are chromatic, and filing it under one would hide it from half the people looking for it. Required and non-empty, enforced in `build.rs`, and the failure was checked by removing the line rather than assumed. An operation with no attribute is invisible to a panel that groups by them; a build that stops costs ten seconds, a control nobody can find costs more. The vocabulary is closed for the same reason: a typo would otherwise invent a category holding exactly one operation, which looks like a deliberate one until somebody counts. Six tests over the real chain, including the hand-written operations that `build.rs` never sees and so cannot check. |
||
|
|
94cfea4748 |
Keep the mask when the app closes, and when two devices disagree
The sidecar is authoritative — the catalog is a disposable index and the RAW is never written — so a mask that does not round-trip is not a persistence bug, it is lost work. Layers get their own `[mask <version> <id>]` blocks rather than being flattened into dotted keys. A layer is not a scalar: it carries a selection, a geometry and a chain of its own, and encoding a region set as `m1.region.0 = 12` would be neither readable nor mergeable. The version uuid is repeated in the header instead of relying on the block following its version, because "belongs to whichever version appeared above me" is a relationship that hand-editing, merging and older builds each break quietly. Region ids sort and deduplicate on read rather than being trusted from the file. The mask's identity is the *set*, so two devices writing the same selection in different orders must produce the same mask rather than argue about a difference that is not one. Masks merge by layer id under FR-NC-9, which is the disjoint-survives rule the parameters already follow one level up: a layer added on the phone and one added on the desktop both survive. A layer *both* sides edited resolves wholesale to the higher revision, because half of one selection plus half of another's opacity is a layer neither person made. A remote deletion is honoured, or a mask the user removed returns on every sync. An unknown mask source is skipped rather than guessed at. Applying a newer format's mask type as the nearest one this build knows would put a confidently wrong adjustment on the photograph, which is worse than applying none. 29 new tests. The interesting ones are about silence: a maskless version clearing the previous image's layers, a bare selection persisting even though it renders nothing, and a mask naming a version that is not in the file being dropped instead of landing on whichever block was open. |