diff --git a/src/Canvas.spec.ts b/src/Canvas.spec.ts index 29e54ad..8601aab 100644 --- a/src/Canvas.spec.ts +++ b/src/Canvas.spec.ts @@ -1,6 +1,7 @@ import { expect } from 'chai'; import { Canvas } from './Canvas'; import { Color } from './Color'; +import { expectColor } from './Color.spec'; describe('Canvas', () => { @@ -17,20 +18,20 @@ describe('Canvas', () => { it('supports simple pixels', () => { canvas.setPixel(black, 0, 0); - expect(canvas.getPixel(0, 0)).to.equal(black); + expectColor(canvas.getPixel(0, 0), black); canvas.setPixel(white, 10, 10); - expect(canvas.getPixel(10, 10)).to.equal(white); + expectColor(canvas.getPixel(10, 10), white); }); it('supports negative pixels', () => { canvas.setPixel(red, -5, -10); - expect(canvas.getPixel(-5, -10)).to.equal(red); + expectColor(canvas.getPixel(-5, -10), red); canvas.setPixel(black, 0, 0); - expect(canvas.getPixel(0, 0)).to.equal(black); + expectColor(canvas.getPixel(0, 0), black); canvas.setPixel(white, 10, 10); - expect(canvas.getPixel(10, 10)).to.equal(white); + expectColor(canvas.getPixel(10, 10), white); }); }); diff --git a/src/Canvas.ts b/src/Canvas.ts index 52a53ab..27f4d5b 100644 --- a/src/Canvas.ts +++ b/src/Canvas.ts @@ -1,4 +1,4 @@ -import type { Color } from './Color'; +import { Color } from './Color'; import * as Jimp from 'jimp'; import { colorAverage } from './util'; @@ -14,7 +14,7 @@ export class Canvas { * A map of rows, containing columns, all indexed by their x or y coordinate. * Index by Y first, then X */ - private grid = new Map>(); + private grid = new Map>(); /** * Get the lowest-defined x value @@ -77,47 +77,59 @@ export class Canvas { public getPixel(x: number, y: number) { x = Math.round(x); y = Math.round(y); - return this.grid.get(y)?.get(x); + const colorStack = this.grid.get(y)?.get(x); + if (colorStack) { + return Color.blend(this.backgroundColor, ...colorStack); + } } /** * Get the row at the specified y coordinate. If it doesn't exist, create it */ - private getRow(y: number) { + private getCell(x: number, y: number) { y = Math.round(y); - let result = this.grid.get(y); - if (!result) { - result = new Map(); - this.grid.set(y, result); + let row = this.grid.get(y); + if (!row) { + row = new Map(); + this.grid.set(y, row); + } + let cell = row.get(x); + if (!cell) { + cell = []; + row.set(x, cell); } - return result; + + return cell; } /** - * Set the color for the given coordinate + * Set the color (or color stack) for the given coordinate. */ - public setPixel(color: Color, x: number, y: number) { + public setPixel(color: Color | Color[], x: number, y: number) { + const colorStack = Array.isArray(color) ? color : [color]; x = Math.round(x); - this.getRow(y).set(x, color); + this.getCell(x, y).push(...colorStack); } /** - * Delete a pixel at the specified location + * Delete a pixel at the specified location. This deletes the whole stack of pixels at that position. */ public deletePixel(x: number, y: number) { x = Math.round(x); y = Math.round(y); - const row = this.getRow(y); - row.delete(x); - if (row.size === 0) { - this.grid.delete(y); + const row = this.grid.get(y); + if (row) { + row.delete(x); + if (row.size === 0) { + this.grid.delete(y); + } } } /** * Set the color for each of the given coordinates */ - public setPixels(color: Color, ...points: Array<[x: number, y: number]>) { + public setPixels(color: Color | Color[], ...points: Array<[x: number, y: number]>) { for (let [x, y] of points) { this.setPixel(color, x, y); } @@ -141,75 +153,6 @@ export class Canvas { } } - /** - * Merge the incoming color with the color at the current coordinates. - */ - public mergePixels(color: Color, ...pixels: Array<[x: number, y: number]>) { - for (const [x, y] of pixels) { - const current = this.getPixel(x, y) ?? this.backgroundColor.clone(); - const merged = current.merge(color); - this.setPixels(merged, [x, y]); - } - } - - /** - * Set a pixel color, and anti-alias the pixels around it - */ - public setAntiAliased(color: Color, x: number, y: number) { - //get a new color with the alpha value totally blanked out - color = color.clone().setAlpha(0); - - for (let roundedX = Math.floor(x); roundedX < Math.ceil(x); roundedX++) { - for (let roundedY = Math.floor(y); roundedY < Math.ceil(y); roundedY++) { - let percentX = 1 - Math.abs(x - roundedX); - let percentY = 1 - Math.abs(y - roundedY); - let percent = percentX * percentY; - - const currentAlpha = (this.getPixel(x, y) ?? color.clone()).alpha; - const additionalAlpha = 255 * percent; - //make the pixel more solid by this percentage - const antiAliasedColor = color.clone().setAlpha( - currentAlpha + additionalAlpha - ); - - this.setPixels( - antiAliasedColor, - [roundedX, roundedY] - ); - } - } - } - - public boxBlur(defaultColor: Color, opacityPercent: number) { - const result = new Canvas(this.backgroundColor); - const { minX, minY, maxX, maxY, width, height } = this; - for (let y = minY; y < maxY; y++) { - for (let x = minX; x < maxX; x++) { - //skip these out of bounds pixels - if (x < 1 || y < 1 || x + 1 === width || y + 1 === height) { - continue; - } - // Set P to the average of 9 pixels: - const color = colorAverage( - defaultColor, - [ - this.getPixel(x - 1, y + 1), // Top left - this.getPixel(x + 0, y + 1), // Top center - this.getPixel(x + 1, y + 1), // Top right - this.getPixel(x - 1, y + 0), // Mid left - this.getPixel(x + 0, y + 0), // Current pixel - this.getPixel(x + 1, y + 0), // Mid right - this.getPixel(x - 1, y - 1), // Low left - this.getPixel(x + 0, y - 1), // Low center - this.getPixel(x + 1, y - 1) // Low right - ] - ); - result.setPixels(color.setAlpha(color.alpha * opacityPercent), [x, y]); - } - } - this.grid = result.grid; - } - /** * Translate all the pixels by this amount */ @@ -231,8 +174,9 @@ export class Canvas { } }); for (const [y, row] of this.grid) { - for (const [x, color] of row) { - image.setPixelColor(color?.toInteger(), x, y); + for (const [x, colorStack] of row) { + const finalColor = Color.blend(this.backgroundColor, ...colorStack); + image.setPixelColor(finalColor?.toInteger(), x, y); } } return image.write(outPath); diff --git a/src/Color.spec.ts b/src/Color.spec.ts index 3a94a35..19c25ef 100644 --- a/src/Color.spec.ts +++ b/src/Color.spec.ts @@ -3,11 +3,6 @@ import type { ColorLike, RgbaArray } from './Color'; import { Color } from './Color'; describe('Color', () => { - function expectColor(input: ColorLike, expected: RgbaArray) { - const color = new Color(input); - expect(color.toRgbaArray()).to.eql(expected); - } - it('parses hex', () => { expectColor('#01020304', [1, 2, 3, 4]); }); @@ -124,3 +119,8 @@ describe('Color', () => { }); }); }); + +export function expectColor(input: ColorLike | undefined, expected: ColorLike) { + const color = new Color(input as any); + expect(color.toRgbaArray()).to.eql(new Color(expected).toRgbaArray()); +} diff --git a/src/Color.ts b/src/Color.ts index 7ae38b9..ee3ef45 100644 --- a/src/Color.ts +++ b/src/Color.ts @@ -7,6 +7,9 @@ export class Color { this.set(value); } + /** + * Stores the rgb and a values as integers within the range (0 - 255) + */ private value!: RgbaArray; /** @@ -126,7 +129,53 @@ export class Color { } public toString() { - return `rgba(${this.red}, ${this.green}, ${this.blue}, ${this.alpha}`; + return `rgba(${this.red}, ${this.green}, ${this.blue}, ${this.alpha / 255}`; + } + + /** + * Blend one or more colors together into a single color. + * The first color is the bottom color + */ + public static blend(...colors: Color[]): Color { + const args = colors.map(x => { + return [ + x.red, + x.green, + x.blue, + x.alpha / 255 + ]; + }); + let base = [0, 0, 0, 0]; + let mix = [] as unknown as RgbaArray; + let added; + while ((added = args.shift())) { + if (typeof added[3] === 'undefined') { + added[3] = 1; + } + // check if both alpha channels exist. + if (base[3] && added[3]) { + mix = [0, 0, 0, 0]; + // alpha + mix[3] = 1 - ((1 - added[3]) * (1 - base[3])); + // red + mix[0] = Math.round((added[0] * added[3] / mix[3]) + (base[0] * base[3] * (1 - added[3]) / mix[3])); + // green + mix[1] = Math.round((added[1] * added[3] / mix[3]) + (base[1] * base[3] * (1 - added[3]) / mix[3])); + // blue + mix[2] = Math.round((added[2] * added[3] / mix[3]) + (base[2] * base[3] * (1 - added[3]) / mix[3])); + + } else if (added) { + mix = added as any; + } else { + mix = base as any; + } + base = mix; + } + if (mix?.length === 4) { + mix[3] = snap(mix[3] * 255); + return new Color(mix as any); + } + return undefined as unknown as Color; } } @@ -161,7 +210,7 @@ function isColor(value: any): value is Color { } /** - * Take any color value and turn it into an rgba array + * Take any color value and turn it into an rgba array (each representing 0-255, even alpha) */ function toRgbaArray(value: ColorLike) { let result: number[] | undefined;