From 97fc5a294f0377874afbe5a4863f8bcc618df693 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 21:08:27 +0000 Subject: [PATCH 1/2] feat(avatar): line, rectangle, gradient, noise and shade drawing tools Pixel-perfect lines with angle snapping, rectangle outlines, a tone gradient with ordered dithering over the start region, seeded tone noise and a shade stroke. Every tool mirrors across the centerline and produces ordinary cells, so content and identity never depend on the tool or seed. Released as 0.8.0. Refs: BotHarness/DeepSeekBot#1216 Agent-Task: claude/cloud/session_01MozvQ2eMvqX1MzSqm9G2o8 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MozvQ2eMvqX1MzSqm9G2o8 --- CHANGELOG.md | 1 + packages/avatar/README.md | 1 + packages/avatar/package.json | 2 +- packages/avatar/src/index.ts | 6 + packages/avatar/src/part.ts | 207 +++++++++++++++++++++ packages/avatar/test/drawing-tools.test.ts | 167 +++++++++++++++++ 6 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 packages/avatar/test/drawing-tools.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f87b262..977ad17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- `@botharness/pixel-avatar` 0.8.0: drawing tool algorithms for Custom Parts. `partLinePoints` (Bresenham, with 0°/45°/90° snapping), `partRectPoints` (outline, with a square option), `gradientPartLayer` (a linear gradient over the start cell's 4-connected region that steps through tones with 4×4 or 2×2 ordered dithering via `ditherThreshold`), `noisePartLayer` (seeded ±1 tone texture) and `shadePartLayer` (moves each colored cell on a stroke one tone, once). Every tool mirrors across the centerline, and each produces ordinary cells, so a part's content and identity never depend on the tool or the seed. Rendering is unchanged. - `@botharness/pixel-avatar` 0.7.0: drawn replacement parts. The `outfit`, `accessory`, `beard`, `glasses`, `nose`, `cheeks`, `petals` and `flowerBase` Custom Part slots replace the built-in part in the same layer, and the built-in choice stays saved. `replacePartStart` flattens the worn part to appearance-color cells where exact and fixed colors elsewhere, so an unchanged copy renders identically. Drawn face parts never cover the pixels any speaking mouth uses, and hiding follows `hiddenChoices`. `MAX_PART_FIXED_COLORS` rises to 32, because a flattened outfit can use up to 17. Every earlier recipe renders unchanged. - `@botharness/pixel-avatar` 0.6.0: drawn hair pieces. The `bangs`, `leftSideHair`, `rightSideHair` and `backHair` Custom Part slots replace one built-in hair piece each, with the built-in choice kept underneath. A `hairColor` cell at tone 0 is live hair, shaded like built-in hair, so `hairPieceStart` flattens the worn piece (including hair ties) and an unchanged copy renders identically. `withCustomPart` and `wornPart` generalise the headpiece helpers; asset version 3 now means a recipe wearing at least one Custom Part. Every earlier recipe renders unchanged. - `@botharness/pixel-avatar` 0.5.0: Custom Parts. A `PixelCustomPart` in the `headpiece` slot has a back layer drawn behind the hair and a front layer over it. Its cells reference an appearance color slot or a fixed color plus a tone step, so a part recolors with the Avatar, and it follows the head through turns. `withHeadpiece` embeds a copy in the recipe as asset version 3, which older consumers reject so they can show a saved snapshot. `customPartId` is a SHA-256 of the canonical content, and `paintPartLayer`, `fillPartLayer` (4-connected) and `mirrorPartX` provide the drawing algorithms. Every earlier recipe renders unchanged. diff --git a/packages/avatar/README.md b/packages/avatar/README.md index a511f27..5faff0d 100644 --- a/packages/avatar/README.md +++ b/packages/avatar/README.md @@ -24,5 +24,6 @@ const svg = pixelAvatarSvg(recipe); // 32×32 viewBox, crisp edges, no ids or sc - `HAIR_PART_SLOTS`, `hairPieceStart(recipe, slot)`, `withCustomPart(recipe, slot, part)`, `wornPart`: drawn hair pieces. `bangs`, `leftSideHair`, `rightSideHair` and `backHair` each take a Custom Part (front layer only) that replaces the built-in piece while it stays saved underneath. In a hair part, a `hairColor` cell at tone 0 is live hair: it is shaded with the rest of the hair exactly like the built-in piece, so `hairPieceStart` flattens the worn built-in piece into such cells and an unchanged copy renders identically. Other cells show their own color. Drawn hair follows turns and is hidden under a helmet or hood and on a flower, like built-in hair. - `REPLACE_PART_SLOTS`, `replacePartStart(recipe, slot)`: drawn `outfit`, `accessory`, `beard`, `glasses`, `nose`, `cheeks`, `petals` and `flowerBase` parts (front layer only). A drawn part replaces the built-in part pixel for pixel in the layer that part paints into, while the built-in choice stays saved underneath. `replacePartStart` flattens the worn built-in part for this recipe: pixels that are exactly an appearance color at a tone become that color slot, and the rest become fixed colors, so an unchanged copy renders identically. Drawn face parts never cover the speaking mouth, and drawn parts are hidden wherever the species or headwear hides the built-in part. A drawn accessory replacing a helmet or hood no longer hides the hair. - `emptyPartLayer`, `paintPartLayer`, `fillPartLayer`, `mirrorPartX`, `partLayer`, `partCells`, `createCustomPart`: drawing on dense layers, with pencil and eraser strokes, 4-connected flood fill and mirroring across the Avatar centerline. +- `partLinePoints`, `partRectPoints`, `gradientPartLayer`, `ditherThreshold`, `noisePartLayer`, `shadePartLayer`: line (with 0°/45°/90° snap), rectangle (with square), tone gradient with 4×4 or 2×2 ordered dithering inside the start region, seeded ±1 tone noise, and a shade stroke that moves each colored cell one tone. All mirror, and all produce ordinary cells: the seed and the tool are not part of the part. - `faceCells`, `pixelSymbolCells`, `symbolArtCells`: pixels for [`@botharness/pixel-morph`](../morph). - `pixelAvatarSvg(recipe, { turns, classPrefix })`: optional pre-rendered head turns, and rig layer classes (`-body`, `-head`, `-face`, `-gaze`, `-blink`) for CSS animation. diff --git a/packages/avatar/package.json b/packages/avatar/package.json index 422f801..166dae6 100644 --- a/packages/avatar/package.json +++ b/packages/avatar/package.json @@ -1,6 +1,6 @@ { "name": "@botharness/pixel-avatar", - "version": "0.7.0", + "version": "0.8.0", "description": "Seeded, editable 32×32 chibi pixel avatars with state symbols that morph in and out", "keywords": [ "agent-ui", diff --git a/packages/avatar/src/index.ts b/packages/avatar/src/index.ts index ec850e9..5962903 100644 --- a/packages/avatar/src/index.ts +++ b/packages/avatar/src/index.ts @@ -61,7 +61,13 @@ export { createCustomPart, customPartId, emptyPartLayer, + ditherThreshold, fillPartLayer, + gradientPartLayer, + noisePartLayer, + partLinePoints, + partRectPoints, + shadePartLayer, isPixelCustomPart, mirrorPartX, paintPartLayer, diff --git a/packages/avatar/src/part.ts b/packages/avatar/src/part.ts index 581525f..3c5cdde 100644 --- a/packages/avatar/src/part.ts +++ b/packages/avatar/src/part.ts @@ -1,3 +1,4 @@ +import { seededRandom } from './random.js'; import { AVATAR_COLORS, type AvatarColor } from './recipe.js'; /** @@ -215,6 +216,212 @@ export function fillPartLayer( return out; } +/** Pixel-perfect line points (Bresenham). With `snap`, the end snaps to 0°, 45° or 90°. */ +export function partLinePoints( + from: readonly [number, number], + to: readonly [number, number], + snap = false, +): [number, number][] { + let [x1, y1] = to; + const [x0, y0] = from; + if (snap) { + const dx = x1 - x0; + const dy = y1 - y0; + const ax = Math.abs(dx); + const ay = Math.abs(dy); + if (ax > ay * 2) y1 = y0; + else if (ay > ax * 2) x1 = x0; + else { + const m = Math.max(ax, ay); + x1 = x0 + Math.sign(dx) * m; + y1 = y0 + Math.sign(dy) * m; + } + } + const points: [number, number][] = []; + const dx = Math.abs(x1 - x0); + const dy = -Math.abs(y1 - y0); + const sx = x0 < x1 ? 1 : -1; + const sy = y0 < y1 ? 1 : -1; + let error = dx + dy; + let [x, y] = [x0, y0]; + for (;;) { + points.push([x, y]); + if (x === x1 && y === y1) break; + const e2 = 2 * error; + if (e2 >= dy) { + error += dy; + x += sx; + } + if (e2 <= dx) { + error += dx; + y += sy; + } + } + return points; +} + +/** Rectangle outline points between two corners. With `square`, the far corner makes a square. */ +export function partRectPoints( + from: readonly [number, number], + to: readonly [number, number], + square = false, +): [number, number][] { + const [x0, y0] = from; + let [x1, y1] = to; + if (square) { + const m = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0)); + x1 = x0 + (x1 < x0 ? -m : m); + y1 = y0 + (y1 < y0 ? -m : m); + } + const [left, right] = [Math.min(x0, x1), Math.max(x0, x1)]; + const [top, bottom] = [Math.min(y0, y1), Math.max(y0, y1)]; + const points = new Map(); + for (let x = left; x <= right; x++) + for (const y of [top, bottom]) points.set(`${x},${y}`, [x, y]); + for (let y = top; y <= bottom; y++) + for (const x of [left, right]) points.set(`${x},${y}`, [x, y]); + return [...points.values()]; +} + +const BAYER4 = [ + [0, 8, 2, 10], + [12, 4, 14, 6], + [3, 11, 1, 9], + [15, 7, 13, 5], +] as const; +const BAYER2 = [ + [0, 2], + [3, 1], +] as const; + +/** The ordered-dither threshold in [0, 1) for a cell. */ +export function ditherThreshold(x: number, y: number, size: 2 | 4 = 4): number { + return size === 4 ? (BAYER4[y & 3]![x & 3]! + 0.5) / 16 : (BAYER2[y & 1]![x & 1]! + 0.5) / 4; +} + +/** + * A linear gradient over the 4-connected region of the start cell: each cell's position along + * `from`→`to` steps through the tones between `fromTone` and `toTone`, with ordered dithering + * between neighboring tones. No RGB is blended; the result is ordinary cells. + */ +export function gradientPartLayer( + slot: PartSlot, + layer: PartLayer, + from: readonly [number, number], + to: readonly [number, number], + color: PartColor, + fromTone: PartTone, + toTone: PartTone, + options: { dither?: 2 | 4; mirror?: boolean } = {}, +): PartLayer { + const { width, height } = PART_SLOTS[slot]; + const out = layer.map((row) => [...row]); + const [fx, fy] = from; + const region = (sx: number, sy: number): [number, number][] => { + if (sx < 0 || sx >= width || sy < 0 || sy >= height) return []; + const target = layer[sy]![sx]!; + const seen = new Set(); + const cells: [number, number][] = []; + const stack: [number, number][] = [[sx, sy]]; + while (stack.length) { + const [x, y] = stack.pop()!; + if (x < 0 || x >= width || y < 0 || y >= height || seen.has(y * width + x)) continue; + if (!same(layer[y]![x]!, target)) continue; + seen.add(y * width + x); + cells.push([x, y]); + stack.push([x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]); + } + return cells; + }; + const paint = ( + cells: [number, number][], + start: readonly [number, number], + end: readonly [number, number], + ditherX: (x: number) => number, + ) => { + const vx = end[0] - start[0]; + const vy = end[1] - start[1]; + const length = vx * vx + vy * vy; + const steps = toTone - fromTone; + for (const [x, y] of cells) { + const t = + length === 0 + ? 0 + : Math.min(1, Math.max(0, ((x - start[0]) * vx + (y - start[1]) * vy) / length)); + const position = t * Math.abs(steps); + const base = Math.floor(position); + const up = position - base > ditherThreshold(ditherX(x), y, options.dither ?? 4) ? 1 : 0; + const tone = (fromTone + Math.sign(steps) * Math.min(Math.abs(steps), base + up)) as PartTone; + out[y]![x] = { color, tone }; + } + }; + paint(region(fx, fy), from, to, (x) => x); + if (options.mirror) { + const mirrored = (point: readonly [number, number]) => + [mirrorPartX(slot, point[0]), point[1]] as const; + paint( + region(mirrorPartX(slot, fx), fy).filter(([x, y]) => out[y]![x] === layer[y]![x]), + mirrored(from), + mirrored(to), + (x) => mirrorPartX(slot, x), + ); + } + return out; +} + +/** + * Moves the tone of colored cells by ±1 at random, for texture. The same seed gives the same + * result; the seed is used only now, and the result is ordinary cells. With `mirror`, each cell + * and its mirror get the same change. + */ +export function noisePartLayer( + slot: PartSlot, + layer: PartLayer, + amount: number, + seed: string, + mirror = false, +): PartLayer { + const random = seededRandom(seed); + const rolls = layer.map((row) => row.map(() => [random(), random() < 0.5 ? -1 : 1] as const)); + return layer.map((row, y) => + row.map((ink, x) => { + const source = mirror ? Math.min(x, mirrorPartX(slot, x)) : x; + const [roll, direction] = rolls[y]![source]!; + if (!ink || roll >= amount) return ink; + return { + color: ink.color, + tone: Math.max(-2, Math.min(2, ink.tone + direction)) as PartTone, + }; + }), + ); +} + +/** Moves the tone of each colored cell on the points by `delta` once; empty cells are skipped. */ +export function shadePartLayer( + slot: PartSlot, + layer: PartLayer, + points: readonly (readonly [number, number])[], + delta: 1 | -1, + mirror = false, +): PartLayer { + const { width, height } = PART_SLOTS[slot]; + const out = layer.map((row) => [...row]); + const done = new Set(); + for (const [x, y] of points) + for (const px of mirror ? [x, mirrorPartX(slot, x)] : [x]) { + const key = `${px},${y}`; + if (done.has(key) || px < 0 || px >= width || y < 0 || y >= height) continue; + done.add(key); + const ink = layer[y]![px]; + if (ink) + out[y]![px] = { + color: ink.color, + tone: Math.max(-2, Math.min(2, ink.tone + delta)) as PartTone, + }; + } + return out; +} + const K = Uint32Array.from( '428a2f98 71374491 b5c0fbcf e9b5dba5 3956c25b 59f111f1 923f82a4 ab1c5ed5 d807aa98 12835b01 243185be 550c7dc3 72be5d74 80deb1fe 9bdc06a7 c19bf174 e49b69c1 efbe4786 0fc19dc6 240ca1cc 2de92c6f 4a7484aa 5cb0a9dc 76f988da 983e5152 a831c66d b00327c8 bf597fc7 c6e00bf3 d5a79147 06ca6351 14292967 27b70a85 2e1b2138 4d2c6dfc 53380d13 650a7354 766a0abb 81c2c92e 92722c85 a2bfe8a1 a81a664b c24b8b70 c76c51a3 d192e819 d6990624 f40e3585 106aa070 19a4c116 1e376c08 2748774c 34b0bcb5 391c0cb3 4ed8aa4a 5b9cca4f 682e6ff3 748f82ee 78a5636f 84c87814 8cc70208 90befffa a4506ceb bef9a3f7 c67178f2' .split(' ') diff --git a/packages/avatar/test/drawing-tools.test.ts b/packages/avatar/test/drawing-tools.test.ts new file mode 100644 index 0000000..f446cdc --- /dev/null +++ b/packages/avatar/test/drawing-tools.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest'; +import { + ditherThreshold, + emptyPartLayer, + gradientPartLayer, + mirrorPartX, + noisePartLayer, + paintPartLayer, + partLayer, + partLinePoints, + partRectPoints, + shadePartLayer, + type PartLayer, +} from '../src/index.js'; + +const tones = (layer: PartLayer, y: number) => layer[y]!.map((ink) => (ink ? ink.tone : null)); + +describe('line and rectangle tools', () => { + it('draws pixel-perfect lines with one cell per step', () => { + expect(partLinePoints([0, 0], [4, 2])).toEqual([ + [0, 0], + [1, 1], + [2, 1], + [3, 2], + [4, 2], + ]); + expect(partLinePoints([3, 3], [3, 3])).toEqual([[3, 3]]); + expect(partLinePoints([5, 1], [1, 1])).toHaveLength(5); + }); + + it('snaps lines to 0°, 45° and 90°', () => { + expect(partLinePoints([2, 2], [9, 3], true).at(-1)).toEqual([9, 2]); + expect(partLinePoints([2, 2], [3, 9], true).at(-1)).toEqual([2, 9]); + expect(partLinePoints([2, 2], [6, 7], true).at(-1)).toEqual([7, 7]); + expect(partLinePoints([6, 6], [1, 2], true).at(-1)).toEqual([1, 1]); + }); + + it('outlines rectangles once per cell, and squares with the modifier', () => { + const rect = partRectPoints([1, 1], [4, 3]); + expect(rect).toHaveLength(10); + expect(new Set(rect.map(String)).size).toBe(rect.length); + expect(rect).not.toContainEqual([2, 2]); + const square = partRectPoints([5, 5], [1, 3], true); + expect(Math.min(...square.map(([x]) => x))).toBe(1); + expect(Math.min(...square.map(([, y]) => y))).toBe(1); + expect(partRectPoints([2, 2], [2, 2])).toEqual([[2, 2]]); + }); +}); + +describe('gradient tool', () => { + it('uses the standard ordered-dither matrices', () => { + const four = Array.from({ length: 16 }, (_, i) => ditherThreshold(i % 4, Math.floor(i / 4))); + expect(new Set(four).size).toBe(16); + expect(four.every((t) => t > 0 && t < 1)).toBe(true); + expect(ditherThreshold(0, 0, 2)).toBe(0.125); + expect(ditherThreshold(5, 7, 4)).toBe(ditherThreshold(1, 3, 4)); + }); + + it('steps through tones between the ends and stays inside the start region', () => { + let layer = emptyPartLayer('headpiece'); + layer = paintPartLayer( + 'headpiece', + layer, + Array.from({ length: 16 }, (_, y) => [10, y] as const), + { color: '#112233', tone: 0 }, + ); + const out = gradientPartLayer('headpiece', layer, [0, 4], [8, 4], 'hairColor', -2, 2); + const row = tones(out, 4).slice(0, 10); + expect(row[0]).toBe(-2); + expect(row[8]).toBe(2); + expect(row[9]).toBe(2); + for (let x = 1; x < row.length; x++) expect(row[x]!).toBeGreaterThanOrEqual(row[x - 1]! - 1); + expect(new Set(row)).toEqual(new Set([-2, -1, 0, 1, 2])); + expect(out[4]![10]).toEqual({ color: '#112233', tone: 0 }); + expect(out[4]![11]).toBeNull(); + expect(out[0]![0]?.color).toBe('hairColor'); + }); + + it('dithers between neighboring tones only', () => { + const out = gradientPartLayer( + 'bangs', + emptyPartLayer('bangs'), + [0, 0], + [20, 0], + 'skinColor', + 0, + 1, + { + dither: 2, + }, + ); + const all = out.flat().map((ink) => ink!.tone); + expect(new Set(all)).toEqual(new Set([0, 1])); + const middle = tones(out, 0).slice(8, 12).concat(tones(out, 1).slice(8, 12)); + expect(new Set(middle)).toEqual(new Set([0, 1])); + }); + + it('mirrors the gradient into the mirrored region', () => { + const layer = paintPartLayer( + 'headpiece', + emptyPartLayer('headpiece'), + Array.from({ length: 16 }, (_, y) => [15, y] as const), + { color: '#000000', tone: 0 }, + true, + ); + const out = gradientPartLayer('headpiece', layer, [0, 0], [14, 0], 'shirtColor', -2, 2, { + mirror: true, + }); + for (let y = 0; y < 16; y++) + for (let x = 0; x < 15; x++) expect(out[y]![mirrorPartX('headpiece', x)]).toEqual(out[y]![x]); + }); +}); + +describe('noise and shade tools', () => { + const solid = partLayer( + 'outfit', + Array.from({ length: 6 }, (_, i) => [8 + i, 2, 'shirtColor', 0] as const), + ); + + it('is deterministic per seed, moves tones by one and leaves empty cells empty', () => { + const a = noisePartLayer('outfit', solid, 0.5, 'seed-a'); + expect(noisePartLayer('outfit', solid, 0.5, 'seed-a')).toEqual(a); + expect(a.flat().filter(Boolean)).toHaveLength(6); + expect(a.flat().every((ink) => !ink || Math.abs(ink.tone) <= 1)).toBe(true); + const seeds = ['a', 'b', 'c', 'd', 'e'].map((seed) => + JSON.stringify(noisePartLayer('outfit', solid, 0.5, seed)), + ); + expect(new Set(seeds).size).toBeGreaterThan(1); + expect(noisePartLayer('outfit', solid, 0, 'x')).toEqual(solid); + expect( + noisePartLayer('outfit', solid, 1, 'x') + .flat() + .filter((ink) => ink?.tone === 0), + ).toEqual([]); + }); + + it('gives a cell and its mirror the same change', () => { + const symmetric = paintPartLayer( + 'outfit', + solid, + Array.from({ length: 6 }, (_, i) => [8 + i, 2] as const), + { color: 'shirtColor', tone: 0 }, + true, + ); + const out = noisePartLayer('outfit', symmetric, 0.6, 'mirror', true); + for (const [x] of Array.from({ length: 6 }, (_, i) => [8 + i])) + expect(out[2]![mirrorPartX('outfit', x!)]).toEqual(out[2]![x!]); + }); + + it('shades each colored cell once per stroke and clamps the tone', () => { + const stroke = [ + [8, 2], + [9, 2], + [8, 2], + [0, 0], + ] as const; + const once = shadePartLayer('outfit', solid, stroke, -1); + expect(tones(once, 2).slice(8, 11)).toEqual([-1, -1, 0]); + expect(once[0]![0]).toBeNull(); + let dark = solid; + for (let i = 0; i < 4; i++) dark = shadePartLayer('outfit', dark, stroke, -1); + expect(dark[2]![8]?.tone).toBe(-2); + const mirrored = shadePartLayer('outfit', solid, [[8, 2]], 1, true); + expect(mirrored[2]![8]?.tone).toBe(1); + expect(mirrored[2]![mirrorPartX('outfit', 8)]).toBeNull(); + }); +}); From 6e1e5a97c15eebc7ba1394caebca18aa8b178fe8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 21:13:42 +0000 Subject: [PATCH 2/2] fix(avatar): symmetric mirrored gradient inside a region shared by both halves Refs: BotHarness/DeepSeekBot#1216 Agent-Task: claude/cloud/session_01MozvQ2eMvqX1MzSqm9G2o8 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MozvQ2eMvqX1MzSqm9G2o8 --- packages/avatar/src/part.ts | 31 +++++++++++++++------- packages/avatar/test/drawing-tools.test.ts | 17 ++++++++++++ 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/packages/avatar/src/part.ts b/packages/avatar/src/part.ts index 3c5cdde..db8fff2 100644 --- a/packages/avatar/src/part.ts +++ b/packages/avatar/src/part.ts @@ -355,17 +355,28 @@ export function gradientPartLayer( out[y]![x] = { color, tone }; } }; - paint(region(fx, fy), from, to, (x) => x); - if (options.mirror) { - const mirrored = (point: readonly [number, number]) => - [mirrorPartX(slot, point[0]), point[1]] as const; - paint( - region(mirrorPartX(slot, fx), fy).filter(([x, y]) => out[y]![x] === layer[y]![x]), - mirrored(from), - mirrored(to), - (x) => mirrorPartX(slot, x), - ); + const own = region(fx, fy); + if (!options.mirror) { + paint(own, from, to, (x) => x); + return out; } + const mx = mirrorPartX(slot, fx); + const mirroredCells = region(mx, fy); + const inOwn = new Set(own.map(([x, y]) => y * width + x)); + const inMirrored = new Set(mirroredCells.map(([x, y]) => y * width + x)); + const startSide = (x: number) => x < width / 2 === fx < width / 2; + paint( + own.filter(([x, y]) => startSide(x) || !inMirrored.has(y * width + x)), + from, + to, + (x) => x, + ); + paint( + mirroredCells.filter(([x, y]) => !startSide(x) || !inOwn.has(y * width + x)), + [mx, fy], + [mirrorPartX(slot, to[0]), to[1]], + (x) => mirrorPartX(slot, x), + ); return out; } diff --git a/packages/avatar/test/drawing-tools.test.ts b/packages/avatar/test/drawing-tools.test.ts index f446cdc..2252833 100644 --- a/packages/avatar/test/drawing-tools.test.ts +++ b/packages/avatar/test/drawing-tools.test.ts @@ -109,6 +109,23 @@ describe('gradient tool', () => { for (let y = 0; y < 16; y++) for (let x = 0; x < 15; x++) expect(out[y]![mirrorPartX('headpiece', x)]).toEqual(out[y]![x]); }); + + it('mirrors symmetrically inside a region shared by both halves', () => { + const out = gradientPartLayer( + 'headpiece', + emptyPartLayer('headpiece'), + [2, 3], + [12, 3], + 'hairColor', + 2, + -2, + { mirror: true }, + ); + expect(out[3]![2]).toEqual({ color: 'hairColor', tone: 2 }); + expect(out[3]![12]).toEqual({ color: 'hairColor', tone: -2 }); + for (let y = 0; y < 16; y++) + for (let x = 0; x < 16; x++) expect(out[y]![mirrorPartX('headpiece', x)]).toEqual(out[y]![x]); + }); }); describe('noise and shade tools', () => {