@botharness/pixel-avatar 0.5.0 → 0.6.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/dist/index.js CHANGED
@@ -1,191 +1,5 @@
1
1
  import { escapeAttribute } from "@botharness/pixel-morph";
2
2
 
3
- //#region src/part.ts
4
- /**
5
- * Custom Part slots. A headpiece covers the top of the tile, around the hair: its back layer is
6
- * drawn behind the hair and its front layer over it. Cells are in tile coordinates, so the
7
- * Avatar centerline sits between x = 15 and x = 16.
8
- */
9
- const PART_SLOTS = { headpiece: {
10
- width: 32,
11
- height: 16
12
- } };
13
- /** Tone steps on the rig's shade ramp: two darker, the color itself, two lighter. */
14
- const PART_TONES = [
15
- -2,
16
- -1,
17
- 0,
18
- 1,
19
- 2
20
- ];
21
- const PART_LAYERS = ["front", "back"];
22
- /** Most fixed colors one part may use. */
23
- const MAX_PART_FIXED_COLORS = 16;
24
- const FIXED = /^#[\da-f]{6}$/iu;
25
- const isColor = (value) => typeof value === "string" && (AVATAR_COLORS.includes(value) || FIXED.test(value));
26
- function isPixelCustomPart(value) {
27
- if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
28
- const r = value;
29
- if (Object.keys(r).length !== 3 || !Object.hasOwn(PART_SLOTS, r["slot"])) return false;
30
- const { width, height } = PART_SLOTS[r["slot"]];
31
- const fixed = /* @__PURE__ */ new Set();
32
- for (const name of PART_LAYERS) {
33
- const cells = r[name];
34
- if (!Array.isArray(cells) || cells.length > width * height) return false;
35
- const seen = /* @__PURE__ */ new Set();
36
- for (const cell of cells) {
37
- if (!Array.isArray(cell) || cell.length !== 4) return false;
38
- const [x, y, color, tone] = cell;
39
- if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || x >= width || y < 0 || y >= height || !isColor(color) || !PART_TONES.includes(tone)) return false;
40
- const key = y * width + x;
41
- if (seen.has(key)) return false;
42
- seen.add(key);
43
- if (color.startsWith("#")) fixed.add(color.toLowerCase());
44
- }
45
- }
46
- return fixed.size <= MAX_PART_FIXED_COLORS;
47
- }
48
- const canonicalCells = (cells) => cells.map(([x, y, color, tone]) => [
49
- x,
50
- y,
51
- color.startsWith("#") ? color.toLowerCase() : color,
52
- tone
53
- ]).sort((a, b) => a[1] - b[1] || a[0] - b[0]);
54
- /** The part with cells in row order and fixed colors in lowercase. */
55
- function canonicalCustomPart(part) {
56
- return {
57
- slot: part.slot,
58
- front: canonicalCells(part.front),
59
- back: canonicalCells(part.back)
60
- };
61
- }
62
- /** Content identity: a SHA-256 of the canonical slot, cells and color references. */
63
- function customPartId(part) {
64
- return sha256(JSON.stringify(canonicalCustomPart(part)));
65
- }
66
- function emptyPartLayer(slot) {
67
- const { width, height } = PART_SLOTS[slot];
68
- return Array.from({ length: height }, () => Array(width).fill(null));
69
- }
70
- function partLayer(slot, cells) {
71
- const layer = emptyPartLayer(slot);
72
- for (const [x, y, color, tone] of cells) layer[y][x] = {
73
- color,
74
- tone
75
- };
76
- return layer;
77
- }
78
- function partCells(layer) {
79
- return layer.flatMap((row, y) => row.flatMap((ink, x) => ink ? [[
80
- x,
81
- y,
82
- ink.color,
83
- ink.tone
84
- ]] : []));
85
- }
86
- /** Builds a canonical part from two drawn layers. */
87
- function createCustomPart(slot, layers) {
88
- return canonicalCustomPart({
89
- slot,
90
- front: partCells(layers.front),
91
- back: partCells(layers.back)
92
- });
93
- }
94
- /** The column mirrored across the Avatar centerline. */
95
- function mirrorPartX(slot, x) {
96
- return PART_SLOTS[slot].width - 1 - x;
97
- }
98
- const same = (a, b) => a === b || a !== null && b !== null && a.color === b.color && a.tone === b.tone;
99
- /** Sets cells (pencil with ink, eraser with `null`), optionally mirrored across the centerline. */
100
- function paintPartLayer(slot, layer, points, ink, mirror = false) {
101
- const out = layer.map((row) => [...row]);
102
- const { width, height } = PART_SLOTS[slot];
103
- for (const [x, y] of points) for (const px of mirror ? [x, mirrorPartX(slot, x)] : [x]) if (px >= 0 && px < width && y >= 0 && y < height) out[y][px] = ink;
104
- return out;
105
- }
106
- /**
107
- * 4-connected flood fill from a cell: every cell reachable through cells equal to the start
108
- * becomes `ink`. With `mirror`, the mirrored start is filled too.
109
- */
110
- function fillPartLayer(slot, layer, x, y, ink, mirror = false) {
111
- const { width, height } = PART_SLOTS[slot];
112
- let out = layer.map((row) => [...row]);
113
- for (const sx of mirror ? [x, mirrorPartX(slot, x)] : [x]) {
114
- if (sx < 0 || sx >= width || y < 0 || y >= height) continue;
115
- const target = out[y][sx];
116
- if (same(target, ink)) continue;
117
- const next = out.map((row) => [...row]);
118
- const stack = [[sx, y]];
119
- while (stack.length) {
120
- const [cx, cy] = stack.pop();
121
- if (cx < 0 || cx >= width || cy < 0 || cy >= height) continue;
122
- if (!same(next[cy][cx], target)) continue;
123
- next[cy][cx] = ink;
124
- stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]);
125
- }
126
- out = next;
127
- }
128
- return out;
129
- }
130
- 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(" ").map((h) => Number.parseInt(h, 16)));
131
- function sha256(text) {
132
- const bytes = new TextEncoder().encode(text);
133
- const length = Math.ceil((bytes.length + 9) / 64) * 64;
134
- const data = new Uint8Array(length);
135
- data.set(bytes);
136
- data[bytes.length] = 128;
137
- const view = new DataView(data.buffer);
138
- view.setUint32(length - 8, Math.floor(bytes.length * 8 / 2 ** 32));
139
- view.setUint32(length - 4, bytes.length * 8 >>> 0);
140
- const h = Uint32Array.from([
141
- 1779033703,
142
- 3144134277,
143
- 1013904242,
144
- 2773480762,
145
- 1359893119,
146
- 2600822924,
147
- 528734635,
148
- 1541459225
149
- ]);
150
- const w = new Uint32Array(64);
151
- const rotr = (v, n) => v >>> n | v << 32 - n;
152
- for (let offset = 0; offset < length; offset += 64) {
153
- for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4);
154
- for (let i = 16; i < 64; i++) {
155
- const a = w[i - 15];
156
- const b = w[i - 2];
157
- const s0 = rotr(a, 7) ^ rotr(a, 18) ^ a >>> 3;
158
- const s1 = rotr(b, 17) ^ rotr(b, 19) ^ b >>> 10;
159
- w[i] = w[i - 16] + s0 + w[i - 7] + s1 >>> 0;
160
- }
161
- let [a, b, c, d, e, f, g, k] = h;
162
- for (let i = 0; i < 64; i++) {
163
- const t1 = k + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + (e & f ^ ~e & g) + K[i] + w[i] >>> 0;
164
- const t2 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + (a & b ^ a & c ^ b & c) >>> 0;
165
- k = g;
166
- g = f;
167
- f = e;
168
- e = d + t1 >>> 0;
169
- d = c;
170
- c = b;
171
- b = a;
172
- a = t1 + t2 >>> 0;
173
- }
174
- [
175
- a,
176
- b,
177
- c,
178
- d,
179
- e,
180
- f,
181
- g,
182
- k
183
- ].forEach((v, i) => h[i] = h[i] + v >>> 0);
184
- }
185
- return [...h].map((v) => v.toString(16).padStart(8, "0")).join("");
186
- }
187
-
188
- //#endregion
189
3
  //#region src/random.ts
