feat: add magic wand tool with settings and controls, including UI integration and functionality
This commit is contained in:
197
view/canvas/magic-wand.ts
Normal file
197
view/canvas/magic-wand.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { commandIds } from "@commands/ids";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { Vec2D } from "@core/geometry";
|
||||
import type { Layer } from "@core/layer";
|
||||
import { resolveTransformTargetBounds } from "@editor/transform-targets";
|
||||
import type { AppStore } from "@editor/store";
|
||||
import type { EditorState } from "@editor/state";
|
||||
|
||||
export async function applyMagicWandAt(store: AppStore, point: Vec2D, modeOverride?: EditorState["tools"]["magicWand"]["mode"]) {
|
||||
const state = store.getState();
|
||||
if (state.editor.tools.activeTool !== "magicWand") return false;
|
||||
const target = resolveTarget(state.document, state.editor);
|
||||
if (!target) return true;
|
||||
const x = Math.floor((point.x - target.layer.transform.position.x) / Math.max(0.0001, target.layer.transform.scale.x));
|
||||
const y = Math.floor((point.y - target.layer.transform.position.y) / Math.max(0.0001, target.layer.transform.scale.y));
|
||||
if (x < 0 || y < 0 || x >= target.asset.intrinsicSize.w || y >= target.asset.intrinsicSize.h) return true;
|
||||
const source = await createWandMask(target.asset.source, target.maskAsset?.source, Math.round(target.asset.intrinsicSize.w), Math.round(target.asset.intrinsicSize.h), x, y, { ...state.editor.tools.magicWand, mode: modeOverride ?? state.editor.tools.magicWand.mode });
|
||||
if (target.maskAsset) {
|
||||
store.dispatch(commandIds.documentUpdateAssetSource, { assetId: target.maskAsset.id, source });
|
||||
return true;
|
||||
}
|
||||
const assetId = crypto.randomUUID();
|
||||
const maskLayerId = crypto.randomUUID();
|
||||
const width = Math.max(1, Math.round(target.asset.intrinsicSize.w));
|
||||
const height = Math.max(1, Math.round(target.asset.intrinsicSize.h));
|
||||
store.dispatch(commandIds.documentAddLayerMask, {
|
||||
layerId: target.layer.id,
|
||||
asset: { id: assetId, name: `${target.layer.name} Wand Mask`, mimeType: "image/png", source, intrinsicSize: { w: width, h: height } },
|
||||
maskLayer: { id: maskLayerId, type: "raster", name: `${target.layer.name} Wand Mask`, visible: true, locked: false, opacity: 1, assetId, transform: { position: { x: target.bounds.x, y: target.bounds.y }, scale: { x: target.bounds.w / width, y: target.bounds.h / height }, rotation: target.layer.transform.rotation } },
|
||||
});
|
||||
store.dispatch(commandIds.toolExitMaskEdit, undefined);
|
||||
store.dispatch(commandIds.toolSetActive, { tool: "magicWand" });
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveTarget(document: ImageDocument, editor: EditorState) {
|
||||
const layerId = editor.selection.layerIds[0];
|
||||
if (!layerId || editor.selection.layerIds.length !== 1) return undefined;
|
||||
const layer = findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
|
||||
if (!layer || layer.type === "group") return undefined;
|
||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
|
||||
const maskLayer = layer.clippingMask ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layer.clippingMask.maskLayerId) : undefined;
|
||||
const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
|
||||
return asset && bounds ? { layer, asset, bounds, maskAsset } : undefined;
|
||||
}
|
||||
|
||||
async function createWandMask(source: string, existingMaskSource: string | undefined, width: number, height: number, startX: number, startY: number, settings: EditorState["tools"]["magicWand"]) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.max(1, width);
|
||||
canvas.height = Math.max(1, height);
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return source;
|
||||
const image = await loadImage(source);
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const start = (startY * canvas.width + startX) * 4;
|
||||
const key = [imageData.data[start] ?? 0, imageData.data[start + 1] ?? 0, imageData.data[start + 2] ?? 0];
|
||||
const selected = postProcessSelection(settings.contiguous ? floodSelect(imageData, canvas.width, canvas.height, startX, startY, key, settings.tolerance) : globalSelect(imageData, key, settings.tolerance), canvas.width, canvas.height, settings);
|
||||
const existingAlpha = existingMaskSource ? await loadMaskAlpha(existingMaskSource, canvas.width, canvas.height) : undefined;
|
||||
for (let pixel = 0; pixel < selected.length; pixel++) {
|
||||
const current = existingAlpha?.[pixel] ?? 255;
|
||||
const value = settings.mode === "add" ? (selected[pixel] ? 0 : current) : settings.mode === "subtract" ? (selected[pixel] ? 255 : current) : selected[pixel] ? 0 : 255;
|
||||
const index = pixel * 4;
|
||||
imageData.data[index] = 255;
|
||||
imageData.data[index + 1] = 255;
|
||||
imageData.data[index + 2] = 255;
|
||||
imageData.data[index + 3] = value;
|
||||
}
|
||||
context.putImageData(imageData, 0, 0);
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
function floodSelect(data: ImageData, width: number, height: number, startX: number, startY: number, key: number[], tolerance: number) {
|
||||
const selected = new Uint8Array(width * height);
|
||||
const queue: Array<[number, number]> = [[startX, startY]];
|
||||
while (queue.length) {
|
||||
const [x, y] = queue.pop()!;
|
||||
if (x < 0 || y < 0 || x >= width || y >= height) continue;
|
||||
const pixel = y * width + x;
|
||||
if (selected[pixel]) continue;
|
||||
if (!matches(data, pixel, key, tolerance)) continue;
|
||||
selected[pixel] = 1;
|
||||
queue.push([x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function globalSelect(data: ImageData, key: number[], tolerance: number) {
|
||||
const selected = new Uint8Array(data.width * data.height);
|
||||
for (let pixel = 0; pixel < selected.length; pixel++) if (matches(data, pixel, key, tolerance)) selected[pixel] = 1;
|
||||
return selected;
|
||||
}
|
||||
|
||||
function matches(data: ImageData, pixel: number, key: number[], tolerance: number) {
|
||||
const index = pixel * 4;
|
||||
return Math.hypot((data.data[index] ?? 0) - (key[0] ?? 0), (data.data[index + 1] ?? 0) - (key[1] ?? 0), (data.data[index + 2] ?? 0) - (key[2] ?? 0)) <= tolerance;
|
||||
}
|
||||
|
||||
function postProcessSelection(selected: Uint8Array, width: number, height: number, settings: EditorState["tools"]["magicWand"]) {
|
||||
let next = selected;
|
||||
const despeckle = Math.round(Math.max(0, Math.min(20, settings.despeckle)));
|
||||
const choke = Math.round(Math.max(-20, Math.min(20, settings.choke)));
|
||||
const feather = Math.round(Math.max(0, Math.min(20, settings.feather)));
|
||||
if (despeckle > 0) next = despeckleSelection(next, width, height, despeckle);
|
||||
if (choke > 0) next = erodeSelection(next, width, height, choke);
|
||||
if (choke < 0) next = dilateSelection(next, width, height, -choke);
|
||||
if (feather > 0) next = featherSelection(next, width, height, feather);
|
||||
return next;
|
||||
}
|
||||
|
||||
function erodeSelection(selected: Uint8Array, width: number, height: number, radius: number) {
|
||||
const next = new Uint8Array(selected.length);
|
||||
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
|
||||
let value = 1;
|
||||
for (let oy = -radius; oy <= radius; oy++) for (let ox = -radius; ox <= radius; ox++) value = Math.min(value, selected[clamp(y + oy, 0, height - 1) * width + clamp(x + ox, 0, width - 1)] ?? 0);
|
||||
next[y * width + x] = value;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function dilateSelection(selected: Uint8Array, width: number, height: number, radius: number) {
|
||||
const next = new Uint8Array(selected.length);
|
||||
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
|
||||
let value = 0;
|
||||
for (let oy = -radius; oy <= radius; oy++) for (let ox = -radius; ox <= radius; ox++) value = Math.max(value, selected[clamp(y + oy, 0, height - 1) * width + clamp(x + ox, 0, width - 1)] ?? 0);
|
||||
next[y * width + x] = value;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function featherSelection(selected: Uint8Array, width: number, height: number, radius: number) {
|
||||
const next = new Uint8Array(selected.length);
|
||||
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
|
||||
let total = 0;
|
||||
let count = 0;
|
||||
for (let oy = -radius; oy <= radius; oy++) for (let ox = -radius; ox <= radius; ox++) {
|
||||
total += selected[clamp(y + oy, 0, height - 1) * width + clamp(x + ox, 0, width - 1)] ?? 0;
|
||||
count += 1;
|
||||
}
|
||||
next[y * width + x] = Math.round(total / count);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function despeckleSelection(selected: Uint8Array, width: number, height: number, strength: number) {
|
||||
const radius = Math.max(1, Math.ceil(strength / 6));
|
||||
const threshold = Math.max(1, Math.round(strength / 2));
|
||||
const next = new Uint8Array(selected);
|
||||
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
|
||||
const index = y * width + x;
|
||||
let same = 0;
|
||||
for (let oy = -radius; oy <= radius; oy++) for (let ox = -radius; ox <= radius; ox++) if (ox !== 0 || oy !== 0) {
|
||||
if ((selected[clamp(y + oy, 0, height - 1) * width + clamp(x + ox, 0, width - 1)] ?? 0) === selected[index]) same += 1;
|
||||
}
|
||||
if (same <= threshold) next[index] = selected[index] ? 0 : 1;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
async function loadMaskAlpha(source: string, width: number, height: number) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return undefined;
|
||||
const image = await loadImage(source);
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
const data = context.getImageData(0, 0, width, height);
|
||||
const alpha = new Uint8ClampedArray(width * height);
|
||||
for (let pixel = 0; pixel < alpha.length; pixel++) alpha[pixel] = data.data[pixel * 4 + 3] ?? 255;
|
||||
return alpha;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function findLayer(layers: readonly Layer[], layerId: string): Layer | undefined {
|
||||
for (const layer of layers) {
|
||||
if (layer.id === layerId) return layer;
|
||||
if (layer.type === "group") {
|
||||
const found = findLayer(layer.children, layerId);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function loadImage(source: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error("Failed to load image"));
|
||||
image.src = source;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user