Add crummy marching ants + selection system

main
kts of kettek 2024-02-16 15:49:59 -08:00
parent 5813cfabdb
commit 8876ab88f9
5 changed files with 289 additions and 24 deletions

View File

@ -17,7 +17,7 @@
import { Close, Erase, PaintBrushAlt, RainDrop, Redo, Select_01, Undo, Scale, Eyedropper } from "carbon-icons-svelte"
import StackPreview from './sections/StackPreview.svelte'
import type { Canvas } from './types/canvas'
import { BrushTool, EraserTool, FillTool, PickerTool, type BrushType, type Tool } from './types/tools'
import { BrushTool, EraserTool, FillTool, PickerTool, SelectionTool, type BrushType, type Tool } from './types/tools'
import BrushSize from './components/BrushSize.svelte'
import Shortcut from './components/Shortcut.svelte'
import Shortcuts from './components/Shortcuts.svelte'
@ -36,7 +36,7 @@
let showPreview: boolean = false
// let toolSelect = new SelectTool()
let toolSelection = new SelectionTool()
let toolFill = new FillTool()
let toolErase = new EraserTool()
let toolBrush = new BrushTool()
@ -108,7 +108,7 @@
<PaletteSection bind:palette bind:primaryColorIndex bind:secondaryColorIndex file={focusedFile} />
</section>
<menu class='toolbar'>
<Button disabled kind="ghost" size="small" icon={Select_01} iconDescription="selection" tooltipPosition="right"></Button>
<Button isSelected={currentTool === toolSelection} kind="ghost" size="small" icon={Select_01} iconDescription="selection" tooltipPosition="right" on:click={()=>swapTool(toolSelection)}></Button>
<Button isSelected={currentTool === toolFill} kind="ghost" size="small" icon={RainDrop} iconDescription="fill" tooltipPosition="right" on:click={()=>swapTool(toolFill)}></Button>
<Button isSelected={currentTool === toolBrush} kind="ghost" size="small" icon={PaintBrushAlt} iconDescription="paint" tooltipPosition="right" on:click={()=>swapTool(toolBrush)}></Button>
<Button isSelected={currentTool === toolPicker} kind="ghost" size="small" icon={Eyedropper} iconDescription="pick" tooltipPosition="right" on:click={()=>swapTool(toolPicker)}></Button>

View File