190
4
  function seededRandom(seed) {
191
5
  let h = 2166136261;
@@ -551,6 +365,15 @@ const AVATAR_EXTRA_PARTS = {
551
365
  ],
552
366
  flowerBase: ["leaves", "pot"]
553
367
  };
368
+ /** The recipe key that embeds the Custom Part worn in each slot. */
369
+ const CUSTOM_PART_KEYS = {
370
+ headpiece: "headpiece",
371
+ bangs: "bangsPart",
372
+ leftSideHair: "leftSideHairPart",
373
+ rightSideHair: "rightSideHairPart",
374
+ backHair: "backHairPart"
375
+ };
376
+ const PART_KEY_ENTRIES = Object.entries(CUSTOM_PART_KEYS);
554
377
  const DEFAULT_RECIPE = {
555
378
  schemaVersion: 1,
556
379
  family: "illustrated",
@@ -581,10 +404,15 @@ function isPixelAvatarRecipe(value) {
581
404
  if (detailed !== 0 && detailed !== DETAIL_KEYS.length) return false;
582
405
  const v3 = r["assetVersion"] === 3;
583
406
  const v2 = r["assetVersion"] === 2 || v3;
584
- if (v3 ? !isPixelCustomPart(r["headpiece"]) : Object.hasOwn(r, "headpiece")) return false;
407
+ const worn = PART_KEY_ENTRIES.filter(([, key]) => Object.hasOwn(r, key));
408
+ if (v3 ? worn.length === 0 : worn.length !== 0) return false;
409
+ for (const [slot, key] of worn) {
410
+ const part = r[key];
411
+ if (!isPixelCustomPart(part) || part.slot !== slot) return false;
412
+ }
585
413
  const pieces = AVATAR_PIECE_COLORS.filter((key) => Object.hasOwn(r, key));
586
414
  const extras = Object.keys(AVATAR_EXTRA_PARTS).filter((key) => Object.hasOwn(r, key));
587
- const v2Keys = v2 ? 2 + pieces.length + extras.length + (v3 ? 1 : 0) : 0;
415
+ const v2Keys = v2 ? 2 + pieces.length + extras.length + worn.length : 0;
588
416
  if (Object.keys(r).length !== Object.keys(DEFAULT_RECIPE).length + detailed + v2Keys) return false;
589
417
  if (v2 && (detailed === 0 || !AVATAR_SPECIES.includes(r["species"]) || !AVATAR_HAIR_PARTS.sideHair.includes(r["rightSideHair"]) || !pieces.every((key) => typeof r[key] === "string" && /^#[\da-f]{6}$/iu.test(r[key])) || !extras.every((key) => AVATAR_EXTRA_PARTS[key].includes(r[key])))) return false;
590
418
  const catalog = v2 ? AVATAR_PARTS_V2 : AVATAR_PARTS;
@@ -609,7 +437,10 @@ function canonicalRecipe(recipe) {
609
437
  canonical["rightSideHair"] = recipe.rightSideHair;
610
438
  for (const key of AVATAR_PIECE_COLORS) if (recipe[key] !== void 0) canonical[key] = recipe[key].toLowerCase();
611
439
  for (const key of Object.keys(AVATAR_EXTRA_PARTS)) if (recipe[key] !== void 0) canonical[key] = recipe[key];
612
- if (recipe.assetVersion === 3) canonical["headpiece"] = canonicalCustomPart(recipe.headpiece);
440
+ if (recipe.assetVersion === 3) for (const [, key] of PART_KEY_ENTRIES) {
441
+ const part = recipe[key];
442
+ if (part !== void 0) canonical[key] = canonicalCustomPart(part);
443
+ }
613
444
  }
614
445
  return canonical;
615
446
  }
@@ -654,21 +485,31 @@ function withSpecies(recipe, species) {
654
485
  };
655
486
  }
656
487
  /**
657
- * Returns the recipe wearing a Custom Part in the headpiece slot (asset version 3), or without
658
- * one (asset version 2). The recipe embeds its own copy of the part.
488
+ * Returns the recipe wearing `part` in `slot`, or with that slot's part taken off. The recipe
489
+ * embeds its own copy; it is asset version 3 while it wears any part and version 2 otherwise.
659
490
  */
660
- function withHeadpiece(recipe, part) {
661
- const { headpiece: _, ...rest } = recipe.assetVersion === 1 ? withSpecies(recipe, "human") : recipe;
662
- if (part === void 0) return {
663
- ...rest,
664
- assetVersion: 2
665
- };
491
+ function withCustomPart(recipe, slot, part) {
492
+ const base = { ...recipe.assetVersion === 1 ? withSpecies(recipe, "human") : recipe };
493
+ const key = CUSTOM_PART_KEYS[slot];
494
+ delete base[key];
495
+ if (part !== void 0) base[key] = canonicalCustomPart({
496
+ ...part,
497
+ slot
498
+ });
499
+ const wearing = PART_KEY_ENTRIES.some(([, k]) => base[k] !== void 0);
666
500
  return {
667
- ...rest,
668
- assetVersion: 3,
669
- headpiece: canonicalCustomPart(part)
501
+ ...base,
502
+ assetVersion: wearing ? 3 : 2
670
503
  };
671
504
  }
505
+ /** `withCustomPart` for the headpiece slot. */
506
+ function withHeadpiece(recipe, part) {
507
+ return withCustomPart(recipe, "headpiece", part);
508
+ }
509
+ /** The Custom Part worn in a slot, if any. */
510
+ function wornPart(recipe, slot) {
511
+ return recipe.assetVersion === 3 ? recipe[CUSTOM_PART_KEYS[slot]] : void 0;
512
+ }
672
513
  function detailedRecipe(recipe) {
673
514
  if (recipe.bangs !== void 0) return recipe;
674
515
  const pick = (part) => AVATAR_HAIR_PARTS[part].includes(recipe.hair) ? recipe.hair : HAIR_SPLIT[recipe.hair][part];
@@ -1025,6 +866,223 @@ const AVATAR_PRESETS = [
1025
866
  })
1026
867
  ];
1027
868
 
869
+ //#endregion
870
+ //#region src/part.ts
871
+ /**
872
+ * Custom Part slots. A headpiece covers the top of the tile, around the hair: its back layer is
873
+ * drawn behind the hair and its front layer over it. Hair slots replace one built-in hair piece
874
+ * and use only the front layer. Cells are in tile coordinates, so the Avatar centerline sits
875
+ * between x = 15 and x = 16.
876
+ */
877
+ const PART_SLOTS = {
878
+ headpiece: {
879
+ width: 32,
880
+ height: 16
881
+ },
882
+ bangs: {
883
+ width: 32,
884
+ height: 32
885
+ },
886
+ leftSideHair: {
887
+ width: 32,
888
+ height: 32
889
+ },
890
+ rightSideHair: {
891
+ width: 32,
892
+ height: 32
893
+ },
894
+ backHair: {
895
+ width: 32,
896
+ height: 32
897
+ }
898
+ };
899
+ /**
900
+ * Hair slots. In a hair part, a `hairColor` cell at tone 0 is live hair: it is shaded with the
901
+ * rest of the hair, exactly like a built-in piece. Every other cell shows its own color.
902
+ */
903
+ const HAIR_PART_SLOTS = [
904
+ "bangs",
905
+ "leftSideHair",
906
+ "rightSideHair",
907
+ "backHair"
908
+ ];
909
+ const isHairPartSlot = (slot) => HAIR_PART_SLOTS.includes(slot);
910
+ /** Tone steps on the rig's shade ramp: two darker, the color itself, two lighter. */
911
+ const PART_TONES = [
912
+ -2,
913
+ -1,
914
+ 0,
915
+ 1,
916
+ 2
917
+ ];
918
+ const PART_LAYERS = ["front", "back"];
919
+ /** Most fixed colors one part may use. */
920
+ const MAX_PART_FIXED_COLORS = 16;
921
+ const FIXED = /^#[\da-f]{6}$/iu;
922
+ const isColor = (value) => typeof value === "string" && (AVATAR_COLORS.includes(value) || FIXED.test(value));
923
+ function isPixelCustomPart(value) {
924
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
925
+ const r = value;
926
+ if (Object.keys(r).length !== 3 || !Object.hasOwn(PART_SLOTS, r["slot"])) return false;
927
+ const { width, height } = PART_SLOTS[r["slot"]];
928
+ if (isHairPartSlot(r["slot"]) && r["back"]?.length !== 0) return false;
929
+ const fixed = /* @__PURE__ */ new Set();
930
+ for (const name of PART_LAYERS) {
931
+ const cells = r[name];
932
+ if (!Array.isArray(cells) || cells.length > width * height) return false;
933
+ const seen = /* @__PURE__ */ new Set();
934
+ for (const cell of cells) {
935
+ if (!Array.isArray(cell) || cell.length !== 4) return false;
936
+ const [x, y, color, tone] = cell;
937
+ if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || x >= width || y < 0 || y >= height || !isColor(color) || !PART_TONES.includes(tone)) return false;
938
+ const key = y * width + x;
939
+ if (seen.has(key)) return false;
940
+ seen.add(key);
941
+ if (color.startsWith("#")) fixed.add(color.toLowerCase());
942
+ }
943
+ }
944
+ return fixed.size <= MAX_PART_FIXED_COLORS;
945
+ }
946
+ const canonicalCells = (cells) => cells.map(([x, y, color, tone]) => [
947
+ x,
948
+ y,
949
+ color.startsWith("#") ? color.toLowerCase() : color,
950
+ tone
951
+ ]).sort((a, b) => a[1] - b[1] || a[0] - b[0]);
952
+ /** The part with cells in row order and fixed colors in lowercase. */
953
+ function canonicalCustomPart(part) {
954
+ return {
955
+ slot: part.slot,
956
+ front: canonicalCells(part.front),
957
+ back: canonicalCells(part.back)
958
+ };
959
+ }
960
+ /** Content identity: a SHA-256 of the canonical slot, cells and color references. */
961
+ function customPartId(part) {
962
+ return sha256(JSON.stringify(canonicalCustomPart(part)));
963
+ }
964
+ function emptyPartLayer(slot) {
965
+ const { width, height } = PART_SLOTS[slot];
966
+ return Array.from({ length: height }, () => Array(width).fill(null));
967
+ }
968
+ function partLayer(slot, cells) {
969
+ const layer = emptyPartLayer(slot);
970
+ for (const [x, y, color, tone] of cells) layer[y][x] = {
971
+ color,
972
+ tone
973
+ };
974
+ return layer;
975
+ }
976
+ function partCells(layer) {
977
+ return layer.flatMap((row, y) => row.flatMap((ink, x) => ink ? [[
978
+ x,
979
+ y,
980
+ ink.color,
981
+ ink.tone
982
+ ]] : []));
983
+ }
984
+ /** Builds a canonical part from two drawn layers. */
985
+ function createCustomPart(slot, layers) {
986
+ return canonicalCustomPart({
987
+ slot,
988
+ front: partCells(layers.front),
989
+ back: partCells(layers.back)
990
+ });
991
+ }
992
+ /** The column mirrored across the Avatar centerline. */
993
+ function mirrorPartX(slot, x) {
994
+ return PART_SLOTS[slot].width - 1 - x;
995
+ }
996
+ const same = (a, b) => a === b || a !== null && b !== null && a.color === b.color && a.tone === b.tone;
997
+ /** Sets cells (pencil with ink, eraser with `null`), optionally mirrored across the centerline. */
998
+ function paintPartLayer(slot, layer, points, ink, mirror = false) {
999
+ const out = layer.map((row) => [...row]);
1000
+ const { width, height } = PART_SLOTS[slot];
1001
+ for (const [x, y] of points) for (const px of mirror ? [x, mirrorPartX(slot, x)] : [x]) if (px >= 0 && px < width && y >= 0 && y < height) out[y][px] = ink;
1002
+ return out;
1003
+ }
1004
+ /**
1005
+ * 4-connected flood fill from a cell: every cell reachable through cells equal to the start
1006
+ * becomes `ink`. With `mirror`, the mirrored start is filled too.
1007
+ */
1008
+ function fillPartLayer(slot, layer, x, y, ink, mirror = false) {
1009
+ const { width, height } = PART_SLOTS[slot];
1010
+ let out = layer.map((row) => [...row]);
1011
+ for (const sx of mirror ? [x, mirrorPartX(slot, x)] : [x]) {
1012
+ if (sx < 0 || sx >= width || y < 0 || y >= height) continue;
1013
+ const target = out[y][sx];
1014
+ if (same(target, ink)) continue;
1015
+ const next = out.map((row) => [...row]);
1016
+ const stack = [[sx, y]];
1017
+ while (stack.length) {
1018
+ const [cx, cy] = stack.pop();
1019
+ if (cx < 0 || cx >= width || cy < 0 || cy >= height) continue;
1020
+ if (!same(next[cy][cx], target)) continue;
1021
+ next[cy][cx] = ink;
1022
+ stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]);
1023
+ }
1024
+ out = next;
1025
+ }
1026
+ return out;
1027
+ }
1028
+ 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(" ").map((h) => Number.parseInt(h, 16)));
1029
+ function sha256(text) {
1030
+ const bytes = new TextEncoder().encode(text);
1031
+ const length = Math.ceil((bytes.length + 9) / 64) * 64;
1032
+ const data = new Uint8Array(length);
1033
+ data.set(bytes);
1034
+ data[bytes.length] = 128;
1035
+ const view = new DataView(data.buffer);
1036
+ view.setUint32(length - 8, Math.floor(bytes.length * 8 / 2 ** 32));
1037
+ view.setUint32(length - 4, bytes.length * 8 >>> 0);
1038
+ const h = Uint32Array.from([
1039
+ 1779033703,
1040
+ 3144134277,
1041
+ 1013904242,
1042
+ 2773480762,
1043
+ 1359893119,
1044
+ 2600822924,
1045
+ 528734635,
1046
+ 1541459225
1047
+ ]);
1048
+ const w = new Uint32Array(64);
1049
+ const rotr = (v, n) => v >>> n | v << 32 - n;
1050
+ for (let offset = 0; offset < length; offset += 64) {
1051
+ for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4);
1052
+ for (let i = 16; i < 64; i++) {
1053
+ const a = w[i - 15];
1054
+ const b = w[i - 2];
1055
+ const s0 = rotr(a, 7) ^ rotr(a, 18) ^ a >>> 3;
1056
+ const s1 = rotr(b, 17) ^ rotr(b, 19) ^ b >>> 10;
1057
+ w[i] = w[i - 16] + s0 + w[i - 7] + s1 >>> 0;
1058
+ }
1059
+ let [a, b, c, d, e, f, g, k] = h;
1060
+ for (let i = 0; i < 64; i++) {
1061
+ const t1 = k + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + (e & f ^ ~e & g) + K[i] + w[i] >>> 0;
1062
+ const t2 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + (a & b ^ a & c ^ b & c) >>> 0;
1063
+ k = g;
1064
+ g = f;
1065
+ f = e;
1066
+ e = d + t1 >>> 0;
1067
+ d = c;
1068
+ c = b;
1069
+ b = a;
1070
+ a = t1 + t2 >>> 0;
1071
+ }
1072
+ [
1073
+ a,
1074
+ b,
1075
+ c,
1076
+ d,
1077
+ e,
1078
+ f,
1079
+ g,
1080
+ k
1081
+ ].forEach((v, i) => h[i] = h[i] + v >>> 0);
1082
+ }
1083
+ return [...h].map((v) => v.toString(16).padStart(8, "0")).join("");
1084
+ }
1085
+
1028
1086
  //#endregion
