piximps 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.github/workflows/ci.yml +20 -0
  2. package/.github/workflows/demo.yml +22 -0
  3. package/.github/workflows/release.yml +22 -0
  4. package/README.md +110 -0
  5. package/docs/browser.global.js +1539 -0
  6. package/docs/index.html +442 -0
  7. package/package.json +42 -0
  8. package/src/piximps/browser.ts +114 -0
  9. package/src/piximps/common/templates/accessories.ts +174 -0
  10. package/src/piximps/common/templates/body.ts +390 -0
  11. package/src/piximps/common/templates/eyes.ts +95 -0
  12. package/src/piximps/common/templates/horns.ts +152 -0
  13. package/src/piximps/common/templates/mouth.ts +96 -0
  14. package/src/piximps/common/templates/types.ts +4 -0
  15. package/src/piximps/domain/color-palette.ts +22 -0
  16. package/src/piximps/domain/imp-traits.ts +35 -0
  17. package/src/piximps/domain/template.ts +35 -0
  18. package/src/piximps/domain/types.ts +34 -0
  19. package/src/piximps/entrypoints/renderers/to-png-binary.ts +13 -0
  20. package/src/piximps/entrypoints/renderers/to-rgba-buffer.ts +26 -0
  21. package/src/piximps/entrypoints/renderers/to-svg-string.ts +28 -0
  22. package/src/piximps/index.ts +117 -0
  23. package/src/piximps/services/edge-detector.ts +60 -0
  24. package/src/piximps/services/hash-to-byte-sequence.ts +75 -0
  25. package/src/piximps/services/layer-compositor.ts +157 -0
  26. package/src/piximps/services/palette-deriver.ts +75 -0
  27. package/src/piximps/services/trait-extractor.ts +88 -0
  28. package/tests/functional/test-domain-models.test.ts +136 -0
  29. package/tests/functional/test-edge-detector.test.ts +64 -0
  30. package/tests/functional/test-hash-to-byte-sequence.test.ts +39 -0
  31. package/tests/functional/test-layer-compositor.test.ts +107 -0
  32. package/tests/functional/test-palette-deriver.test.ts +43 -0
  33. package/tests/functional/test-renderers.test.ts +105 -0
  34. package/tests/functional/test-trait-extractor.test.ts +50 -0
  35. package/tests/integration/test-determinism.test.ts +40 -0
  36. package/tests/integration/test-generator-builder.test.ts +56 -0
  37. package/tests/integration/test-output-formats.test.ts +47 -0
  38. package/tsconfig.json +24 -0
  39. package/tsup.config.ts +11 -0
  40. package/vitest.config.ts +18 -0
