Skip to content

Commit

Permalink
feat(gui): show mask brush preview (fixes #39)
Browse files Browse the repository at this point in the history
  • Loading branch information
ssube committed Jan 14, 2023
1 parent e915ab5 commit a87dc45
Showing 1 changed file with 118 additions and 72 deletions.
190 changes: 118 additions & 72 deletions gui/src/components/MaskCanvas.tsx
Expand Up @@ -2,7 +2,7 @@ import { doesExist, Maybe, mustExist } from '@apextoaster/js-utils';
import { FormatColorFill, Gradient } from '@mui/icons-material';
import { Button, Stack } from '@mui/material';
import { throttle } from 'lodash';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { RefObject, useEffect, useMemo, useRef, useState } from 'react';

import { ConfigParams, DEFAULT_BRUSH, SAVE_TIME } from '../config.js';
import { NumericField } from './NumericField';
Expand All @@ -28,30 +28,6 @@ export const MASK_STATE = {
dirty: 'dirty',
};

export function floodBelow(n: number): number {
if (n < THRESHOLDS.upper) {
return COLORS.black;
} else {
return COLORS.white;
}
}

export function floodAbove(n: number): number {
if (n > THRESHOLDS.lower) {
return COLORS.white;
} else {
return COLORS.black;
}
}

export function floodGray(n: number): number {
return n;
}

export function grayToRGB(n: number): string {
return `rgb(${n.toFixed(0)},${n.toFixed(0)},${n.toFixed(0)})`;
}

export type FloodFn = (n: number) => number;

export interface Point {
Expand All @@ -71,28 +47,6 @@ export interface MaskCanvasProps {
export function MaskCanvas(props: MaskCanvasProps) {
const { base, config, source } = props;

function floodMask(flood: FloodFn) {
const buffer = mustExist(bufferRef.current);
const ctx = mustExist(buffer.getContext('2d'));
const image = ctx.getImageData(0, 0, buffer.width, buffer.height);
const pixels = image.data;

for (let x = 0; x < buffer.width; ++x) {
for (let y = 0; y < buffer.height; ++y) {
const i = (y * buffer.width * PIXEL_SIZE) + (x * PIXEL_SIZE);
const hue = (pixels[i] + pixels[i + 1] + pixels[i + 2]) / PIXEL_WEIGHT;
const final = flood(hue);

pixels[i] = final;
pixels[i + 1] = final;
pixels[i + 2] = final;
}
}

ctx.putImageData(image, 0, 0);
save();
}

function saveMask(): void {
if (doesExist(bufferRef.current)) {
if (maskState.current === MASK_STATE.clean) {
Expand All @@ -107,27 +61,21 @@ export function MaskCanvas(props: MaskCanvasProps) {
}

function drawBuffer() {
if (doesExist(bufferRef.current) && doesExist(canvasRef.current)) {
const dest = mustExist(canvasRef.current);
const ctx = mustExist(dest.getContext('2d'));

ctx.clearRect(0, 0, dest.width, dest.height);
if (doesExist(brushRef.current) && doesExist(bufferRef.current) && doesExist(canvasRef.current)) {
const { ctx } = getClearContext(canvasRef);
ctx.globalAlpha = MASK_OPACITY;
ctx.drawImage(bufferRef.current, 0, 0);
}
}

function drawCircle(ctx: CanvasRenderingContext2D, point: Point): void {
ctx.beginPath();
ctx.arc(point.x, point.y, brushSize, 0, FULL_CIRCLE);
ctx.fill();
if (maskState.current !== MASK_STATE.painting) {
ctx.drawImage(brushRef.current, 0, 0);
}
}
}

function drawSource(file: Blob): void {
const image = new Image();
image.onload = () => {
const buffer = mustExist(bufferRef.current);
const ctx = mustExist(buffer.getContext('2d'));
const { ctx } = getContext(bufferRef);
ctx.drawImage(image, 0, 0);
URL.revokeObjectURL(src);

Expand All @@ -139,6 +87,11 @@ export function MaskCanvas(props: MaskCanvasProps) {
}

function finishPainting() {
if (doesExist(brushRef.current)) {
getClearContext(brushRef);
drawBuffer();
}

if (maskState.current === MASK_STATE.painting) {
maskState.current = MASK_STATE.dirty;
save();
Expand All @@ -147,6 +100,8 @@ export function MaskCanvas(props: MaskCanvasProps) {

const save = useMemo(() => throttle(saveMask, SAVE_TIME), []);

// eslint-disable-next-line no-null/no-null
const brushRef = useRef<HTMLCanvasElement>(null);
// eslint-disable-next-line no-null/no-null
const bufferRef = useRef<HTMLCanvasElement>(null);
// eslint-disable-next-line no-null/no-null
Expand All @@ -162,11 +117,11 @@ export function MaskCanvas(props: MaskCanvasProps) {
useEffect(() => {
// including clicks.length prevents the initial render from saving a blank canvas
if (doesExist(bufferRef.current) && maskState.current === MASK_STATE.painting && clicks.length > 0) {
const ctx = mustExist(bufferRef.current.getContext('2d'));
const { ctx } = getContext(bufferRef);
ctx.fillStyle = grayToRGB(brushColor);

for (const click of clicks) {
drawCircle(ctx, click);
drawCircle(ctx, click, brushSize);
}

clicks.length = 0;
Expand Down Expand Up @@ -206,6 +161,14 @@ export function MaskCanvas(props: MaskCanvasProps) {
}

return <Stack spacing={2}>
<canvas
ref={brushRef}
height={config.height.default}
width={config.width.default}
style={{
display: 'none',
}}
/>
<canvas
ref={bufferRef}
height={config.height.default}
Expand All @@ -223,14 +186,13 @@ export function MaskCanvas(props: MaskCanvasProps) {
const canvas = mustExist(canvasRef.current);
const bounds = canvas.getBoundingClientRect();

const buffer = mustExist(bufferRef.current);
const ctx = mustExist(buffer.getContext('2d'));
const { ctx } = getContext(bufferRef);
ctx.fillStyle = grayToRGB(brushColor);

drawCircle(ctx, {
x: event.clientX - bounds.left,
y: event.clientY - bounds.top,
});
}, brushSize);

maskState.current = MASK_STATE.dirty;
save();
Expand All @@ -242,14 +204,24 @@ export function MaskCanvas(props: MaskCanvasProps) {
onMouseOut={finishPainting}
onMouseUp={finishPainting}
onMouseMove={(event) => {
if (maskState.current === MASK_STATE.painting) {
const canvas = mustExist(canvasRef.current);
const bounds = canvas.getBoundingClientRect();
const canvas = mustExist(canvasRef.current);
const bounds = canvas.getBoundingClientRect();

if (maskState.current === MASK_STATE.painting) {
setClicks([...clicks, {
x: event.clientX - bounds.left,
y: event.clientY - bounds.top,
}]);
} else {
const { ctx } = getClearContext(brushRef);
ctx.fillStyle = grayToRGB(brushColor);

drawCircle(ctx, {
x: event.clientX - bounds.left,
y: event.clientY - bounds.top,
}, brushSize);

drawBuffer();
}
}}
/>
Expand Down Expand Up @@ -279,20 +251,94 @@ export function MaskCanvas(props: MaskCanvasProps) {
<Button
variant='outlined'
startIcon={<FormatColorFill />}
onClick={() => floodMask(floodBelow)}>
onClick={() => {
floodCanvas(bufferRef, floodBelow);
save();
}}>
Gray to black
</Button>
<Button
variant='outlined'
startIcon={<Gradient />}
onClick={() => floodMask(floodGray)}>
onClick={() => {
floodCanvas(bufferRef, floodGray);
save();
}}>
Grayscale
</Button>
<Button
variant='outlined'
startIcon={<FormatColorFill />}
onClick={() => floodMask(floodAbove)}>
onClick={() => {
floodCanvas(bufferRef, floodAbove);
save();
}}>
Gray to white
</Button>
</Stack></Stack>;
</Stack>
</Stack>;
}

function getContext(ref: RefObject<HTMLCanvasElement>) {
const canvas = mustExist(ref.current);
const ctx = mustExist(canvas.getContext('2d'));

return { canvas, ctx };
}

function getClearContext(ref: RefObject<HTMLCanvasElement>) {
const ret = getContext(ref);
ret.ctx.clearRect(0, 0, ret.canvas.width, ret.canvas.height);

return ret;
}

function drawCircle(ctx: CanvasRenderingContext2D, point: Point, size: number): void {
ctx.beginPath();
ctx.arc(point.x, point.y, size, 0, FULL_CIRCLE);
ctx.fill();
}

export function floodBelow(n: number): number {
if (n < THRESHOLDS.upper) {
return COLORS.black;
} else {
return COLORS.white;
}
}

export function floodAbove(n: number): number {
if (n > THRESHOLDS.lower) {
return COLORS.white;
} else {
return COLORS.black;
}
}

export function floodGray(n: number): number {
return n;
}

export function grayToRGB(n: number): string {
return `rgb(${n.toFixed(0)},${n.toFixed(0)},${n.toFixed(0)})`;
}

function floodCanvas(ref: RefObject<HTMLCanvasElement>, flood: FloodFn) {
const { canvas, ctx } = getContext(ref);
const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixels = image.data;

for (let x = 0; x < canvas.width; ++x) {
for (let y = 0; y < canvas.height; ++y) {
const i = (y * canvas.width * PIXEL_SIZE) + (x * PIXEL_SIZE);
const hue = (pixels[i] + pixels[i + 1] + pixels[i + 2]) / PIXEL_WEIGHT;
const final = flood(hue);

pixels[i] = final;
pixels[i + 1] = final;
pixels[i + 2] = final;
}
}

ctx.putImageData(image, 0, 0);
}

0 comments on commit a87dc45

Please sign in to comment.