@ -4,7 +4,7 @@
import type { data } from '../../wailsjs/go/models.ts'
import type { LoadedFile } from '../types/file'
import { FilledCircle, FilledSquare, type PixelPosition } from '../types/shapes'
import { BrushTool, EraserTool, FillTool, PickerTool, type BrushType, type Tool } from '../types/tools'
import { BrushTool, EraserTool, FillTool, PickerTool, type BrushType, type Tool, SelectionTool } from '../types/tools'
import { Button, NumberInput, OverflowMenu, OverflowMenuItem, Slider } from 'carbon-components-svelte';
import { ZoomIn, ZoomOut } from 'carbon-icons-svelte';
@ -19,6 +19,7 @@
let offsetX: number
let offsetY: number
let zoom: number = 1.0
$: file.selection.resize(file.canvas.width, file.canvas.height, zoom)
let mouseX: number = 0
let mouseY: number = 0
@ -98,6 +99,9 @@
drawOverlay()
overlayDirty = false
}
file.selection.update()
let ctx = rootCanvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, rootCanvas.width, rootCanvas.height)
@ -110,7 +114,38 @@
ctx.drawImage(file.canvas.canvas, offsetX, offsetY)
ctx.restore()
// Draw our selection overlay.
if (file.selection.active) {
ctx.imageSmoothingEnabled = false
ctx.drawImage(file.selection.marchingCanvas, offsetX*zoom, offsetY*zoom)
}
// FIXME: Reorganize overlay drawing to have two types: regular composition, such as this pixel brush preview, and difference composition for cursors and bounding boxes.
// Draw brush preview.
if (currentTool instanceof BrushTool) {
let shape: PixelPosition[]
if (brushType === 'square' || brushSize <= 2) {
// FIXME: This is daft to adjust +1,+1 for size 2 -- without this, the rect preview draws one pixel offset to the top-left, which is not the same as when the filled rect is placed.
if (brushSize === 2) {
shape = FilledSquare(1, 1, brushSize, 1)
} else {
shape = FilledSquare(0, 0, brushSize, 1)
}
} else if (brushType === 'circle') {
shape = FilledCircle(0, 0, brushSize-2, 1)
}
let {r, g, b, a } = file.canvas.getPaletteAsRGBA(primaryColorIndex)
ctx.fillStyle = `rgba(${r},${g},${b},${a})`
for (let i = 0; i < shape.length; i++) {
ctx.fillRect(offsetX*zoom+(mousePixelX+shape[i].x)*zoom, offsetY*zoom+(mousePixelY+shape[i].y)*zoom, zoom, zoom)
}
}
// Draw our overlay with difference composition so visibility is better.
ctx.save()
ctx.globalCompositeOperation = 'difference'
ctx.drawImage(overlayCanvas, 0, 0)
ctx.restore()
}
function drawCanvas() {
@ -161,27 +196,13 @@
// Draw a cursor.
{
ctx.beginPath()
ctx.strokeStyle = '#ff0000'
ctx.strokeStyle = '#cc3388'
ctx.lineWidth = 1
// Draw brush preview.
if (currentTool instanceof BrushTool) {
let shape: PixelPosition[]
if (brushType === 'square' || brushSize <= 2) {
// FIXME: This is daft to adjust +1,+1 for size 2 -- without this, the rect preview draws one pixel offset to the top-left, which is not the same as when the filled rect is placed.
if (brushSize === 2) {
shape = FilledSquare(1, 1, brushSize, 1)
} else {
shape = FilledSquare(0, 0, brushSize, 1)
}
} else if (brushType === 'circle') {
shape = FilledCircle(0, 0, brushSize-2, 1)
}
let {r, g, b, a }= file.canvas.getPaletteAsRGBA(primaryColorIndex)
ctx.fillStyle = `rgba(${r},${g},${b},${a})`
for (let i = 0; i < shape.length; i++) {
ctx.fillRect(offsetX*zoom+(mousePixelX+shape[i].x)*zoom, offsetY*zoom+(mousePixelY+shape[i].y)*zoom, zoom, zoom)
}
// Draw bounding box selection preview.
if (currentTool instanceof SelectionTool && currentTool.isActive()) {
let {x, y, width, height} = currentTool.getArea()
ctx.strokeRect(offsetX*zoom+x*zoom, offsetY*zoom+y*zoom, width*zoom, height*zoom)
}
// Draw zoomed pixel-sized square where mouse is.
if (zoom > 1) {

View File

@ -1,5 +1,6 @@
import type { data } from '../../wailsjs/go/models.ts'
import type { Canvas } from './canvas'
import { SelectionArea } from './selection'
import { UndoableStack, type Undoable } from './undo'
export interface LoadedFileOptions {
@ -13,28 +14,33 @@ export class LoadedFile extends UndoableStack<LoadedFile> {
filepath: string
title: string
canvas: Canvas
selection: SelectionArea
data: data.StackistFileV1
constructor(options: LoadedFileOptions) {
super()
this.setTarget(this)
this.data = options.data
this.filepath = options.filepath
this.title = options.title
this.canvas = options.canvas
this.data = options.data
this.selection = new SelectionArea(options.canvas.width, options.canvas.height, 1)
}
undo() {
super.undo()
this.canvas.refreshCanvas()
this.selection.refresh()
}
redo() {
super.redo()
this.canvas.refreshCanvas()
this.selection.refresh()
}
push(item: Undoable<LoadedFile>) {
super.push(item)
this.canvas.refreshCanvas()
this.selection.refresh()
}
}

View File

@ -0,0 +1,177 @@
export class SelectionArea {
public marchingCanvas: HTMLCanvasElement
private marchStep: number = 0
private offsetX: number = 0
private offsetY: number = 8
public active: boolean
private canvas: HTMLCanvasElement
private pixelMaskCanvas: HTMLCanvasElement
private pixelMaskCanvasPixels: ImageData
private redrawPixelMask: boolean
private checkerboard: HTMLCanvasElement
constructor(width: number, height: number, zoom: number) {
this.canvas = document.createElement('canvas')
this.canvas.width = width * zoom
this.canvas.height = height * zoom
this.marchingCanvas = document.createElement('canvas')
this.marchingCanvas.width = width * zoom
this.marchingCanvas.height = height * zoom
this.pixelMaskCanvas = document.createElement('canvas')
this.pixelMaskCanvas.width = width
this.pixelMaskCanvas.height = height
this.pixelMaskCanvasPixels = new ImageData(width, height)
this.checkerboard = document.createElement('canvas')
this.checkerboard.width = 64
this.checkerboard.height = 64
let ctx = this.checkerboard.getContext('2d')
ctx.fillStyle = '#000000'
ctx.fillRect(0, 0, this.checkerboard.width, this.checkerboard.height)
let rows = this.checkerboard.height / 4
let cols = this.checkerboard.width / 4
ctx.beginPath()
ctx.fillStyle = '#ffffff'
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (r % 2 === 0 && c % 2 === 1 || r % 2 === 1 && c % 2 === 0) {
ctx.rect(
c * 4,
r * 4,
4,
4,
)
}
}
}
ctx.fill()
this.redrawPixelMask = true
this.refresh()
}
public resize(width: number, height: number, zoom: number) {
// Copy the old pixel mask pixels to a new one.
let pixelMaskCanvasPixels = new ImageData(width, height)
for (let y = 0; y < Math.min(this.pixelMaskCanvas.height, height); y++) {
for (let x = 0; x < Math.min(this.pixelMaskCanvas.width, width); x++) {
pixelMaskCanvasPixels.data[(y * width + x) * 4 + 3] = this.pixelMaskCanvasPixels.data[(y * this.pixelMaskCanvas.width + x) * 4 + 3]
}
}
this.pixelMaskCanvasPixels = pixelMaskCanvasPixels
// Resize canvases and redraw.
this.marchingCanvas.width = width * zoom
this.marchingCanvas.height = height * zoom
this.canvas.width = width * zoom
this.canvas.height = height * zoom
this.pixelMaskCanvas.width = width
this.pixelMaskCanvas.height = height
this.redrawPixelMask = true
this.refresh()
}
public refresh() {
if (this.redrawPixelMask) {
this.pixelMaskCanvas.getContext('2d').putImageData(this.pixelMaskCanvasPixels, 0, 0)
let ctx = this.canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
ctx.imageSmoothingEnabled = false
ctx.save()
ctx.scale(this.canvas.width / this.pixelMaskCanvas.width, this.canvas.height / this.pixelMaskCanvas.height)
ctx.drawImage(this.pixelMaskCanvas, 0, 0)
ctx.restore()
let imageData = ctx.getImageData(0, 0, this.canvas.width, this.canvas.height)
let dilatedImageData = ctx.createImageData(this.canvas.width, this.canvas.height)
let getPixelIndex = (x: number, y: number): number => {
return (y * this.canvas.width + x) * 4
}
// Dilate the pixel mask.
for (let y = 0; y < imageData.height; y++) {
for (let x = 0; x < imageData.width; x++) {
let i = getPixelIndex(x, y)
let a = imageData.data[i + 3]
if (a === 255) {
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
let xx = x + dx
let yy = y + dy
if (xx >= 0 && xx < imageData.width && yy >= 0 && yy < imageData.height) {
let ii = getPixelIndex(xx, yy)
dilatedImageData.data[ii + 3] = 255
}
}
}
}
}
}
// Subtract the original pixel mask from the dilated one.
for (let y = 0; y < imageData.height; y++) {
for (let x = 0; x < imageData.width; x++) {
let i = getPixelIndex(x, y)
let a = imageData.data[i + 3]
if (a === 255) {
dilatedImageData.data[i + 3] = 0
}
}
}
// Put the dilated pixel mask back into the canvas.
ctx.putImageData(dilatedImageData, 0, 0)
this.redrawPixelMask = false
}
}
update() {
this.refresh()
if (this.marchStep % 10 === 0) {
let ctx = this.marchingCanvas.getContext('2d')
ctx.clearRect(0, 0, this.marchingCanvas.width, this.marchingCanvas.height)
ctx.drawImage(this.canvas, 0, 0)
ctx.save()
ctx.globalCompositeOperation = 'source-atop'
// Fill with checkerboard offset by the marching ants offset.
ctx.fillStyle = ctx.createPattern(this.checkerboard, 'repeat')
ctx.translate(this.offsetX, this.offsetY)
ctx.fillRect(-this.offsetX, -this.offsetY, this.marchingCanvas.width, this.marchingCanvas.height)
ctx.restore()
this.offsetX++
this.offsetY++
if (this.offsetX > 8) {
this.offsetX = 0
}
if (this.offsetY > 8) {
this.offsetY = 0
}
}
this.marchStep++
}
setPixel(x: number, y: number, marked: boolean) {
this.redrawPixelMask = true
this.pixelMaskCanvasPixels.data[(y * this.pixelMaskCanvas.width + x) * 4 + 3] = marked ? 255 : 0
}
clear() {
this.redrawPixelMask = true
for (let i = 0; i < this.pixelMaskCanvasPixels.data.length; i += 4) {
this.pixelMaskCanvasPixels.data[i + 3] = 0
}
}
}

View File

@ -35,6 +35,9 @@ export interface FloodToolContext {
colorIndex: number
}
export interface SelectionToolContext {
}
export interface PickerToolContext {
setColorIndex(index: number): void
}
@ -196,6 +199,64 @@ export class PickerTool implements Tool {
}
}
pointerUp(ctx: ToolContext & PickerToolContext, ptr: Pointer) {
this.active = false
}
}
export class SelectionTool implements Tool {
private active: boolean
private startX: number
private startY: number
private endX: number
private endY: number
isActive(): boolean {
return this.active
}
getArea(): { x: number, y: number, width: number, height: number } {
let x1 = Math.min(this.startX, this.endX)
let x2 = Math.max(this.startX, this.endX)
let y1 = Math.min(this.startY, this.endY)
let y2 = Math.max(this.startY, this.endY)
return {
x: x1,
y: y1,
width: x2-x1+1,
height: y2-y1+1,
}
}
pointerDown(ctx: ToolContext & SelectionToolContext, ptr: Pointer) {
ctx.file.selection.active = true
this.startX = this.endX = ptr.x
this.startY = this.endY = ptr.y
this.active = true
}
pointerMove(ctx: ToolContext & SelectionToolContext, ptr: Pointer) {
if (ptr.x < 0) ptr.x = 0
if (ptr.y < 0) ptr.y = 0
if (ptr.x >= ctx.file.canvas.width) ptr.x = ctx.file.canvas.width-1
if (ptr.y >= ctx.file.canvas.height) ptr.y = ctx.file.canvas.height-1
this.endX = ptr.x
this.endY = ptr.y
}
pointerUp(ctx: ToolContext & SelectionToolContext, ptr: Pointer) {
if (this.startX === this.endX && this.startY === this.endY) {
ctx.file.selection.clear()
ctx.file.selection.active = false
this.active = false
return
}
let {x: startX, y: startY, width, height} = this.getArea()
for (let x = startX; x <= startX+width-1; x++) {
for (let y = startY; y <= startY+height-1; y++) {
ctx.file.selection.setPixel(x, y, true)
}
}
this.active = false
}
}