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.
- package/.github/workflows/ci.yml +20 -0
- package/.github/workflows/demo.yml +22 -0
- package/.github/workflows/release.yml +22 -0
- package/README.md +110 -0
- package/docs/browser.global.js +1539 -0
- package/docs/index.html +442 -0
- package/package.json +42 -0
- package/src/piximps/browser.ts +114 -0
- package/src/piximps/common/templates/accessories.ts +174 -0
- package/src/piximps/common/templates/body.ts +390 -0
- package/src/piximps/common/templates/eyes.ts +95 -0
- package/src/piximps/common/templates/horns.ts +152 -0
- package/src/piximps/common/templates/mouth.ts +96 -0
- package/src/piximps/common/templates/types.ts +4 -0
- package/src/piximps/domain/color-palette.ts +22 -0
- package/src/piximps/domain/imp-traits.ts +35 -0
- package/src/piximps/domain/template.ts +35 -0
- package/src/piximps/domain/types.ts +34 -0
- package/src/piximps/entrypoints/renderers/to-png-binary.ts +13 -0
- package/src/piximps/entrypoints/renderers/to-rgba-buffer.ts +26 -0
- package/src/piximps/entrypoints/renderers/to-svg-string.ts +28 -0
- package/src/piximps/index.ts +117 -0
- package/src/piximps/services/edge-detector.ts +60 -0
- package/src/piximps/services/hash-to-byte-sequence.ts +75 -0
- package/src/piximps/services/layer-compositor.ts +157 -0
- package/src/piximps/services/palette-deriver.ts +75 -0
- package/src/piximps/services/trait-extractor.ts +88 -0
- package/tests/functional/test-domain-models.test.ts +136 -0
- package/tests/functional/test-edge-detector.test.ts +64 -0
- package/tests/functional/test-hash-to-byte-sequence.test.ts +39 -0
- package/tests/functional/test-layer-compositor.test.ts +107 -0
- package/tests/functional/test-palette-deriver.test.ts +43 -0
- package/tests/functional/test-renderers.test.ts +105 -0
- package/tests/functional/test-trait-extractor.test.ts +50 -0
- package/tests/integration/test-determinism.test.ts +40 -0
- package/tests/integration/test-generator-builder.test.ts +56 -0
- package/tests/integration/test-output-formats.test.ts +47 -0
- package/tsconfig.json +24 -0
- package/tsup.config.ts +11 -0
- 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,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
|
+
}
|