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.
This commit is contained in:
+100
-2
@@ -1,6 +1,6 @@
|
||||
import { Theme } from "theme.slint";
|
||||
import { AdjustPanel, ComposePanel, GroupStrip, ParamRow, TransferPanel, ViewMode } from "adjust.slint";
|
||||
import { CategoryRow, GradientHandle, GradientHandles, HandleRole, MaskPanel, MaskRow, SubjectRow } from "masks.slint";
|
||||
import { CategoryRow, GradientHandle, GradientHandles, HandleRole, MaskPanel, MaskRow, PartRow, SubjectRow } from "masks.slint";
|
||||
import { SpotHandle, SpotHandles, SpotPanel, SpotRole } from "spots.slint";
|
||||
import { CropOverlay } from "crop.slint";
|
||||
import { HistoryPanel, HistoryRow } from "history.slint";
|
||||
@@ -1146,7 +1146,35 @@ export component AppWindow inherits Window {
|
||||
/// which meant local masking could be open with picking off — a mode
|
||||
/// entered and doing nothing. Arming a click is part of what local mode
|
||||
/// *is*, so it follows the mode and the existence of something to pick.
|
||||
property <bool> region-picking: root.local-mode && root.segmented;
|
||||
/// TRACES: FR-DEV-19b
|
||||
/// What a drag on the photograph does while a mask is selected: 0 selects
|
||||
/// a subject, 1 paints into the mask, 2 erases from it.
|
||||
in-out property <int> mask-tool: 0;
|
||||
/// TRACES: FR-DEV-19a
|
||||
/// The parts of the selected mask, for the panel.
|
||||
in property <[PartRow]> mask-parts;
|
||||
in property <float> brush-radius: 0.05;
|
||||
in property <float> brush-hardness: 0.5;
|
||||
in property <float> brush-flow: 1.0;
|
||||
|
||||
callback mask-tool-picked(int);
|
||||
callback brush-changed(float, float, float);
|
||||
callback mask-part-selected(string, int);
|
||||
callback mask-part-join-picked(string, int, int);
|
||||
callback mask-part-removed(string, int);
|
||||
callback mask-part-added(string, int);
|
||||
/// TRACES: FR-DEV-19b
|
||||
/// A stroke, in fractions of the photograph as it is currently shown.
|
||||
callback paint-began(float, float);
|
||||
callback paint-moved(float, float);
|
||||
callback paint-ended();
|
||||
|
||||
// Clicking to select a subject and dragging to paint are the same press,
|
||||
// so exactly one of them is armed at a time. Without this the first dab of
|
||||
// every stroke would also re-select whatever was under it, and the mask
|
||||
// being painted would be replaced by the one the click made.
|
||||
property <bool> region-picking: root.local-mode && root.segmented && root.mask-tool == 0;
|
||||
property <bool> painting: root.local-mode && root.mask-tool > 0;
|
||||
|
||||
/// Whether shift is down, tracked by the develop key scope below.
|
||||
///
|
||||
@@ -2134,6 +2162,59 @@ in property <bool> panel-visible: true;
|
||||
}
|
||||
}
|
||||
|
||||
// GESTURE: Paint a mask by hand
|
||||
// where: Develop
|
||||
// touch: Choose Paint or Erase, then drag on the
|
||||
// photograph
|
||||
// pointer: Choose Paint or Erase, then drag
|
||||
// why: A model's mask stops inside a shoulder and
|
||||
// leaks into the hair, and no single edge
|
||||
// control fixes two errors that go opposite
|
||||
// ways. The whole stroke is one step in the
|
||||
// history, so taking a mark back costs one
|
||||
// press however long it took to make.
|
||||
//
|
||||
// TRACES: FR-DEV-19b
|
||||
//
|
||||
// In front of the region picker rather than beside it: the
|
||||
// two want the same press, and `region-picking` is false
|
||||
// whenever this is armed so only one of them exists at a
|
||||
// time.
|
||||
if root.painting && root.total > 0 && root.load-error == "": paint := TouchArea {
|
||||
x: parent.shown-x;
|
||||
y: parent.shown-y;
|
||||
width: parent.shown-w;
|
||||
height: parent.shown-h;
|
||||
mouse-cursor: crosshair;
|
||||
enabled: !root.cropping;
|
||||
|
||||
pointer-event(ev) => {
|
||||
if ev.kind == PointerEventKind.down {
|
||||
root.paint-began(
|
||||
self.mouse-x / max(self.width, 1px),
|
||||
self.mouse-y / max(self.height, 1px),
|
||||
);
|
||||
}
|
||||
// A cancelled gesture ends the stroke like a
|
||||
// release does. The alternative is a stroke left
|
||||
// open across the next press, which paints a line
|
||||
// between two places the finger never travelled.
|
||||
if ev.kind == PointerEventKind.up
|
||||
|| ev.kind == PointerEventKind.cancel {
|
||||
root.paint-ended();
|
||||
}
|
||||
}
|
||||
|
||||
moved => {
|
||||
if self.pressed {
|
||||
root.paint-moved(
|
||||
self.mouse-x / max(self.width, 1px),
|
||||
self.mouse-y / max(self.height, 1px),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TRACES: FR-DEV-8
|
||||
// Placing a repair, beside the region picker and for the
|
||||
// same reasons: a click and a pan want different handlers,
|
||||
@@ -3061,7 +3142,24 @@ in property <bool> panel-visible: true;
|
||||
// the value alone.
|
||||
overlay-hidden <=> root.overlay-hidden;
|
||||
|
||||
parts: root.mask-parts;
|
||||
tool: root.mask-tool;
|
||||
brush-radius: root.brush-radius;
|
||||
brush-hardness: root.brush-hardness;
|
||||
brush-flow: root.brush-flow;
|
||||
|
||||
segment(fine) => { root.segment-image(fine); }
|
||||
tool-picked(i) => {
|
||||
root.mask-tool = i;
|
||||
root.mask-tool-picked(i);
|
||||
}
|
||||
brush-changed(r, h, f) => { root.brush-changed(r, h, f); }
|
||||
part-selected(id, i) => { root.mask-part-selected(id, i); }
|
||||
part-join-picked(id, i, j) => {
|
||||
root.mask-part-join-picked(id, i, j);
|
||||
}
|
||||
part-removed(id, i) => { root.mask-part-removed(id, i); }
|
||||
part-added(id, j) => { root.mask-part-added(id, j); }
|
||||
mask-selected(id, extend) => { root.mask-selected(id, extend); }
|
||||
mask-removed(id) => { root.mask-removed(id); }
|
||||
mask-refined(id) => { root.mask-refined(id); }
|
||||
|
||||
@@ -67,6 +67,26 @@ export struct MaskRow {
|
||||
adjusted: bool,
|
||||
}
|
||||
|
||||
/// One selection inside a layer's mask (FR-DEV-19a).
|
||||
///
|
||||
/// A mask is a fold over these, so the list is ordered and the order is what
|
||||
/// it means: a subtraction placed before the part it was meant to cut into
|
||||
/// takes nothing away.
|
||||
export struct PartRow {
|
||||
/// Identity, opaque to this file — the callbacks carry the index instead,
|
||||
/// because that is what a row is addressed by on screen.
|
||||
id: string,
|
||||
/// What this part selects — "brush", "subject", "radial".
|
||||
label: string,
|
||||
/// 0 adds to the mask, 1 takes away from it. Meaningless on the first.
|
||||
join: int,
|
||||
/// Whether the brush and the edge controls point at this part.
|
||||
selected: bool,
|
||||
/// The selection the layer began as. It joins nothing and cannot be
|
||||
/// removed — removing it is removing the layer.
|
||||
base: bool,
|
||||
}
|
||||
|
||||
/// Which part of a gradient a canvas handle drags.
|
||||
///
|
||||
/// Named by the job rather than by the field, because the two gradients keep
|
||||
@@ -114,6 +134,10 @@ component MaskEntry inherits Rectangle {
|
||||
/// Whether a refine pass is running (for any row — only one runs at a
|
||||
/// time), so this row's button can say so and refuse a second press.
|
||||
in property <bool> refining: false;
|
||||
/// TRACES: FR-DEV-19a
|
||||
/// What this layer's mask is built from. Empty unless the row is the one
|
||||
/// selected, since the parts of a mask nobody is editing are noise.
|
||||
in property <[PartRow]> parts;
|
||||
|
||||
/// `true` when the click should add this row to the selection rather
|
||||
/// than replace it with just this row.
|
||||
@@ -135,6 +159,14 @@ component MaskEntry inherits Rectangle {
|
||||
callback hue-changed(float, float);
|
||||
callback refined();
|
||||
|
||||
/// TRACES: FR-DEV-19a
|
||||
/// Point the tools at one part, change how it joins, or take it out.
|
||||
callback part-selected(int);
|
||||
callback part-join-picked(int, int);
|
||||
callback part-removed(int);
|
||||
/// Join a fresh painted correction: 0 adds, 1 subtracts.
|
||||
callback part-added(int);
|
||||
|
||||
height: layout.preferred-height;
|
||||
background: root.data.selected ? Theme.surface-raised : transparent;
|
||||
border-radius: Theme.radius;
|
||||
@@ -248,6 +280,95 @@ component MaskEntry inherits Rectangle {
|
||||
spacing: Theme.gap-sm;
|
||||
padding-top: Theme.gap-sm;
|
||||
|
||||
// TRACES: FR-DEV-19a
|
||||
// What this mask is built from, in the order it is built.
|
||||
//
|
||||
// Shown even when there is only one part, because the row is what
|
||||
// says the edge controls below belong to a *selection* rather than
|
||||
// to the whole layer — and because it is where the next one is
|
||||
// added. A photographer who cannot see that a mask has parts has
|
||||
// no way to guess that the feather they are dragging moves one of
|
||||
// them.
|
||||
for part[i] in root.parts: Rectangle {
|
||||
height: Theme.touch-target;
|
||||
background: part.selected ? Theme.surface : transparent;
|
||||
border-radius: Theme.radius;
|
||||
|
||||
// Declared before the two buttons, so it sits *behind* them: a
|
||||
// press on the join chip or the cross does its own job, and a
|
||||
// press anywhere else on the row points the tools at the part.
|
||||
pick := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
enabled: root.enabled;
|
||||
clicked => { root.part-selected(i); }
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Theme.gap-sm;
|
||||
padding-right: Theme.gap-sm;
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
// One chip, two states, because there are two ways to
|
||||
// join and a photographer flips between them while
|
||||
// looking at the picture rather than at a menu.
|
||||
join := TouchArea {
|
||||
width: Theme.touch-target;
|
||||
mouse-cursor: part.base ? MouseCursor.default : MouseCursor.pointer;
|
||||
enabled: root.enabled && !part.base;
|
||||
clicked => { root.part-join-picked(i, part.join == 0 ? 1 : 0); }
|
||||
|
||||
Label {
|
||||
// The first part joins nothing — a chip saying
|
||||
// "adds" on it would be a control that moves and
|
||||
// changes no pixel.
|
||||
text: part.base ? "◆" : (part.join == 0 ? "+" : "−");
|
||||
emphasised: join.has-hover || part.selected;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
Caption {
|
||||
text: part.label;
|
||||
overflow: elide;
|
||||
min-width: 0px;
|
||||
max-width: 140px;
|
||||
vertical-alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
|
||||
if !part.base: cut := TouchArea {
|
||||
width: Theme.touch-target;
|
||||
mouse-cursor: pointer;
|
||||
enabled: root.enabled;
|
||||
clicked => { root.part-removed(i); }
|
||||
|
||||
Label {
|
||||
text: "×";
|
||||
emphasised: cut.has-hover;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
Button {
|
||||
text: "+ Add";
|
||||
enabled: root.enabled;
|
||||
clicked => { root.part-added(0); }
|
||||
}
|
||||
|
||||
Button {
|
||||
text: "− Subtract";
|
||||
enabled: root.enabled;
|
||||
clicked => { root.part-added(1); }
|
||||
}
|
||||
}
|
||||
|
||||
SliderRow {
|
||||
label: "Opacity";
|
||||
value: root.data.opacity;
|
||||
@@ -432,6 +553,18 @@ component MaskEntry inherits Rectangle {
|
||||
export component MaskPanel inherits Rectangle {
|
||||
in property <bool> enabled: true;
|
||||
in property <[MaskRow]> masks;
|
||||
/// TRACES: FR-DEV-19a
|
||||
/// The parts of the selected layer. Empty when none is selected, or when
|
||||
/// more than one is — a stroke has one target, and so does an edge.
|
||||
in property <[PartRow]> parts;
|
||||
/// TRACES: FR-DEV-19b
|
||||
/// What a drag on the photograph does: 0 selects, 1 paints, 2 erases.
|
||||
in property <int> tool: 0;
|
||||
/// The brush: radius as a fraction of the frame's shorter edge, hardness,
|
||||
/// and flow.
|
||||
in property <float> brush-radius: 0.05;
|
||||
in property <float> brush-hardness: 0.5;
|
||||
in property <float> brush-flow: 1.0;
|
||||
in property <[SubjectRow]> subjects;
|
||||
in property <[CategoryRow]> categories;
|
||||
|
||||
@@ -467,6 +600,15 @@ export component MaskPanel inherits Rectangle {
|
||||
callback mask-band-changed(string, float, float, float);
|
||||
callback mask-hue-changed(string, float, float);
|
||||
|
||||
/// TRACES: FR-DEV-19b
|
||||
callback tool-picked(int);
|
||||
callback brush-changed(float, float, float);
|
||||
/// TRACES: FR-DEV-19a
|
||||
callback part-selected(string, int);
|
||||
callback part-join-picked(string, int, int);
|
||||
callback part-removed(string, int);
|
||||
callback part-added(string, int);
|
||||
|
||||
callback add-gradient(bool);
|
||||
/// `true` asks for the colour range rather than the brightness one.
|
||||
callback add-range(bool);
|
||||
@@ -496,6 +638,51 @@ export component MaskPanel inherits Rectangle {
|
||||
|
||||
if !root.enabled: Caption { text: "No image"; }
|
||||
|
||||
// TRACES: FR-DEV-19b
|
||||
// **In the panel and not in the tool rail.** The rail's entries arm a
|
||||
// gesture for the whole photograph; a brush has no meaning without a
|
||||
// mask to paint into, and a rail entry that lit up with nothing
|
||||
// selected would either do nothing or silently make a layer. Here the
|
||||
// strip can simply be absent until there is something to paint on.
|
||||
if root.enabled && root.parts.length > 0: Segmented {
|
||||
options: ["Select", "Paint", "Erase"];
|
||||
selected: root.tool;
|
||||
picked(i) => { root.tool-picked(i); }
|
||||
}
|
||||
|
||||
if root.enabled && root.tool > 0: SliderRow {
|
||||
label: "Size";
|
||||
value: root.brush-radius;
|
||||
default-value: 0.05;
|
||||
minimum: 0.005;
|
||||
maximum: 0.4;
|
||||
precision: 3;
|
||||
changed(v) => { root.brush-changed(v, root.brush-hardness, root.brush-flow); }
|
||||
reset => { root.brush-changed(0.05, root.brush-hardness, root.brush-flow); }
|
||||
}
|
||||
|
||||
if root.enabled && root.tool > 0: SliderRow {
|
||||
label: "Hardness";
|
||||
value: root.brush-hardness;
|
||||
default-value: 0.5;
|
||||
minimum: 0.0;
|
||||
maximum: 1.0;
|
||||
precision: 2;
|
||||
changed(v) => { root.brush-changed(root.brush-radius, v, root.brush-flow); }
|
||||
reset => { root.brush-changed(root.brush-radius, 0.5, root.brush-flow); }
|
||||
}
|
||||
|
||||
if root.enabled && root.tool > 0: SliderRow {
|
||||
label: "Flow";
|
||||
value: root.brush-flow;
|
||||
default-value: 1.0;
|
||||
minimum: 0.05;
|
||||
maximum: 1.0;
|
||||
precision: 2;
|
||||
changed(v) => { root.brush-changed(root.brush-radius, root.brush-hardness, v); }
|
||||
reset => { root.brush-changed(root.brush-radius, root.brush-hardness, 1.0); }
|
||||
}
|
||||
|
||||
// --- the region map ---------------------------------------------
|
||||
//
|
||||
// Segmentation is a thing the user starts. Half a second of watershed
|
||||
@@ -752,6 +939,13 @@ export component MaskPanel inherits Rectangle {
|
||||
morph-radius-changed(v) => { root.mask-morph-radius-changed(mask.id, v); }
|
||||
band-changed(lo, hi, soft) => { root.mask-band-changed(mask.id, lo, hi, soft); }
|
||||
hue-changed(h, w) => { root.mask-hue-changed(mask.id, h, w); }
|
||||
// Only the selected row is given any, so seven unselected stacks
|
||||
// of parts are not built and drawn behind the one being worked on.
|
||||
parts: mask.selected ? root.parts : [];
|
||||
part-selected(i) => { root.part-selected(mask.id, i); }
|
||||
part-join-picked(i, j) => { root.part-join-picked(mask.id, i, j); }
|
||||
part-removed(i) => { root.part-removed(mask.id, i); }
|
||||
part-added(j) => { root.part-added(mask.id, j); }
|
||||
}
|
||||
|
||||
// No caption saying which chain the sliders below are pointed at.
|
||||
|
||||
Reference in New Issue
Block a user