1029
1087
  //#region src/figure.ts
1030
1088
  const N = 32;
@@ -1382,10 +1440,18 @@ function detailedHair(recipe, s, b) {
1382
1440
  const length = (recipe.hairLength ?? 0) * 2;
1383
1441
  const lengthen = (mask) => (x, y) => mask(x, y > 18 ? Math.round(y - length) : y);
1384
1442
  const sideOf = (sign) => (x, y) => y > 7 && sign * (x + .5 - C - s) >= 7 && lengthen((sign < 0 ? side : right).front)(x, y);
1443
+ const fringe = (x, y) => y <= 7 ? bangs.front(x, y) || back.front(x, y) : Math.abs(x + .5 - C - s) < 7 && bangs.front(x, y);
1444
+ const behind = length === 0 ? back.back : lengthen(back.back);
1385
1445
  return {
1386
- front: (x, y) => y <= 7 ? bangs.front(x, y) || back.front(x, y) : Math.abs(x + .5 - C - s) < 7 ? bangs.front(x, y) : lengthen((x + .5 - C - s < 0 ? side : right).front)(x, y),
1446
+ front: (x, y) => fringe(x, y) || sideOf(-1)(x, y) || sideOf(1)(x, y),
1387
1447
  sides: [sideOf(-1), sideOf(1)],
1388
- back: length === 0 ? back.back : lengthen(back.back),
1448
+ pieces: {
1449
+ bangs: fringe,
1450
+ leftSideHair: sideOf(-1),
1451
+ rightSideHair: sideOf(1),
1452
+ backHair: behind
1453
+ },
1454
+ back: behind,
1389
1455
  ...back.ties ? { ties: back.ties } : {},
1390
1456
  ...back.bands ? { bands: back.bands } : {}
1391
1457
  };
@@ -1832,7 +1898,7 @@ function pixelFigure(recipe, yawDeg, options = {}) {
1832
1898
  const covered = recipe.accessory === "helmet" || recipe.accessory === "hood";
1833
1899
  const bare = () => false;
1834
1900
  const petalRing = flower ? petals(recipe.petals ?? "trumpet", s) : bare;
1835
- const masks = flower ? {
1901
+ const builtin = flower ? {
1836
1902
  back: petalRing,
1837
1903
  front: bare,
1838
1904
  sides: [(x, y) => petalRing(x, y) && x + .5 < C + s, (x, y) => petalRing(x, y) && x + .5 >= C + s]
@@ -1840,6 +1906,40 @@ function pixelFigure(recipe, yawDeg, options = {}) {
1840
1906
  back: bare,
1841
1907
  front: bare
1842
1908
  } : recipe.bangs === void 0 ? hair(recipe, s, b) : detailedHair(recipe, s, b);
1909
+ const drawnHair = HAIR_PART_SLOTS.flatMap((slot) => {
1910
+ const part = builtin.pieces ? wornPart(recipe, slot) : void 0;
1911
+ return part ? [{
1912
+ slot,
1913
+ part
1914
+ }] : [];
1915
+ });
1916
+ const live = (cells, shift) => {
1917
+ const keys = new Set(cells.flatMap(([x, y, color, tone]) => color === "hairColor" && tone === 0 ? [`${x + shift},${y}`] : []));
1918
+ return (x, y) => keys.has(`${x},${y}`);
1919
+ };
1920
+ const backShift = () => {
1921
+ if (s === b || !builtin.pieces) return s;
1922
+ const front = detailedHair(detailedRecipe(recipe), 0, 0).pieces.backHair;
1923
+ const misses = (shift) => {
1924
+ let count = 0;
1925
+ for (let y = 0; y < N; y++) for (let x = -4; x < N + 4; x++) if (front(x, y) !== builtin.pieces.backHair(x + shift, y)) count++;
1926
+ return count;
1927
+ };
1928
+ return misses(b) < misses(s) ? b : s;
1929
+ };
1930
+ const piece = (slot) => {
1931
+ const part = drawnHair.find((drawn) => drawn.slot === slot)?.part;
1932
+ if (!part) return builtin.pieces[slot];
1933
+ return live(part.front, slot === "backHair" ? backShift() : s);
1934
+ };
1935
+ const masks = drawnHair.length === 0 ? builtin : {
1936
+ ...builtin,
1937
+ front: (x, y) => piece("bangs")(x, y) || piece("leftSideHair")(x, y) || piece("rightSideHair")(x, y),
1938
+ sides: [piece("leftSideHair"), piece("rightSideHair")],
1939
+ back: piece("backHair"),
1940
+ ...drawnHair.some((drawn) => drawn.slot === "backHair") ? { ties: [] } : {}
1941
+ };
1942
+ const inked = (slot) => drawnHair.filter((drawn) => slot === "all-front" ? drawn.slot !== "backHair" : drawn.slot === slot).flatMap((drawn) => drawn.part.front.filter(([, , color, tone]) => !(color === "hairColor" && tone === 0)));
1843
1943
  const body = blank();
1844
1944
  if (flower) flowerBase(recipe, body, s);
1845
1945
  else outfit(recipe, body, s);
@@ -1850,11 +1950,11 @@ function pixelFigure(recipe, yawDeg, options = {}) {
1850
1950
  paint(back, (x, y) => petalRing(x, y) && x + .5 < C + s - 3 && y < 10, mix$1(hairColor, WHITE, .3));
1851
1951
  } else paint(back, masks.back, shade(hairColor, .82));
1852
1952
  if (masks.bands) paint(back, (x, y) => masks.back(x, y) && (y + Math.floor(x / 2)) % 4 === 0, shade(hairColor, .68));
1853
- const headpiece = recipe.assetVersion === 3 ? recipe.headpiece : void 0;
1854
- const placeCells = (grid, cells) => {
1953
+ const headpiece = wornPart(recipe, "headpiece");
1954
+ const placeCells = (grid, cells, compress = true) => {
1855
1955
  const placed = cells.map(([x, y, color, tone]) => {
1856
1956
  const dx = x + .5 - C;
1857
- const far = turned && d * dx < 0 && Math.abs(dx) >= 5;
1957
+ const far = compress && turned && d * dx < 0 && Math.abs(dx) >= 5;
1858
1958
  const base = color.startsWith("#") ? color.toLowerCase() : recipe[color];
1859
1959
  return {
1860
1960
  x: x + s + (far ? d : 0),
@@ -1865,6 +1965,7 @@ function pixelFigure(recipe, yawDeg, options = {}) {
1865
1965
  });
1866
1966
  for (const cell of [...placed.filter((p) => p.far), ...placed.filter((p) => !p.far)]) if (cell.x >= 0 && cell.x < N) grid[cell.y][cell.x] = cell.c;
1867
1967
  };
1968
+ placeCells(back, inked("backHair"), false);
1868
1969
  if (headpiece) placeCells(back, headpiece.back);
1869
1970
  const head = blank();
1870
1971
  paint(head, face, skin);
@@ -1955,6 +2056,7 @@ function pixelFigure(recipe, yawDeg, options = {}) {
1955
2056
  return out;
1956
2057
  };
1957
2058
  const crown = blank();
2059
+ placeCells(crown, inked("all-front"), false);
1958
2060
  if (headpiece) placeCells(crown, headpiece.front);
1959
2061
  const figure = blank();
1960
2062
  for (const layer of [
@@ -2133,6 +2235,40 @@ function pixelFigure(recipe, yawDeg, options = {}) {
2133
2235
  ].join("")
2134
2236
  };
2135
2237
  }
2238
+ /**
2239
+ * The hair piece in `slot` as a Custom Part to start drawing from: the drawn part worn there,
2240
+ * or the built-in piece flattened for this recipe in the front pose as live `hairColor` cells.
2241
+ * A flattened piece keeps this shape and no longer follows face shape or hair length.
2242
+ */
2243
+ function hairPieceStart(recipe, slot) {
2244
+ const worn = wornPart(recipe, slot);
2245
+ if (worn) return {
2246
+ slot,
2247
+ front: [...worn.front],
2248
+ back: []
2249
+ };
2250
+ const built = detailedHair(detailedRecipe(recipe), 0, 0);
2251
+ const mask = built.pieces[slot];
2252
+ const ties = new Set(slot === "backHair" ? (built.ties ?? []).map(([x, y]) => `${Math.round(x)},${y}`) : []);
2253
+ const front = [];
2254
+ for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) if (ties.has(`${x},${y}`)) front.push([
2255
+ x,
2256
+ y,
2257
+ "shirtColor",
2258
+ -1
2259
+ ]);
2260
+ else if (mask(x, y)) front.push([
2261
+ x,
2262
+ y,
2263
+ "hairColor",
2264
+ 0
2265
+ ]);
2266
+ return {
2267
+ slot,
2268
+ front,
2269
+ back: []
2270
+ };
2271
+ }
2136
2272
 
2137
2273
  //#endregion
2138
2274
  //#region src/svg.ts
@@ -2656,5 +2792,5 @@ function symbolArtCells(rows, color) {
2656
2792
  }
2657
2793
 
2658
2794
  //#endregion
2659
- export { AVATAR_COLORS, AVATAR_EXTRA_PARTS, AVATAR_HAIR_PARTS, AVATAR_PARTS, AVATAR_PARTS_V2, AVATAR_PIECE_COLORS, AVATAR_PRESETS, AVATAR_RANGES, AVATAR_SPECIES, AVATAR_SPECIES_SWATCHES, AVATAR_SWATCHES, AVATAR_TURNS, DEFAULT_RECIPE, MAX_PART_FIXED_COLORS, PART_LAYERS, PART_SLOTS, PART_TONES, PIXEL_SYMBOLS, canonicalCustomPart, canonicalRecipe, createCustomPart, createSeededRecipe, createSeededRecipeV2, customPartId, detailedRecipe, emptyPartLayer, faceCells, fillPartLayer, hiddenChoices, isPixelAvatarRecipe, isPixelCustomPart, mirrorPartX, paintPartLayer, partCells, partLayer, partToneColor, pixelAvatarSvg, pixelFigure, pixelSymbolCells, pixelTileColor, seededRandom, seededRecipe, seededRecipeV2, symbolArtCells, withHeadpiece, withSpecies };
2795
+ export { AVATAR_COLORS, AVATAR_EXTRA_PARTS, AVATAR_HAIR_PARTS, AVATAR_PARTS, AVATAR_PARTS_V2, AVATAR_PIECE_COLORS, AVATAR_PRESETS, AVATAR_RANGES, AVATAR_SPECIES, AVATAR_SPECIES_SWATCHES, AVATAR_SWATCHES, AVATAR_TURNS, CUSTOM_PART_KEYS, DEFAULT_RECIPE, HAIR_PART_SLOTS, MAX_PART_FIXED_COLORS, PART_LAYERS, PART_SLOTS, PART_TONES, PIXEL_SYMBOLS, canonicalCustomPart, canonicalRecipe, createCustomPart, createSeededRecipe, createSeededRecipeV2, customPartId, detailedRecipe, emptyPartLayer, faceCells, fillPartLayer, hairPieceStart, hiddenChoices, isHairPartSlot, isPixelAvatarRecipe, isPixelCustomPart, mirrorPartX, paintPartLayer, partCells, partLayer, partToneColor, pixelAvatarSvg, pixelFigure, pixelSymbolCells, pixelTileColor, seededRandom, seededRecipe, seededRecipeV2, symbolArtCells, withCustomPart, withHeadpiece, withSpecies, wornPart };
2660
2796
  //# sourceMappingURL=index.js.map