@@ -0,0 +1,152 @@
1
+ import { CellType as C } from '@piximps/domain/types'
2
+ import { Template } from '@piximps/domain/template'
3
+ import { type TemplateRegistry } from './types'
4
+
5
+ const _ = C.AlwaysEmpty
6
+ const F = C.AlwaysFilled
7
+
8
+ const wideHorns8 = new Template({
9
+ grid: [
10
+ [F, _, _, _],
11
+ [_, F, _, _],
12
+ ],
13
+ anchors: {},
14
+ compatibleWith: ['wide-horns'],
15
+ symmetric: true,
16
+ })
17
+
18
+ const smallHorns8 = new Template({
19
+ grid: [
20
+ [_, _, F, _],
21
+ ],
22
+ anchors: {},
23
+ compatibleWith: ['small-horns'],
24
+ symmetric: true,
25
+ })
26
+
27
+ const tallHorns8 = new Template({
28
+ grid: [
29
+ [_, F, _, _],
30
+ [_, F, _, _],
31
+ [_, _, _, _],
32
+ ],
33
+ anchors: {},
34
+ compatibleWith: ['tall-horns'],
35
+ symmetric: true,
36
+ })
37
+
38
+ const curvedHorns16 = new Template({
39
+ grid: [
40
+ [F, _, _, _],
41
+ [_, F, _, _],
42
+ [_, _, F, _],
43
+ ],
44
+ anchors: {},
45
+ compatibleWith: ['wide-horns'],
46
+ symmetric: true,
47
+ })
48
+
49
+ const straightHorns16 = new Template({
50
+ grid: [
51
+ [_, F, _],
52
+ [_, F, _],
53
+ [_, F, _],
54
+ ],
55
+ anchors: {},
56
+ compatibleWith: ['tall-horns'],
57
+ symmetric: true,
58
+ })
59
+
60
+ const branchingHorns16 = new Template({
61
+ grid: [
62
+ [F, _, _, _, _],
63
+ [_, F, _, _, _],
64
+ [_, F, F, _, _],
65
+ [_, _, _, F, _],
66
+ ],
67
+ anchors: {},
68
+ compatibleWith: ['wide-horns'],
69
+ symmetric: true,
70
+ })
71
+
72
+ const stubbyHorns16 = new Template({
73
+ grid: [
74
+ [F, _, _],
75
+ [_, F, _],
76
+ ],
77
+ anchors: {},
78
+ compatibleWith: ['small-horns'],
79
+ symmetric: true,
80
+ })
81
+
82
+ const longCurved32 = new Template({
83
+ grid: [
84
+ [F, _, _, _, _, _, _, _],
85
+ [_, F, _, _, _, _, _, _],
86
+ [_, _, F, _, _, _, _, _],
87
+ [_, _, _, F, F, _, _, _],
88
+ [_, _, _, _, _, F, _, _],
89
+ [_, _, _, _, _, _, F, _],
90
+ ],
91
+ anchors: {},
92
+ compatibleWith: ['wide-horns'],
93
+ symmetric: true,
94
+ })
95
+
96
+ const shortStubby32 = new Template({
97
+ grid: [
98
+ [_, _, F, _, _],
99
+ [_, _, _, F, _],
100
+ [_, _, _, _, F],
101
+ ],
102
+ anchors: {},
103
+ compatibleWith: ['small-horns'],
104
+ symmetric: true,
105
+ })
106
+
107
+ const branching32 = new Template({
108
+ grid: [
109
+ [F, _, _, _, _, _, _, _],
110
+ [_, F, _, _, _, _, _, _],
111
+ [_, _, F, F, _, _, _, _],
112
+ [_, _, F, _, F, _, _, _],
113
+ [_, _, _, _, _, F, _, _],
114
+ [_, _, _, _, _, _, F, _],
115
+ ],
116
+ anchors: {},
117
+ compatibleWith: ['wide-horns'],
118
+ symmetric: true,
119
+ })
120
+
121
+ const ramStyle32 = new Template({
122
+ grid: [
123
+ [_, F, F, _, _, _],
124
+ [F, _, _, F, _, _],
125
+ [F, _, _, _, F, _],
126
+ [_, F, _, _, F, _],
127
+ [_, _, F, F, _, _],
128
+ ],
129
+ anchors: {},
130
+ compatibleWith: ['wide-horns'],
131
+ symmetric: true,
132
+ })
133
+
134
+ const singleSpike32 = new Template({
135
+ grid: [
136
+ [_, F, _],
137
+ [_, F, _],
138
+ [_, F, _],
139
+ [_, F, _],
140
+ [_, F, _],
141
+ [_, F, _],
142
+ ],
143
+ anchors: {},
144
+ compatibleWith: ['tall-horns'],
145
+ symmetric: true,
146
+ })
147
+
148
+ export const hornTemplates: TemplateRegistry = {
149
+ 8: [wideHorns8, smallHorns8, tallHorns8],
150
+ 16: [curvedHorns16, straightHorns16, branchingHorns16, stubbyHorns16],
151
+ 32: [longCurved32, shortStubby32, branching32, ramStyle32, singleSpike32],
152
+ }
@@ -0,0 +1,96 @@
1
+ import { CellType as C } from '@piximps/domain/types'
2
+ import { Template } from '@piximps/domain/template'
3
+ import { type TemplateRegistry } from './types'
4
+
5
+ const _ = C.AlwaysEmpty
6
+ const F = C.AlwaysFilled
7
+
8
+ const fangs8 = new Template({
9
+ grid: [[_, F, _, F]],
10
+ anchors: {},
11
+ compatibleWith: ['fangs'],
12
+ symmetric: true,
13
+ })
14
+
15
+ const grin8 = new Template({
16
+ grid: [[_, F, F, F]],
17
+ anchors: {},
18
+ compatibleWith: ['grin'],
19
+ symmetric: true,
20
+ })
21
+
22
+ const smirk8 = new Template({
23
+ grid: [[_, _, F, F]],
24
+ anchors: {},
25
+ compatibleWith: ['smirk'],
26
+ symmetric: false,
27
+ })
28
+
29
+ const fangs16 = new Template({
30
+ grid: [
31
+ [_, F, _, F],
32
+ [F, _, F, _],
33
+ ],
34
+ anchors: {},
35
+ compatibleWith: ['fangs'],
36
+ symmetric: true,
37
+ })
38
+
39
+ const grin16 = new Template({
40
+ grid: [
41
+ [_, F, F, F],
42
+ [_, _, F, F],
43
+ ],
44
+ anchors: {},
45
+ compatibleWith: ['grin'],
46
+ symmetric: true,
47
+ })
48
+
49
+ const smirk16 = new Template({
50
+ grid: [
51
+ [_, _, F, F],
52
+ [_, F, F, _],
53
+ ],
54
+ anchors: {},
55
+ compatibleWith: ['smirk'],
56
+ symmetric: false,
57
+ })
58
+
59
+ const multiFangs32 = new Template({
60
+ grid: [
61
+ [_, _, F, _, F, _, F, _],
62
+ [_, F, _, F, _, F, _, _],
63
+ [F, _, _, _, _, _, _, _],
64
+ ],
65
+ anchors: {},
66
+ compatibleWith: ['fangs'],
67
+ symmetric: true,
68
+ })
69
+
70
+ const wideGrin32 = new Template({
71
+ grid: [
72
+ [_, _, F, F, F, F, F, F],
73
+ [_, _, _, F, F, F, F, F],
74
+ [_, _, _, _, F, F, F, F],
75
+ ],
76
+ anchors: {},
77
+ compatibleWith: ['grin'],
78
+ symmetric: true,
79
+ })
80
+
81
+ const asymSmirk32 = new Template({
82
+ grid: [
83
+ [_, _, _, _, F, F, F, F],
84
+ [_, _, _, F, F, F, _, _],
85
+ [_, _, F, F, _, _, _, _],
86
+ ],
87
+ anchors: {},
88
+ compatibleWith: ['smirk'],
89
+ symmetric: false,
90
+ })
91
+
92
+ export const mouthTemplates: TemplateRegistry = {
93
+ 8: [fangs8, grin8, smirk8],
94
+ 16: [fangs16, grin16, smirk16],
95
+ 32: [multiFangs32, wideGrin32, asymSmirk32],
96
+ }
@@ -0,0 +1,4 @@
1
+ import { type GridSize } from '@piximps/domain/types'
2
+ import { Template } from '@piximps/domain/template'
3
+
4
+ export type TemplateRegistry = Record<GridSize, Template[]>
@@ -0,0 +1,22 @@
1
+ import { type RgbaColor } from './types'
2
+
3
+ export interface ColorPaletteConfig {
4
+ skin: RgbaColor
5
+ accent: RgbaColor
6
+ glow: RgbaColor
7
+ secondary: RgbaColor
8
+ }
9
+
10
+ export class ColorPalette {
11
+ readonly skin: RgbaColor
12
+ readonly accent: RgbaColor
13
+ readonly glow: RgbaColor
14
+ readonly secondary: RgbaColor
15
+
16
+ constructor(config: ColorPaletteConfig) {
17
+ this.skin = config.skin
18
+ this.accent = config.accent
19
+ this.glow = config.glow
20
+ this.secondary = config.secondary
21
+ }
22
+ }
@@ -0,0 +1,35 @@
1
+ export type AccessoryType = 'tail' | 'wings' | 'weapon' | 'hat'
2
+
3
+ export type AccessoryIndices = Record<AccessoryType, number | null>
4
+
5
+ export type SymmetryBreakSide = 'left' | 'right'
6
+
7
+ export interface ImpTraitsConfig {
8
+ bodyIndex: number
9
+ hornsIndex: number
10
+ eyesIndex: number
11
+ mouthIndex: number
12
+ accessoryIndices: AccessoryIndices
13
+ probabilisticBits: boolean[]
14
+ symmetryBreakSide: SymmetryBreakSide
15
+ }
16
+
17
+ export class ImpTraits {
18
+ readonly bodyIndex: number
19
+ readonly hornsIndex: number
20
+ readonly eyesIndex: number
21
+ readonly mouthIndex: number
22
+ readonly accessoryIndices: AccessoryIndices
23
+ readonly probabilisticBits: boolean[]
24
+ readonly symmetryBreakSide: SymmetryBreakSide
25
+
26
+ constructor(config: ImpTraitsConfig) {
27
+ this.bodyIndex = config.bodyIndex
28
+ this.hornsIndex = config.hornsIndex
29
+ this.eyesIndex = config.eyesIndex
30
+ this.mouthIndex = config.mouthIndex
31
+ this.accessoryIndices = config.accessoryIndices
32
+ this.probabilisticBits = config.probabilisticBits
33
+ this.symmetryBreakSide = config.symmetryBreakSide
34
+ }
35
+ }
@@ -0,0 +1,35 @@
1
+ import { type TemplateGrid, type AnchorPoint } from './types'
2
+
3
+ export interface TemplateConfig {
4
+ grid: TemplateGrid
5
+ anchors: Record<string, AnchorPoint>
6
+ compatibleWith: string[]
7
+ symmetric: boolean
8
+ }
9
+
10
+ export class Template {
11
+ readonly grid: TemplateGrid
12
+ readonly anchors: Record<string, AnchorPoint>
13
+ readonly compatibleWith: string[]
14
+ readonly symmetric: boolean
15
+
16
+ constructor(config: TemplateConfig) {
17
+ this.grid = config.grid
18
+ this.anchors = config.anchors
19
+ this.compatibleWith = config.compatibleWith
20
+ this.symmetric = config.symmetric
21
+ }
22
+
23
+ get width(): number {
24
+ return this.grid[0]?.length ?? 0
25
+ }
26
+
27
+ get height(): number {
28
+ return this.grid.length
29
+ }
30
+
31
+ get mirroredWidth(): number {
32
+ if (!this.symmetric) return this.width
33
+ return this.width * 2 - 1
34
+ }
35
+ }
@@ -0,0 +1,34 @@
1
+ export enum CellType {
2
+ AlwaysEmpty = -1,
3
+ Probabilistic = 0,
4
+ AlwaysFilled = 1,
5
+ InnerBody = 2,
6
+ }
7
+
8
+ export type GridSize = 8 | 16 | 32
9
+
10
+ export type OutputFormat = 'buffer' | 'png' | 'svg'
11
+
12
+ export type RgbaColor = [r: number, g: number, b: number, a: number]
13
+
14
+ export type AnchorPoint = {
15
+ row: number
16
+ columnStart: number
17
+ columnEnd: number
18
+ }
19
+
20
+ export type TemplateGrid = CellType[][]
21
+
22
+ export enum RenderedCellType {
23
+ Empty = 0,
24
+ Body = 1,
25
+ InnerBody = 2,
26
+ Edge = 3,
27
+ }
28
+
29
+ export type RenderedGrid = {
30
+ cells: RenderedCellType[][]
31
+ colors: (RgbaColor | null)[][]
32
+ width: number
33
+ height: number
34
+ }
@@ -0,0 +1,13 @@
1
+ import { PNG } from 'pngjs'
2
+ import { type RenderedGrid } from '@piximps/domain/types'
3
+ import { toRgbaBuffer } from './to-rgba-buffer'
4
+
5
+ export function toPngBinary(grid: RenderedGrid, outputSize: number): Uint8Array {
6
+ const rgbaData = toRgbaBuffer(grid, outputSize)
7
+
8
+ const png = new PNG({ width: outputSize, height: outputSize })
9
+ png.data = Buffer.from(rgbaData)
10
+
11
+ const pngBuffer = PNG.sync.write(png)
12
+ return new Uint8Array(pngBuffer)
13
+ }
@@ -0,0 +1,26 @@
1
+ import { RenderedCellType, type RenderedGrid } from '@piximps/domain/types'
2
+
3
+ export function toRgbaBuffer(grid: RenderedGrid, outputSize: number): Uint8Array {
4
+ const buffer = new Uint8Array(outputSize * outputSize * 4)
5
+ const scale = outputSize / grid.width
6
+
7
+ for (let y = 0; y < outputSize; y++) {
8
+ for (let x = 0; x < outputSize; x++) {
9
+ const gridRow = Math.floor(y / scale)
10
+ const gridCol = Math.floor(x / scale)
11
+ const pixelOffset = (y * outputSize + x) * 4
12
+
13
+ const cell = grid.cells[gridRow]?.[gridCol]
14
+ const color = grid.colors[gridRow]?.[gridCol]
15
+
16
+ if (cell !== undefined && cell !== RenderedCellType.Empty && color) {
17
+ buffer[pixelOffset] = color[0]
18
+ buffer[pixelOffset + 1] = color[1]
19
+ buffer[pixelOffset + 2] = color[2]
20
+ buffer[pixelOffset + 3] = color[3]
21
+ }
22
+ }
23
+ }
24
+
25
+ return buffer
26
+ }
@@ -0,0 +1,28 @@
1
+ import { RenderedCellType, type RenderedGrid } from '@piximps/domain/types'
2
+
3
+ export function toSvgString(grid: RenderedGrid, outputSize: number): string {
4
+ const pixelSize = outputSize / grid.width
5
+ const rects: string[] = []
6
+
7
+ for (let r = 0; r < grid.height; r++) {
8
+ for (let c = 0; c < grid.width; c++) {
9
+ const cell = grid.cells[r][c]
10
+ const color = grid.colors[r][c]
11
+
12
+ if (cell === RenderedCellType.Empty || !color) continue
13
+
14
+ const x = c * pixelSize
15
+ const y = r * pixelSize
16
+
17
+ rects.push(
18
+ `<rect x="${x}" y="${y}" width="${pixelSize}" height="${pixelSize}" fill="rgb(${color[0]},${color[1]},${color[2]})" opacity="${color[3] / 255}"/>`,
19
+ )
20
+ }
21
+ }
22
+
23
+ return [
24
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${outputSize}" height="${outputSize}" viewBox="0 0 ${outputSize} ${outputSize}">`,
25
+ ...rects,
26
+ '</svg>',
27
+ ].join('\n')
28
+ }
@@ -0,0 +1,117 @@
1
+ import { type GridSize, type OutputFormat } from './domain/types'
2
+ import { hashToByteSequence } from './services/hash-to-byte-sequence'
3
+ import { derivePalette } from './services/palette-deriver'
4
+ import { extractTraits } from './services/trait-extractor'
5
+ import { composeLayers } from './services/layer-compositor'
6
+ import { detectEdges } from './services/edge-detector'
7
+ import { toRgbaBuffer } from './entrypoints/renderers/to-rgba-buffer'
8
+ import { toPngBinary } from './entrypoints/renderers/to-png-binary'
9
+ import { toSvgString } from './entrypoints/renderers/to-svg-string'
10
+ import { bodyTemplates } from './common/templates/body'
11
+ import { hornTemplates } from './common/templates/horns'
12
+ import { eyeTemplates } from './common/templates/eyes'
13
+ import { mouthTemplates } from './common/templates/mouth'
14
+ import { accessoryTemplates } from './common/templates/accessories'
15
+ import { type AccessoryType } from './domain/imp-traits'
16
+
17
+ export interface ImpGeneratorOptions {
18
+ size: number
19
+ grid: GridSize
20
+ format: OutputFormat
21
+ }
22
+
23
+ export class ImpGenerator {
24
+ private readonly options: ImpGeneratorOptions
25
+
26
+ private static readonly VALID_GRID_SIZES: GridSize[] = [8, 16, 32]
27
+ private static readonly MAX_OUTPUT_SIZE = 4096
28
+
29
+ constructor(options?: Partial<ImpGeneratorOptions>) {
30
+ const grid = options?.grid ?? 16
31
+ const size = options?.size ?? 128
32
+
33
+ if (!ImpGenerator.VALID_GRID_SIZES.includes(grid)) {
34
+ throw new RangeError(`Grid size must be one of: ${ImpGenerator.VALID_GRID_SIZES.join(', ')}`)
35
+ }
36
+ if (size <= 0 || size > ImpGenerator.MAX_OUTPUT_SIZE) {
37
+ throw new RangeError(`Output size must be between 1 and ${ImpGenerator.MAX_OUTPUT_SIZE}`)
38
+ }
39
+
40
+ this.options = {
41
+ size,
42
+ grid,
43
+ format: options?.format ?? 'png',
44
+ }
45
+ }
46
+
47
+ getOptions(): Readonly<ImpGeneratorOptions> {
48
+ return { ...this.options }
49
+ }
50
+
51
+ size(value: number): ImpGenerator {
52
+ return new ImpGenerator({ ...this.options, size: value })
53
+ }
54
+
55
+ grid(value: GridSize): ImpGenerator {
56
+ return new ImpGenerator({ ...this.options, grid: value })
57
+ }
58
+
59
+ format(value: OutputFormat): ImpGenerator {
60
+ return new ImpGenerator({ ...this.options, format: value })
61
+ }
62
+
63
+ async generate(input?: string): Promise<Uint8Array | string> {
64
+ const bytes = input !== undefined
65
+ ? hashToByteSequence(input)
66
+ : crypto.getRandomValues(new Uint8Array(32))
67
+
68
+ const palette = derivePalette(bytes)
69
+ const traits = extractTraits(bytes, this.options.grid)
70
+
71
+ const body = bodyTemplates[this.options.grid][traits.bodyIndex]
72
+ if (!body) {
73
+ throw new Error(`No body template at index ${traits.bodyIndex} for grid size ${this.options.grid}`)
74
+ }
75
+
76
+ const horns = hornTemplates[this.options.grid][traits.hornsIndex] ?? null
77
+ const eyes = eyeTemplates[this.options.grid][traits.eyesIndex] ?? null
78
+ const mouth = mouthTemplates[this.options.grid][traits.mouthIndex] ?? null
79
+
80
+ const accessories: typeof body[] = []
81
+ const accessoryTypes: AccessoryType[] = ['tail', 'wings', 'weapon', 'hat']
82
+ for (const accType of accessoryTypes) {
83
+ const idx = traits.accessoryIndices[accType]
84
+ if (idx !== null) {
85
+ const template = accessoryTemplates[accType]?.[this.options.grid]?.[idx]
86
+ if (template) accessories.push(template)
87
+ }
88
+ }
89
+
90
+ const composited = composeLayers({
91
+ gridSize: this.options.grid,
92
+ body,
93
+ horns,
94
+ eyes,
95
+ mouth,
96
+ accessories,
97
+ palette,
98
+ probabilisticBits: traits.probabilisticBits,
99
+ symmetryBreakSide: traits.symmetryBreakSide,
100
+ })
101
+
102
+ const edgeDetected = detectEdges(composited)
103
+
104
+ switch (this.options.format) {
105
+ case 'buffer':
106
+ return toRgbaBuffer(edgeDetected, this.options.size)
107
+ case 'png':
108
+ return toPngBinary(edgeDetected, this.options.size)
109
+ case 'svg':
110
+ return toSvgString(edgeDetected, this.options.size)
111
+ }
112
+ }
113
+ }
114
+
115
+ export { type GridSize, type OutputFormat } from './domain/types'
116
+ export { type ImpTraits } from './domain/imp-traits'
117
+ export { type ColorPalette } from './domain/color-palette'
@@ -0,0 +1,60 @@
1
+ import { RenderedCellType, type RenderedGrid, type RgbaColor } from '@piximps/domain/types'
2
+
3
+ const DARKEN_FACTOR = 0.4
4
+
5
+ function darkenColor(color: RgbaColor): RgbaColor {
6
+ const multiplier = 1 - DARKEN_FACTOR
7
+ return [
8
+ Math.round(color[0] * multiplier),
9
+ Math.round(color[1] * multiplier),
10
+ Math.round(color[2] * multiplier),
11
+ color[3],
12
+ ]
13
+ }
14
+
15
+ function isFilled(cellType: RenderedCellType): boolean {
16
+ return cellType !== RenderedCellType.Empty
17
+ }
18
+
19
+ function hasEmptyNeighbor(
20
+ cells: RenderedCellType[][],
21
+ row: number,
22
+ col: number,
23
+ height: number,
24
+ width: number,
25
+ ): boolean {
26
+ if (row === 0 || row === height - 1 || col === 0 || col === width - 1) {
27
+ return true
28
+ }
29
+
30
+ const neighbors = [
31
+ cells[row - 1][col],
32
+ cells[row + 1][col],
33
+ cells[row][col - 1],
34
+ cells[row][col + 1],
35
+ ]
36
+
37
+ return neighbors.some(n => !isFilled(n))
38
+ }
39
+
40
+ export function detectEdges(grid: RenderedGrid): RenderedGrid {
41
+ const { cells, colors, width, height } = grid
42
+
43
+ const newCells: RenderedCellType[][] = cells.map(row => [...row])
44
+ const newColors: (RgbaColor | null)[][] = colors.map(row => [...row])
45
+
46
+ for (let r = 0; r < height; r++) {
47
+ for (let c = 0; c < width; c++) {
48
+ if (!isFilled(cells[r][c])) continue
49
+
50
+ if (hasEmptyNeighbor(cells, r, c, height, width)) {
51
+ newCells[r][c] = RenderedCellType.Edge
52
+ if (colors[r][c]) {
53
+ newColors[r][c] = darkenColor(colors[r][c]!)
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ return { cells: newCells, colors: newColors, width, height }
60
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * MurmurHash3 (32-bit) implementation.
3
+ * Produces a deterministic 32-bit hash from a string.
4
+ * We run it with multiple seeds to generate enough bytes for trait extraction.
5
+ */
6
+ function murmurhash3_32(key: string, seed: number): number {
7
+ let h1 = seed >>> 0
8
+ const remainder = key.length & 3
9
+ const bytes = key.length - remainder
10
+ const c1 = 0xcc9e2d51
11
+ const c2 = 0x1b873593
12
+
13
+ let i = 0
14
+ while (i < bytes) {
15
+ let k1 =
16
+ (key.charCodeAt(i) & 0xff) |
17
+ ((key.charCodeAt(i + 1) & 0xff) << 8) |
18
+ ((key.charCodeAt(i + 2) & 0xff) << 16) |
19
+ ((key.charCodeAt(i + 3) & 0xff) << 24)
20
+ i += 4
21
+
22
+ k1 = Math.imul(k1, c1)
23
+ k1 = (k1 << 15) | (k1 >>> 17)
24
+ k1 = Math.imul(k1, c2)
25
+
26
+ h1 ^= k1
27
+ h1 = (h1 << 13) | (h1 >>> 19)
28
+ h1 = Math.imul(h1, 5) + 0xe6546b64
29
+ }
30
+
31
+ let k1 = 0
32
+ switch (remainder) {
33
+ case 3:
34
+ k1 ^= (key.charCodeAt(i + 2) & 0xff) << 16
35
+ // falls through
36
+ case 2:
37
+ k1 ^= (key.charCodeAt(i + 1) & 0xff) << 8
38
+ // falls through
39
+ case 1:
40
+ k1 ^= key.charCodeAt(i) & 0xff
41
+ k1 = Math.imul(k1, c1)
42
+ k1 = (k1 << 15) | (k1 >>> 17)
43
+ k1 = Math.imul(k1, c2)
44
+ h1 ^= k1
45
+ }
46
+
47
+ h1 ^= key.length
48
+ h1 ^= h1 >>> 16
49
+ h1 = Math.imul(h1, 0x85ebca6b)
50
+ h1 ^= h1 >>> 13
51
+ h1 = Math.imul(h1, 0xc2b2ae35)
52
+ h1 ^= h1 >>> 16
53
+
54
+ return h1 >>> 0
55
+ }
56
+
57
+ /**
58
+ * Converts a string input to a deterministic byte sequence
59
+ * by running MurmurHash3 with 8 different seeds, producing 32 bytes.
60
+ */
61
+ export function hashToByteSequence(input: string): Uint8Array {
62
+ const seedCount = 8
63
+ const bytes = new Uint8Array(seedCount * 4)
64
+
65
+ for (let seed = 0; seed < seedCount; seed++) {
66
+ const hash = murmurhash3_32(input, seed)
67
+ const offset = seed * 4
68
+ bytes[offset] = hash & 0xff
69
+ bytes[offset + 1] = (hash >> 8) & 0xff
70
+ bytes[offset + 2] = (hash >> 16) & 0xff
71
+ bytes[offset + 3] = (hash >> 24) & 0xff
72
+ }
73
+
74
+ return bytes
75
+ }