Skip to content

Oval and card-edge dodge/burn masks, plus per-mask invert - #776

Merged
marcinz606 merged 3 commits into
mainfrom
feat/mask-shapes
Aug 8, 2026
Merged

Oval and card-edge dodge/burn masks, plus per-mask invert#776
marcinz606 merged 3 commits into
mainfrom
feat/mask-shapes

Conversation

@marcinz606

Copy link
Copy Markdown
Owner

Dodge & Burn had one shape: a clicked polygon. Two common darkroom moves did not fit it.

  • Burning through a hole in the card (or dodging with a wand). A smooth oval took a dozen clicked vertices and was awkward to reshape.
  • The card-edge burn — the graduated sky burn a printer makes by moving a card across the paper. It needs an unbounded ramp, which a closed polygon and a Gaussian cannot express.

Two more gaps came out of the same model: no way to act on everything except a shape, and smooth_polyline rounds every corner.

What changed

PolygonMask becomes LocalMask and gains shape and invert. The vertices stay the universal store and shape says how to read them:

Shape Vertices Outline
Polygon N control points Catmull-Rom, closed (unchanged)
Oval 3: centre, one end of each axis the unit circle under the matrix [u v], 64 samples
Gradient 2: full exposure, then zero none; a smoothstep ramp along the axis

Storing an oval as points, not as a centre plus radii plus an angle, keeps geometry mapping shape-blind: every control point still goes through map_coords_to_geometry, so rotation, flips and distortion follow as they did. The points map to pixels before the outline is generated, so the raw-image aspect needs no special case. The axes need not be perpendicular, which makes a tilted oval the same expression as a round one.

An oval outline is generated, so the existing fillPoly and Gaussian feather serve it with no new fill path. A gradient ignores Feather, because the distance between its two handles is the softness. invert is 1 - alpha after the feather.

One function (local/logic.rasterise) now serves the render, the canvas tint and the printing-notes map, so the three cannot describe different shapes. The GPU consumes the same CPU-rasterised map, so there is no shader work and no parity surface.

Canvas

Both new tools drag out their shape, and one local_mask_created(shape, points) signal replaces lasso_completed. Outlines and control points are now tracked separately: the old code hit-tested the control polygon, which would have hit-tested an oval's triangle. A card edge draws a solid line at full exposure and a dashed line at zero, and hits near its axis. The oval centre handle moves the axes with it. Midpoint + and vertex delete stay polygon-only, because only a polygon can change its point count.

UI

Three tool buttons, an Invert toggle, a shape icon per list row, and Feather disabled on a card edge. Two shortcut-registry entries with empty default keys — no free key was worth a collision.

Compatibility

shape and invert default in _build_local, so a mask saved before this change loads as a polygon. Nothing goes through migrations.py.

Verification

  • make all green: 3499 passed, 2 skipped.
  • Headless end-to-end on a real scan, on both engines: oval burn +1 st gave −0.0495, card-edge dodge −1 st gave +0.0757, and a region outside both masks moved ±0.0000. GPU against CPU: max diff 0.0078 (2 codes at 8 bit), mean 2e-7.

Left out

A freehand brush mask, a hard-edge toggle, and tonal-range gating. The first two fit the same shape slot. The third needs the normalised density threaded into compute_local_maps, which the GPU rasterises per tile before the print stage, so it is separate work.

Trim the Style section to the two rules that govern how prose gets written.
Dodge & Burn had one shape, a clicked polygon. Two common darkroom moves did
not fit it: burning through a hole in the card (a smooth oval took a dozen
vertices) and the graduated card-edge burn (an unbounded ramp that a closed
polygon and a Gaussian cannot express).

Add a `shape` field to the mask, renamed `PolygonMask` -> `LocalMask`. The
vertices stay the universal store and `shape` says how to read them: a polygon
keeps N control points, an oval takes 3 (centre and one end of each axis, an
affine frame that permits oblique axes), a gradient takes 2. Geometry mapping
stays shape-blind, because every control point still goes through
`map_coords_to_geometry`.

An oval outline is generated, so the existing `fillPoly` and feather serve it
unchanged. The gradient is a smoothstep ramp along its axis, and ignores
Feather because the distance between its handles sets the softness. `invert`
flips the alpha, which applies a mask outside its own shape.

One rasteriser (`local/logic.rasterise`) now serves the render, the canvas
tint and the printing-notes map. The GPU consumes the same CPU-rasterised map,
so no shader work and no parity surface. Old saves load as polygons.
A tilted card edge could not burn a full corner. The ramp is already an
unbounded half-plane, but the handles were held inside the frame, so the line
through the start point always cut one corner off the full-exposure side as
soon as you tilted the axis. To hold the whole top edge, the start must sit
past the top-right corner, which is off the frame.

The render path was ready for this: `map_coords_to_geometry` is analytic and
takes coords outside [0,1]. Only the view path clamped. Both uv-grid lookups
now continue past the boundary with an affine model of the grid, taken from
central differences in the middle of the grid. The samples avoid the border,
because a fine rotation fills it with zeros and those are not coordinates.

Drop the clamps on the shape drag, on the handle drag and on the emitted
points. This also permits an oval whose centre is off the frame, and a polygon
vertex outside the picture.
@marcinz606
marcinz606 merged commit 958d24c into main Aug 8, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant