Curves image adjustment node (#1214)

* Create ValueMapperNode and use it for brightness/contrast

* move spline code into seperate module

* Add GenerateCurvesNode

* add a `LuminanceMut`-trait
* add `lerp` to `Channel`

* Add frontend code to handle the curves widget's inputs

* Rename spline module to curve

* Make messages in CurveInput pass

* Improve curves widget design and fix sizing issue

* Implement proper bezier handling

* Use bezier_rs's intersections function instead of own cubic root solver

* Debounce CurveInput events and change how debouncer works

the first event issued to the debouncer was unneccessarily delayed.
Instead now the debouncer fires it instantaneously but blocks events
that come in until a timeout was reached.

* Make curve editing more user friendly

* Change code to use project terminology

* sample -> manipulator group or manipulator
* marker -> handle

* Fix small documentation mistake in bezier-rs

* Add find_tvalues_for_x function to bezier-rs

also integrate the function into curves node

* Add tests for find_tvalues_for_x in bezier-rs

* Fix formatting

* Revert BrightnessContrastNode changes

* Frontend cleanup

---------

Co-authored-by: Keavon Chambers <keavon@keavon.com>
Co-authored-by: Dennis Kobert <dennis@kobert.dev>
This commit is contained in:
nat-rix
2023-08-13 01:07:11 -07:00
committed by GitHub
co-authored by Keavon Chambers Dennis Kobert
parent cfe38c6413
commit dc4b16aead
29 changed files with 822 additions and 75 deletions
@@ -13,6 +13,7 @@
import TextButton from "@graphite/components/widgets/buttons/TextButton.svelte";
import CheckboxInput from "@graphite/components/widgets/inputs/CheckboxInput.svelte";
import ColorInput from "@graphite/components/widgets/inputs/ColorInput.svelte";
import CurveInput from "@graphite/components/widgets/inputs/CurveInput.svelte";
import DropdownInput from "@graphite/components/widgets/inputs/DropdownInput.svelte";
import FontInput from "@graphite/components/widgets/inputs/FontInput.svelte";
import LayerReferenceInput from "@graphite/components/widgets/inputs/LayerReferenceInput.svelte";
@@ -95,6 +96,10 @@
{#if colorInput}
<ColorInput {...exclude(colorInput)} on:value={({ detail }) => updateLayout(index, detail)} sharpRightCorners={nextIsSuffix} />
{/if}
{@const curvesInput = narrowWidgetProps(component.props, "CurveInput")}
{#if curvesInput}
<CurveInput {...exclude(curvesInput)} on:value={({ detail }) => debouncer((value) => updateLayout(index, value), { debounceTime: 120 }).updateValue(detail)} />
{/if}
{@const dropdownInput = narrowWidgetProps(component.props, "DropdownInput")}
{#if dropdownInput}
<DropdownInput {...exclude(dropdownInput)} on:selectedIndex={({ detail }) => updateLayout(index, detail)} sharpRightCorners={nextIsSuffix} />
@@ -0,0 +1,261 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import type { Curve, CurveManipulatorGroup } from "@graphite/wasm-communication/messages";
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
import { clamp } from "@graphite/utility-functions/math";
// emits: ["update:value"],
const dispatch = createEventDispatcher<{
value: Curve;
}>();
export let classes: Record<string, boolean> = {};
let styleName = "";
export { styleName as style };
export let styles: Record<string, string | number | undefined> = {};
export let value: Curve;
export let disabled = false;
export let tooltip: string | undefined = undefined;
const GRID_SIZE = 4;
let groups: CurveManipulatorGroup[] = [
{
anchor: [0, 0],
handles: [
[-1, -1],
[0.25, 0.25],
],
},
{
anchor: [0.5, 0.5],
handles: [
[0.25, 0.25],
[0.75, 0.75],
],
},
{
anchor: [1, 1],
handles: [
[0.75, 0.75],
[2, 2],
],
},
];
let selectedNodeIndex: number | undefined = undefined;
let draggedNodeIndex: number | undefined = undefined;
let dAttribute = recalculateSvgPath();
$: {
groups = [groups[0]].concat(value.manipulatorGroups).concat([groups[groups.length - 1]]);
groups[0].handles[1] = value.firstHandle;
groups[groups.length - 1].handles[0] = value.lastHandle;
dAttribute = recalculateSvgPath();
}
function updateCurve() {
dispatch("value", {
manipulatorGroups: groups.slice(1, groups.length - 1),
firstHandle: groups[0].handles[1],
lastHandle: groups[groups.length - 1].handles[0],
});
}
function recalculateSvgPath() {
let dAttribute = "";
let anchor = groups[0].anchor;
let handle = groups[0].handles[1];
groups.slice(1).forEach((group) => {
dAttribute += `M${anchor[0]} ${1 - anchor[1]} C${handle[0]} ${1 - handle[1]}, ${group.handles[0][0]} ${1 - group.handles[0][1]}, ${group.anchor[0]} ${1 - group.anchor[1]} `;
anchor = group.anchor;
handle = group.handles[1];
});
return dAttribute;
}
function handleManipulatorPointerDown(e: PointerEvent, i: number) {
// Delete an anchor with RMB or MMB
if (e.button > 0 && i > 0 && i < groups.length - 1) {
draggedNodeIndex = undefined;
selectedNodeIndex = undefined;
groups.splice(i, 1);
groups = groups;
dAttribute = recalculateSvgPath();
updateCurve();
return;
}
draggedNodeIndex = i;
if (i >= 0) selectedNodeIndex = i;
}
function getSvgPositionFromPointerEvent(e: PointerEvent): [number, number] | undefined {
if (!(e.target instanceof SVGElement)) return undefined;
const target = e.target?.closest("svg") || undefined;
if (!target) return undefined;
const rect = target.getBoundingClientRect();
const x = (e.x - rect.x) / rect.width;
const y = 1 - (e.y - rect.y) / rect.height;
return [clamp(x), clamp(y)];
}
function clampHandles() {
for (let i = 0; i < groups.length - 1; i++) {
const [min, max] = [groups[i].anchor[0], groups[i + 1].anchor[0]];
for (let j = 0; j < 2; j++) {
groups[i + j].handles[1 - j][0] = clamp(groups[i + j].handles[1 - j][0], min, max);
groups[i + j].handles[1 - j][1] = clamp(groups[i + j].handles[1 - j][1]);
}
}
}
function handlePointerUp(e: PointerEvent) {
if (draggedNodeIndex !== undefined) {
draggedNodeIndex = undefined;
return;
}
if (e.button !== 0) return;
const anchor = getSvgPositionFromPointerEvent(e);
if (!anchor) return;
let nodeIndex = groups.findIndex((group) => group.anchor[0] > anchor[0]);
if (nodeIndex === -1) nodeIndex = groups.length;
groups.splice(nodeIndex, 0, {
anchor: anchor,
handles: [
[anchor[0] - 0.05, anchor[1]],
[anchor[0] + 0.05, anchor[1]],
],
});
selectedNodeIndex = nodeIndex;
clampHandles();
dAttribute = recalculateSvgPath();
updateCurve();
}
function setHandlePosition(anchorIndex: number, handleIndex: number, position: [number, number]): void {
const { anchor, handles } = groups[anchorIndex];
const otherHandle = handles[1 - handleIndex];
const handleVector = [anchor[0] - position[0], anchor[1] - position[1]];
const handleVectorLength = Math.hypot(...handleVector);
const handleVectorNormalized = [handleVector[0] / handleVectorLength, handleVector[1] / handleVectorLength];
const otherHandleVectorLength = Math.hypot(anchor[0] - otherHandle[0], anchor[1] - otherHandle[1]);
handles[handleIndex] = position;
handles[1 - handleIndex] = [anchor[0] + handleVectorNormalized[0] * otherHandleVectorLength, anchor[1] + handleVectorNormalized[1] * otherHandleVectorLength];
}
function handlePointerMove(e: PointerEvent) {
if (draggedNodeIndex === undefined || draggedNodeIndex === 0 || draggedNodeIndex === groups.length - 1) return;
const position = getSvgPositionFromPointerEvent(e);
if (!position) return;
if (draggedNodeIndex > 0) {
position[0] = clamp(position[0], groups[draggedNodeIndex - 1].anchor[0], groups[draggedNodeIndex + 1].anchor[0]);
const group = groups[draggedNodeIndex];
group.handles = [
[group.handles[0][0] + position[0] - group.anchor[0], group.handles[0][1] + position[1] - group.anchor[1]],
[group.handles[1][0] + position[0] - group.anchor[0], group.handles[1][1] + position[1] - group.anchor[1]],
];
group.anchor = position;
} else {
if (selectedNodeIndex === undefined) return;
setHandlePosition(selectedNodeIndex, -draggedNodeIndex - 1, position);
const group = groups[selectedNodeIndex];
if (group.handles[0][0] > group.anchor[0]) {
group.handles = [group.handles[1], group.handles[0]];
draggedNodeIndex = -3 - draggedNodeIndex;
}
}
clampHandles();
dAttribute = recalculateSvgPath();
updateCurve();
}
</script>
<LayoutRow class={"curve-input"} classes={{ disabled, ...classes }} style={styleName} {styles} {tooltip}>
<svg viewBox="0 0 1 1" on:pointermove={handlePointerMove} on:pointerup={handlePointerUp}>
{#each { length: GRID_SIZE - 1 } as _, i}
<path class="grid" d={`M 0 ${(i + 1) / GRID_SIZE} L 1 ${(i + 1) / GRID_SIZE}`} />
<path class="grid" d={`M ${(i + 1) / GRID_SIZE} 0 L ${(i + 1) / GRID_SIZE} 1`} />
{/each}
<path class="curve" d={dAttribute} />
{#if selectedNodeIndex !== undefined}
{@const group = groups[selectedNodeIndex]}
{#each [0, 1] as i}
<path d={`M ${group.anchor[0]} ${1 - group.anchor[1]} L ${group.handles[i][0]} ${1 - group.handles[i][1]}`} class="handle-line" />
<circle cx={group.handles[i][0]} cy={1 - group.handles[i][1]} class="manipulator handle" on:pointerdown={(e) => handleManipulatorPointerDown(e, -i - 1)} />
{/each}
{/if}
{#each groups as group, i}
<circle cx={group.anchor[0]} cy={1 - group.anchor[1]} class="manipulator" on:pointerdown={(e) => handleManipulatorPointerDown(e, i)} />
{/each}
</svg>
<slot />
</LayoutRow>
<style lang="scss" global>
.curve-input {
background: var(--color-1-nearblack);
display: flex;
position: relative;
min-width: calc(2 * var(--widget-height));
max-width: calc(8 * var(--widget-height));
.grid {
stroke: var(--color-7-middlegray);
stroke-width: 0.005;
pointer-events: none;
}
.curve {
fill: none;
stroke: var(--color-e-nearwhite);
stroke-width: 0.01;
}
.manipulator {
fill: var(--color-1-nearblack);
stroke: var(--color-e-nearwhite);
stroke-width: 0.01;
r: 0.02;
&:hover {
fill: var(--color-f-white);
stroke: var(--color-f-white);
}
&.handle {
fill: var(--color-1-nearblack);
stroke: var(--color-c-brightgray);
&:hover {
fill: var(--color-a-softgray);
stroke: var(--color-a-softgray);
}
}
}
.handle-line {
stroke: var(--color-7-middlegray);
stroke-width: 0.005;
pointer-events: none;
}
}
</style>