@botharness/pixel-avatar 0.6.0 → 0.8.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/README.md CHANGED
@@ -22,6 +22,8 @@ const svg = pixelAvatarSvg(recipe); // 32×32 viewBox, crisp edges, no ids or sc
22
22
  - `AVATAR_PARTS_V2`, `AVATAR_EXTRA_PARTS`, `hiddenChoices(recipe)`: version 2 adds elf, dwarf, orc and flower species, armor/robe/tunic/cloak outfits, helmet/hood headwear, an optional `beard`, and a flower's `petals` and `flowerBase`. `hiddenChoices` names saved choices the current species or headwear keeps but does not draw, so an editor can say so instead of discarding them.
23
23
  - `PixelCustomPart`, `withHeadpiece(recipe, part)`, `customPartId`, `isPixelCustomPart`: a Human-drawn Custom Part in the `headpiece` slot (the top 32×16 of the tile). Its back layer is drawn behind the hair and its front layer over it, and both follow the head through turns. Each cell is `[x, y, color, tone]`, where `color` is an appearance color slot (`hairColor`, `skinColor`, `eyeColor`, `shirtColor`), so the part recolors with the Avatar, or a fixed `#rrggbb`, and `tone` is a step from −2 to 2 on the rig's shade ramp (`partToneColor`). A recipe wearing a part embeds its own copy as asset version 3. `customPartId` is the SHA-256 of the canonical content; names, authors and origins belong to the consumer's library.
24
24
  - `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.
25
+ - `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.
25
26
  - `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.
27
+ - `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.
26
28
  - `faceCells`, `pixelSymbolCells`, `symbolArtCells`: pixels for [`@botharness/pixel-morph`](../morph).
27
29
  - `pixelAvatarSvg(recipe, { turns, classPrefix })`: optional pre-rendered head turns, and rig layer classes (`<prefix>-body`, `-head`, `-face`, `-gaze`, `-blink`) for CSS animation.
package/dist/index.d.ts CHANGED
@@ -90,6 +90,14 @@ declare const CUSTOM_PART_KEYS: {
90
90
  readonly leftSideHair: "leftSideHairPart";
91
91
  readonly rightSideHair: "rightSideHairPart";
92
92
  readonly backHair: "backHairPart";
93
+ readonly outfit: "outfitPart";
94
+ readonly accessory: "accessoryPart";
95
+ readonly beard: "beardPart";
96
+ readonly glasses: "glassesPart";
97
+ readonly nose: "nosePart";
98
+ readonly cheeks: "cheeksPart";
99
+ readonly petals: "petalsPart";
100
+ readonly flowerBase: "flowerBasePart";
93
101
  };
94
102
  type CustomPartKey = (typeof CUSTOM_PART_KEYS)[PartSlot];
95
103
  /**
@@ -177,6 +185,38 @@ declare const PART_SLOTS: {
177
185
  readonly width: 32;
178
186
  readonly height: 32;
179
187
  };
188
+ readonly outfit: {
189
+ readonly width: 32;
190
+ readonly height: 32;
191
+ };
192
+ readonly accessory: {
193
+ readonly width: 32;
194
+ readonly height: 32;
195
+ };
196
+ readonly beard: {
197
+ readonly width: 32;
198
+ readonly height: 32;
199
+ };
200
+ readonly glasses: {
201
+ readonly width: 32;
202
+ readonly height: 32;
203
+ };
204
+ readonly nose: {
205
+ readonly width: 32;
206
+ readonly height: 32;
207
+ };
208
+ readonly cheeks: {
209
+ readonly width: 32;
210
+ readonly height: 32;
211
+ };
212
+ readonly petals: {
213
+ readonly width: 32;
214
+ readonly height: 32;
215
+ };
216
+ readonly flowerBase: {
217
+ readonly width: 32;
218
+ readonly height: 32;
219
+ };
180
220
  };
181
221
  type PartSlot = keyof typeof PART_SLOTS;
182
222
  /**
@@ -186,6 +226,13 @@ type PartSlot = keyof typeof PART_SLOTS;
186
226
  declare const HAIR_PART_SLOTS: readonly ["bangs", "leftSideHair", "rightSideHair", "backHair"];
187
227
  type HairPartSlot = (typeof HAIR_PART_SLOTS)[number];
188
228
  declare const isHairPartSlot: (slot: PartSlot) => slot is HairPartSlot;
229
+ /**
230
+ * Slots whose drawn part replaces a built-in part pixel for pixel: the part's cells are painted
231
+ * where the built-in part would be, and every cell shows its own color and tone.
232
+ */
233
+ declare const REPLACE_PART_SLOTS: readonly ["outfit", "accessory", "beard", "glasses", "nose", "cheeks", "petals", "flowerBase"];
234
+ type ReplacePartSlot = (typeof REPLACE_PART_SLOTS)[number];
235
+ declare const isReplacePartSlot: (slot: PartSlot) => slot is ReplacePartSlot;
189
236
  /** Tone steps on the rig's shade ramp: two darker, the color itself, two lighter. */
190
237
  declare const PART_TONES: readonly [-2, -1, 0, 1, 2];
191
238
  type PartTone = (typeof PART_TONES)[number];
@@ -196,7 +243,7 @@ type PartCell = readonly [x: number, y: number, color: PartColor, tone: PartTone
196
243
  declare const PART_LAYERS: readonly ["front", "back"];
197
244
  type PartLayerName = (typeof PART_LAYERS)[number];
198
245
  /** Most fixed colors one part may use. */
199
- declare const MAX_PART_FIXED_COLORS = 16;
246
+ declare const MAX_PART_FIXED_COLORS = 32;
200
247
  /**
201
248
  * A Human-drawn Custom Part. Its content is immutable and its identity is `customPartId`;
202
249
  * name, author, origin and parent are metadata kept outside it.
@@ -231,6 +278,29 @@ declare function paintPartLayer(slot: PartSlot, layer: PartLayer, points: readon
231
278
  * becomes `ink`. With `mirror`, the mirrored start is filled too.
232
279
  */
233
280
  declare function fillPartLayer(slot: PartSlot, layer: PartLayer, x: number, y: number, ink: PartInk | null, mirror?: boolean): PartLayer;
281
+ /** Pixel-perfect line points (Bresenham). With `snap`, the end snaps to 0°, 45° or 90°. */
282
+ declare function partLinePoints(from: readonly [number, number], to: readonly [number, number], snap?: boolean): [number, number][];
283
+ /** Rectangle outline points between two corners. With `square`, the far corner makes a square. */
284
+ declare function partRectPoints(from: readonly [number, number], to: readonly [number, number], square?: boolean): [number, number][];
285
+ /** The ordered-dither threshold in [0, 1) for a cell. */
286
+ declare function ditherThreshold(x: number, y: number, size?: 2 | 4): number;
287
+ /**
288
+ * A linear gradient over the 4-connected region of the start cell: each cell's position along
289
+ * `from`→`to` steps through the tones between `fromTone` and `toTone`, with ordered dithering
290
+ * between neighboring tones. No RGB is blended; the result is ordinary cells.
291
+ */
292
+ declare function gradientPartLayer(slot: PartSlot, layer: PartLayer, from: readonly [number, number], to: readonly [number, number], color: PartColor, fromTone: PartTone, toTone: PartTone, options?: {
293
+ dither?: 2 | 4;
294
+ mirror?: boolean;
295
+ }): PartLayer;
296
+ /**
297
+ * Moves the tone of colored cells by ±1 at random, for texture. The same seed gives the same
298
+ * result; the seed is used only now, and the result is ordinary cells. With `mirror`, each cell
299
+ * and its mirror get the same change.
300
+ */
301
+ declare function noisePartLayer(slot: PartSlot, layer: PartLayer, amount: number, seed: string, mirror?: boolean): PartLayer;
302
+ /** Moves the tone of each colored cell on the points by `delta` once; empty cells are skipped. */
303
+ declare function shadePartLayer(slot: PartSlot, layer: PartLayer, points: readonly (readonly [number, number])[], delta: 1 | -1, mirror?: boolean): PartLayer;
234
304
  //#endregion
235
305
  //#region src/figure.d.ts
236
306
  type Cell = string | undefined;
@@ -260,6 +330,18 @@ declare function hairPieceStart(recipe: PixelAvatarRecipe, slot: HairPartSlot):
260
330
  front: PartCell[];
261
331
  back: PartCell[];
262
332
  };
333
+ /**
334
+ * The part in a replacement slot as a Custom Part to start drawing from: the drawn part worn
335
+ * there, or the built-in part flattened for this recipe in the front pose. Each pixel becomes a
336
+ * color slot and tone when it is exactly one, so it recolors with the Avatar, and a fixed color
337
+ * otherwise; an unchanged copy renders identically. A flattened helmet or hood no longer hides
338
+ * the hair: only the built-in ones do.
339
+ */
340
+ declare function replacePartStart(recipe: PixelAvatarRecipe, slot: ReplacePartSlot): {
341
+ slot: ReplacePartSlot;
342
+ front: PartCell[];
343
+ back: PartCell[];
344
+ };
263
345
  //#endregion
264
346
  //#region src/random.d.ts
265
347
  declare function seededRandom(seed: string): () => number;
@@ -309,5 +391,5 @@ declare function pixelSymbolCells(symbol: PixelSymbol, color: string): PixelCell
309
391
  */
310
392
  declare function symbolArtCells(rows: readonly string[], color: string): PixelCell$1[];
311
393
  //#endregion
312
- 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, type AvatarColor, type AvatarExtraPart, type AvatarHairPart, type AvatarPart, type AvatarPieceColor, type AvatarRange, type AvatarSpecies, CUSTOM_PART_KEYS, type CustomPartKey, DEFAULT_RECIPE, HAIR_PART_SLOTS, type HairPartSlot, MAX_PART_FIXED_COLORS, PART_LAYERS, PART_SLOTS, PART_TONES, PIXEL_SYMBOLS, type PartCell, type PartColor, type PartInk, type PartLayer, type PartLayerName, type PartSlot, type PartTone, type PixelAvatarRecipe, type PixelAvatarRecipeV1, type PixelAvatarRecipeV2, type PixelAvatarRecipeV3, type PixelAvatarSvgOptions, type PixelCell, type PixelCustomPart, type PixelFigureOptions, type PixelGrid, type PixelMouthState, type PixelSymbol, 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 };
394
+ 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, type AvatarColor, type AvatarExtraPart, type AvatarHairPart, type AvatarPart, type AvatarPieceColor, type AvatarRange, type AvatarSpecies, CUSTOM_PART_KEYS, type CustomPartKey, DEFAULT_RECIPE, HAIR_PART_SLOTS, type HairPartSlot, MAX_PART_FIXED_COLORS, PART_LAYERS, PART_SLOTS, PART_TONES, PIXEL_SYMBOLS, type PartCell, type PartColor, type PartInk, type PartLayer, type PartLayerName, type PartSlot, type PartTone, type PixelAvatarRecipe, type PixelAvatarRecipeV1, type PixelAvatarRecipeV2, type PixelAvatarRecipeV3, type PixelAvatarSvgOptions, type PixelCell, type PixelCustomPart, type PixelFigureOptions, type PixelGrid, type PixelMouthState, type PixelSymbol, REPLACE_PART_SLOTS, type ReplacePartSlot, canonicalCustomPart, canonicalRecipe, createCustomPart, createSeededRecipe, createSeededRecipeV2, customPartId, detailedRecipe, ditherThreshold, emptyPartLayer, faceCells, fillPartLayer, gradientPartLayer, hairPieceStart, hiddenChoices, isHairPartSlot, isPixelAvatarRecipe, isPixelCustomPart, isReplacePartSlot, mirrorPartX, noisePartLayer, paintPartLayer, partCells, partLayer, partLinePoints, partRectPoints, partToneColor, pixelAvatarSvg, pixelFigure, pixelSymbolCells, pixelTileColor, replacePartStart, seededRandom, seededRecipe, seededRecipeV2, shadePartLayer, symbolArtCells, withCustomPart, withHeadpiece, withSpecies, wornPart };
313
395
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -371,7 +371,15 @@ const CUSTOM_PART_KEYS = {
371
371
  bangs: "bangsPart",
372
372
  leftSideHair: "leftSideHairPart",
373
373
  rightSideHair: "rightSideHairPart",
374
- backHair: "backHairPart"
374
+ backHair: "backHairPart",
375
+ outfit: "outfitPart",
376
+ accessory: "accessoryPart",
377
+ beard: "beardPart",
378
+ glasses: "glassesPart",
379
+ nose: "nosePart",
380
+ cheeks: "cheeksPart",
381
+ petals: "petalsPart",
382
+ flowerBase: "flowerBasePart"
375
383
  };
376
384
  const PART_KEY_ENTRIES = Object.entries(CUSTOM_PART_KEYS);
377
385
  const DEFAULT_RECIPE = {
@@ -465,10 +473,10 @@ function hiddenChoices(recipe) {
465
473
  "nose",
466
474
  "cheeks",
467
475
  "glasses",
468
- ...recipe.beard ? ["beard"] : []
476
+ ...recipe.beard || wornPart(recipe, "beard") ? ["beard"] : []
469
477
  ];
470
478
  const hidden = [];
471
- if (recipe.accessory === "helmet" || recipe.accessory === "hood") hidden.push(...HAIR_PIECES);
479
+ if ((recipe.accessory === "helmet" || recipe.accessory === "hood") && !wornPart(recipe, "accessory")) hidden.push(...HAIR_PIECES);
472
480
  if (recipe.species === "dwarf") hidden.push("nose");
473
481
  return hidden;
474
482
  }
@@ -894,6 +902,38 @@ const PART_SLOTS = {
894
902
  backHair: {
895
903
  width: 32,
896
904
  height: 32
905
+ },
906
+ outfit: {
907
+ width: 32,
908
+ height: 32
909
+ },
910
+ accessory: {
911
+ width: 32,
912
+ height: 32
913
+ },
914
+ beard: {
915
+ width: 32,
916
+ height: 32
917
+ },
918
+ glasses: {
919
+ width: 32,
920
+ height: 32
921
+ },
922
+ nose: {
923
+ width: 32,
924
+ height: 32
925
+ },
926
+ cheeks: {
927
+ width: 32,
928
+ height: 32
929
+ },
930
+ petals: {
931
+ width: 32,
932
+ height: 32
933
+ },
934
+ flowerBase: {
935
+ width: 32,
936
+ height: 32
897
937
  }
898
938
  };
899
939
  /**
@@ -907,6 +947,21 @@ const HAIR_PART_SLOTS = [
907
947
  "backHair"
908
948
  ];
909
949
  const isHairPartSlot = (slot) => HAIR_PART_SLOTS.includes(slot);
950
+ /**
951
+ * Slots whose drawn part replaces a built-in part pixel for pixel: the part's cells are painted
952
+ * where the built-in part would be, and every cell shows its own color and tone.
953
+ */
954
+ const REPLACE_PART_SLOTS = [
955
+ "outfit",
956
+ "accessory",
957
+ "beard",
958
+ "glasses",
959
+ "nose",
960
+ "cheeks",
961
+ "petals",
962
+ "flowerBase"
963
+ ];
964
+ const isReplacePartSlot = (slot) => REPLACE_PART_SLOTS.includes(slot);
910
965
  /** Tone steps on the rig's shade ramp: two darker, the color itself, two lighter. */
911
966
  const PART_TONES = [
912
967
  -2,
@@ -917,7 +972,7 @@ const PART_TONES = [
917
972
  ];
918
973
  const PART_LAYERS = ["front", "back"];
919
974
  /** Most fixed colors one part may use. */
920
- const MAX_PART_FIXED_COLORS = 16;
975
+ const MAX_PART_FIXED_COLORS = 32;
921
976
  const FIXED = /^#[\da-f]{6}$/iu;
922
977
  const isColor = (value) => typeof value === "string" && (AVATAR_COLORS.includes(value) || FIXED.test(value));
923
978
  function isPixelCustomPart(value) {
@@ -925,7 +980,7 @@ function isPixelCustomPart(value) {
925
980
  const r = value;
926
981
  if (Object.keys(r).length !== 3 || !Object.hasOwn(PART_SLOTS, r["slot"])) return false;
927
982
  const { width, height } = PART_SLOTS[r["slot"]];
928
- if (isHairPartSlot(r["slot"]) && r["back"]?.length !== 0) return false;
983
+ if (r["slot"] !== "headpiece" && r["back"]?.length !== 0) return false;
929
984
  const fixed = /* @__PURE__ */ new Set();
930
985
  for (const name of PART_LAYERS) {
931
986
  const cells = r[name];
@@ -1025,6 +1080,182 @@ function fillPartLayer(slot, layer, x, y, ink, mirror = false) {
1025
1080
  }
1026
1081
  return out;
1027
1082
  }
1083
+ /** Pixel-perfect line points (Bresenham). With `snap`, the end snaps to 0°, 45° or 90°. */
1084
+ function partLinePoints(from, to, snap = false) {
1085
+ let [x1, y1] = to;
1086
+ const [x0, y0] = from;
1087
+ if (snap) {
1088
+ const dx = x1 - x0;
1089
+ const dy = y1 - y0;
1090
+ const ax = Math.abs(dx);
1091
+ const ay = Math.abs(dy);
1092
+ if (ax > ay * 2) y1 = y0;
1093
+ else if (ay > ax * 2) x1 = x0;
1094
+ else {
1095
+ const m = Math.max(ax, ay);
1096
+ x1 = x0 + Math.sign(dx) * m;
1097
+ y1 = y0 + Math.sign(dy) * m;
1098
+ }
1099
+ }
1100
+ const points = [];
1101
+ const dx = Math.abs(x1 - x0);
1102
+ const dy = -Math.abs(y1 - y0);
1103
+ const sx = x0 < x1 ? 1 : -1;
1104
+ const sy = y0 < y1 ? 1 : -1;
1105
+ let error = dx + dy;
1106
+ let [x, y] = [x0, y0];
1107
+ for (;;) {
1108
+ points.push([x, y]);
1109
+ if (x === x1 && y === y1) break;
1110
+ const e2 = 2 * error;
1111
+ if (e2 >= dy) {
1112
+ error += dy;
1113
+ x += sx;
1114
+ }
1115
+ if (e2 <= dx) {
1116
+ error += dx;
1117
+ y += sy;
1118
+ }
1119
+ }
1120
+ return points;
1121
+ }
1122
+ /** Rectangle outline points between two corners. With `square`, the far corner makes a square. */
1123
+ function partRectPoints(from, to, square = false) {
1124
+ const [x0, y0] = from;
1125
+ let [x1, y1] = to;
1126
+ if (square) {
1127
+ const m = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0));
1128
+ x1 = x0 + (x1 < x0 ? -m : m);
1129
+ y1 = y0 + (y1 < y0 ? -m : m);
1130
+ }
1131
+ const [left, right] = [Math.min(x0, x1), Math.max(x0, x1)];
1132
+ const [top, bottom] = [Math.min(y0, y1), Math.max(y0, y1)];
1133
+ const points = /* @__PURE__ */ new Map();
1134
+ for (let x = left; x <= right; x++) for (const y of [top, bottom]) points.set(`${x},${y}`, [x, y]);
1135
+ for (let y = top; y <= bottom; y++) for (const x of [left, right]) points.set(`${x},${y}`, [x, y]);
1136
+ return [...points.values()];
1137
+ }
1138
+ const BAYER4 = [
1139
+ [
1140
+ 0,
1141
+ 8,
1142
+ 2,
1143
+ 10
1144
+ ],
1145
+ [
1146
+ 12,
1147
+ 4,
1148
+ 14,
1149
+ 6
1150
+ ],
1151
+ [
1152
+ 3,
1153
+ 11,
1154
+ 1,
1155
+ 9
1156
+ ],
1157
+ [
1158
+ 15,
1159
+ 7,
1160
+ 13,
1161
+ 5
1162
+ ]
1163
+ ];
1164
+ const BAYER2 = [[0, 2], [3, 1]];
1165
+ /** The ordered-dither threshold in [0, 1) for a cell. */
1166
+ function ditherThreshold(x, y, size = 4) {
1167
+ return size === 4 ? (BAYER4[y & 3][x & 3] + .5) / 16 : (BAYER2[y & 1][x & 1] + .5) / 4;
1168
+ }
1169
+ /**
1170
+ * A linear gradient over the 4-connected region of the start cell: each cell's position along
1171
+ * `from`→`to` steps through the tones between `fromTone` and `toTone`, with ordered dithering
1172
+ * between neighboring tones. No RGB is blended; the result is ordinary cells.
1173
+ */
1174
+ function gradientPartLayer(slot, layer, from, to, color, fromTone, toTone, options = {}) {
1175
+ const { width, height } = PART_SLOTS[slot];
1176
+ const out = layer.map((row) => [...row]);
1177
+ const [fx, fy] = from;
1178
+ const region = (sx, sy) => {
1179
+ if (sx < 0 || sx >= width || sy < 0 || sy >= height) return [];
1180
+ const target = layer[sy][sx];
1181
+ const seen = /* @__PURE__ */ new Set();
1182
+ const cells = [];
1183
+ const stack = [[sx, sy]];
1184
+ while (stack.length) {
1185
+ const [x, y] = stack.pop();
1186
+ if (x < 0 || x >= width || y < 0 || y >= height || seen.has(y * width + x)) continue;
1187
+ if (!same(layer[y][x], target)) continue;
1188
+ seen.add(y * width + x);
1189
+ cells.push([x, y]);
1190
+ stack.push([x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]);
1191
+ }
1192
+ return cells;
1193
+ };
1194
+ const paint = (cells, start, end, ditherX) => {
1195
+ const vx = end[0] - start[0];
1196
+ const vy = end[1] - start[1];
1197
+ const length = vx * vx + vy * vy;
1198
+ const steps = toTone - fromTone;
1199
+ for (const [x, y] of cells) {
1200
+ const position = (length === 0 ? 0 : Math.min(1, Math.max(0, ((x - start[0]) * vx + (y - start[1]) * vy) / length))) * Math.abs(steps);
1201
+ const base = Math.floor(position);
1202
+ const up = position - base > ditherThreshold(ditherX(x), y, options.dither ?? 4) ? 1 : 0;
1203
+ const tone = fromTone + Math.sign(steps) * Math.min(Math.abs(steps), base + up);
1204
+ out[y][x] = {
1205
+ color,
1206
+ tone
1207
+ };
1208
+ }
1209
+ };
1210
+ const own = region(fx, fy);
1211
+ if (!options.mirror) {
1212
+ paint(own, from, to, (x) => x);
1213
+ return out;
1214
+ }
1215
+ const mx = mirrorPartX(slot, fx);
1216
+ const mirroredCells = region(mx, fy);
1217
+ const inOwn = new Set(own.map(([x, y]) => y * width + x));
1218
+ const inMirrored = new Set(mirroredCells.map(([x, y]) => y * width + x));
1219
+ const startSide = (x) => x < width / 2 === fx < width / 2;
1220
+ paint(own.filter(([x, y]) => startSide(x) || !inMirrored.has(y * width + x)), from, to, (x) => x);
1221
+ paint(mirroredCells.filter(([x, y]) => !startSide(x) || !inOwn.has(y * width + x)), [mx, fy], [mirrorPartX(slot, to[0]), to[1]], (x) => mirrorPartX(slot, x));
1222
+ return out;
1223
+ }
1224
+ /**
1225
+ * Moves the tone of colored cells by ±1 at random, for texture. The same seed gives the same
1226
+ * result; the seed is used only now, and the result is ordinary cells. With `mirror`, each cell
1227
+ * and its mirror get the same change.
1228
+ */
1229
+ function noisePartLayer(slot, layer, amount, seed, mirror = false) {
1230
+ const random = seededRandom(seed);
1231
+ const rolls = layer.map((row) => row.map(() => [random(), random() < .5 ? -1 : 1]));
1232
+ return layer.map((row, y) => row.map((ink, x) => {
1233
+ const source = mirror ? Math.min(x, mirrorPartX(slot, x)) : x;
1234
+ const [roll, direction] = rolls[y][source];
1235
+ if (!ink || roll >= amount) return ink;
1236
+ return {
1237
+ color: ink.color,
1238
+ tone: Math.max(-2, Math.min(2, ink.tone + direction))
1239
+ };
1240
+ }));
1241
+ }
1242
+ /** Moves the tone of each colored cell on the points by `delta` once; empty cells are skipped. */
1243
+ function shadePartLayer(slot, layer, points, delta, mirror = false) {
1244
+ const { width, height } = PART_SLOTS[slot];
1245
+ const out = layer.map((row) => [...row]);
1246
+ const done = /* @__PURE__ */ new Set();
1247
+ for (const [x, y] of points) for (const px of mirror ? [x, mirrorPartX(slot, x)] : [x]) {
1248
+ const key = `${px},${y}`;
1249
+ if (done.has(key) || px < 0 || px >= width || y < 0 || y >= height) continue;
1250
+ done.add(key);
1251
+ const ink = layer[y][px];
1252
+ if (ink) out[y][px] = {
1253
+ color: ink.color,
1254
+ tone: Math.max(-2, Math.min(2, ink.tone + delta))
1255
+ };
1256
+ }
1257
+ return out;
1258
+ }
1028
1259
  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
1260
  function sha256(text) {
1030
1261
  const bytes = new TextEncoder().encode(text);
@@ -1879,6 +2110,15 @@ function pixelTileColor(hair) {
1879
2110
  ].map((v) => v * 255));
1880
2111
  }
1881
2112
  function pixelFigure(recipe, yawDeg, options = {}) {
2113
+ const { grids: _, ...out } = renderFigure(recipe, yawDeg, options, /* @__PURE__ */ new Set());
2114
+ return out;
2115
+ }
2116
+ const FACE_SLOTS = [
2117
+ "nose",
2118
+ "cheeks",
2119
+ "glasses"
2120
+ ];
2121
+ function renderFigure(recipe, yawDeg, options, omit) {
1882
2122
  const yaw = yawDeg * Math.PI / 180;
1883
2123
  const sinY = Math.sin(yaw);
1884
2124
  const s = Math.round(sinY * 3);
@@ -1895,9 +2135,40 @@ function pixelFigure(recipe, yawDeg, options = {}) {
1895
2135
  return front(x - Math.round(d * (1 + Math.max(0, y - 20) * .4) - d * tuck), y);
1896
2136
  } : front;
1897
2137
  const flower = recipe.species === "flower";
1898
- const covered = recipe.accessory === "helmet" || recipe.accessory === "hood";
2138
+ const hiddenNow = hiddenChoices(recipe);
2139
+ const drawnParts = new Map(REPLACE_PART_SLOTS.flatMap((slot) => {
2140
+ const part = wornPart(recipe, slot);
2141
+ const shown = slot === "petals" || slot === "flowerBase" ? flower : !hiddenNow.includes(slot);
2142
+ return part && shown ? [[slot, part.front]] : [];
2143
+ }));
2144
+ const skip = (slot) => omit.has(slot) || drawnParts.has(slot);
2145
+ const covered = !skip("accessory") && (recipe.accessory === "helmet" || recipe.accessory === "hood");
1899
2146
  const bare = () => false;
1900
- const petalRing = flower ? petals(recipe.petals ?? "trumpet", s) : bare;
2147
+ const petalRing = flower && !skip("petals") ? petals(recipe.petals ?? "trumpet", s) : bare;
2148
+ const faceShift = Math.round(fs * .6);
2149
+ const mouthCells = /* @__PURE__ */ new Set();
2150
+ for (const rows of [...Object.values(MOUTHS), [
2151
+ ".K.",
2152
+ "KMK",
2153
+ ".K."
2154
+ ]]) {
2155
+ const left = Math.round(C - .5 + fs * .6 - (rows[0].length - 1) / 2);
2156
+ rows.forEach((row, dy) => [...row].forEach((code, dx) => {
2157
+ if (code !== ".") mouthCells.add(`${left + dx},${20 + (recipe.height ?? 0) + dy}`);
2158
+ }));
2159
+ }
2160
+ const mouthBox = (x, y) => mouthCells.has(`${x},${y}`);
2161
+ const overlay = (grid, slot, keepOutMouth = false) => {
2162
+ const cells = drawnParts.get(slot);
2163
+ if (!cells) return;
2164
+ const shift = FACE_SLOTS.includes(slot) ? faceShift : s;
2165
+ for (const [x, y, color, tone] of cells) {
2166
+ const px = x + shift;
2167
+ if (px < 0 || px >= N || keepOutMouth && mouthBox(px, y)) continue;
2168
+ const base = color.startsWith("#") ? color.toLowerCase() : recipe[color];
2169
+ grid[y][px] = TONES[tone](base);
2170
+ }
2171
+ };
1901
2172
  const builtin = flower ? {
1902
2173
  back: petalRing,
1903
2174
  front: bare,
@@ -1941,8 +2212,13 @@ function pixelFigure(recipe, yawDeg, options = {}) {
1941
2212
  };
1942
2213
  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)));
1943
2214
  const body = blank();
1944
- if (flower) flowerBase(recipe, body, s);
1945
- else outfit(recipe, body, s);
2215
+ if (flower) {
2216
+ if (!skip("flowerBase")) flowerBase(recipe, body, s);
2217
+ overlay(body, "flowerBase");
2218
+ } else {
2219
+ if (!skip("outfit")) outfit(recipe, body, s);
2220
+ overlay(body, "outfit");
2221
+ }
1946
2222
  const back = blank();
1947
2223
  if (flower) {
1948
2224
  paint(back, petalRing, hairColor);
@@ -1966,11 +2242,12 @@ function pixelFigure(recipe, yawDeg, options = {}) {
1966
2242
  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;
1967
2243
  };
1968
2244
  placeCells(back, inked("backHair"), false);
2245
+ overlay(back, "petals");
1969
2246
  if (headpiece) placeCells(back, headpiece.back);
1970
2247
  const head = blank();
1971
2248
  paint(head, face, skin);
1972
2249
  const h = recipe.height ?? 0;
1973
- if (recipe.beard && !flower) {
2250
+ if (recipe.beard && !flower && !skip("beard")) {
1974
2251
  const centre = C + fs * .6;
1975
2252
  const mouthX = Math.round(C - .5 + fs * .6);
1976
2253
  const chin = (x, y) => face(x, y) || face(x, y - 2);
@@ -2014,7 +2291,7 @@ function pixelFigure(recipe, yawDeg, options = {}) {
2014
2291
  });
2015
2292
  if (!pointed && !flower) paint(head, masks.front, hairColor);
2016
2293
  for (const [x, y] of masks.ties ?? []) sprite(back, ["T"], Math.round(x), y, { T: shade(recipe.shirtColor, .8) });
2017
- if (!flower) accessory(recipe, head, s, face);
2294
+ if (!flower && !skip("accessory")) accessory(recipe, head, s, face);
2018
2295
  const shadeHair = (g) => {
2019
2296
  const out = g.map((r) => [...r]);
2020
2297
  const cells = g.flatMap((row, y) => row.flatMap((c, x) => c === hairColor ? [[x, y]] : []));
@@ -2055,6 +2332,9 @@ function pixelFigure(recipe, yawDeg, options = {}) {
2055
2332
  }
2056
2333
  return out;
2057
2334
  };
2335
+ const drawnOver = blank();
2336
+ overlay(drawnOver, "beard", true);
2337
+ overlay(drawnOver, "accessory");
2058
2338
  const crown = blank();
2059
2339
  placeCells(crown, inked("all-front"), false);
2060
2340
  if (headpiece) placeCells(crown, headpiece.front);
@@ -2063,6 +2343,7 @@ function pixelFigure(recipe, yawDeg, options = {}) {
2063
2343
  back,
2064
2344
  body,
2065
2345
  shadeHair(head),
2346
+ drawnOver,
2066
2347
  crown
2067
2348
  ]) for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) if (layer[y][x]) figure[y][x] = layer[y][x];
2068
2349
  const features = blank();
@@ -2130,6 +2411,7 @@ function pixelFigure(recipe, yawDeg, options = {}) {
2130
2411
  N: shade(skin, .84),
2131
2412
  n: shade(skin, .72)
2132
2413
  });
2414
+ else if (skip("nose")) overlay(features, "nose", true);
2133
2415
  else if (recipe.nose === "dot") sprite(features, ["N"], cx, 18 + h, { N: shade(skin, .78) });
2134
2416
  else if (recipe.nose === "button") sprite(features, ["N"], cx, 18 + h, { N: shade(skin, .88) });
2135
2417
  else if (recipe.nose === "line") sprite(features, ["N", "N"], cx, 17 + h, { N: shade(skin, .82) });
@@ -2144,11 +2426,12 @@ function pixelFigure(recipe, yawDeg, options = {}) {
2144
2426
  });
2145
2427
  if (recipe.species === "goblin") for (const tx of [Math.round(mx - 2.5), Math.round(mx + 2.5)]) sprite(layer, ["T"], tx, 21 + h, { T: TUSK });
2146
2428
  if (recipe.species === "orc") for (const tx of [Math.round(mx - 2.5), Math.round(mx + 2.5)]) sprite(layer, ["T", "T"], tx, 20 + h, { T: TUSK });
2147
- if (recipe.cheeks === "blush" && !flower) {
2429
+ if (skip("cheeks")) overlay(layer, "cheeks", true);
2430
+ if (recipe.cheeks === "blush" && !flower && !skip("cheeks")) {
2148
2431
  sprite(layer, ["PP"], left.x - 1, 19 + h, { P: mix$1(BLUSH, skin, .25) });
2149
2432
  sprite(layer, ["PP"], right.x + right.w - 1, 19 + h, { P: mix$1(BLUSH, skin, .25) });
2150
2433
  }
2151
- if (recipe.cheeks === "freckles" && !flower) {
2434
+ if (recipe.cheeks === "freckles" && !flower && !skip("cheeks")) {
2152
2435
  sprite(layer, ["F.F"], left.x, 19 + h, { F: shade(skin, .7) });
2153
2436
  sprite(layer, ["F.F"], right.x + 1, 19 + h, { F: shade(skin, .7) });
2154
2437
  }
@@ -2171,7 +2454,8 @@ function pixelFigure(recipe, yawDeg, options = {}) {
2171
2454
  const corner = (x === fx - 1 || x === fx + w) && (y === eyeTop - 1 || y === eyeTop + 4);
2172
2455
  return box && !inside && !(round && corner);
2173
2456
  }, INK$1);
2174
- const lenses = flower ? "none" : recipe.glasses;
2457
+ const lenses = flower || skip("glasses") ? "none" : recipe.glasses;
2458
+ overlay(glasses, "glasses", true);
2175
2459
  if (lenses === "round" || lenses === "square") {
2176
2460
  frame(left.x, left.w, lenses === "round");
2177
2461
  frame(right.x, right.w, lenses === "round");
@@ -2201,7 +2485,7 @@ function pixelFigure(recipe, yawDeg, options = {}) {
2201
2485
  hairColor: color
2202
2486
  };
2203
2487
  for (const k of AVATAR_PIECE_COLORS) delete plain[k];
2204
- for (const cell of pixelFigure(plain, yawDeg).cells) variant[cell.y][cell.x] = cell.c;
2488
+ for (const cell of renderFigure(plain, yawDeg, {}, omit).cells) variant[cell.y][cell.x] = cell.c;
2205
2489
  const near = (x, y) => mask(x, y) || mask(x - 1, y) || mask(x + 1, y) || mask(x, y - 1) || mask(x, y + 1);
2206
2490
  for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) if (full[y][x] && near(x, y) && !eyes[y][x] && !glasses[y][x]) full[y][x] = variant[y][x];
2207
2491
  });
@@ -2210,7 +2494,7 @@ function pixelFigure(recipe, yawDeg, options = {}) {
2210
2494
  for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) {
2211
2495
  const c = full[y][x];
2212
2496
  if (!c) continue;
2213
- if (y >= 22 && !head[y][x] && !back[y][x] && !crown[y][x]) bodyCells[y][x] = c;
2497
+ if (y >= 22 && !head[y][x] && !back[y][x] && !crown[y][x] && !drawnOver[y][x]) bodyCells[y][x] = c;
2214
2498
  else headCells[y][x] = c;
2215
2499
  }
2216
2500
  const cells = [];
@@ -2223,6 +2507,13 @@ function pixelFigure(recipe, yawDeg, options = {}) {
2223
2507
  });
2224
2508
  }
2225
2509
  return {
2510
+ grids: {
2511
+ figure,
2512
+ features: savedFeatures,
2513
+ glasses,
2514
+ back,
2515
+ body
2516
+ },
2226
2517
  cells,
2227
2518
  tile: `<rect width="32" height="32" rx="6" fill="${pixelTileColor(recipe.hairColor)}"/>`,
2228
2519
  body: `<g class="bh-illustrated-body">${rects(bodyCells)}</g>`,
@@ -2269,6 +2560,48 @@ function hairPieceStart(recipe, slot) {
2269
2560
  back: []
2270
2561
  };
2271
2562
  }
2563
+ /**
2564
+ * The part in a replacement slot as a Custom Part to start drawing from: the drawn part worn
2565
+ * there, or the built-in part flattened for this recipe in the front pose. Each pixel becomes a
2566
+ * color slot and tone when it is exactly one, so it recolors with the Avatar, and a fixed color
2567
+ * otherwise; an unchanged copy renders identically. A flattened helmet or hood no longer hides
2568
+ * the hair: only the built-in ones do.
2569
+ */
2570
+ function replacePartStart(recipe, slot) {
2571
+ const worn = wornPart(recipe, slot);
2572
+ if (worn) return {
2573
+ slot,
2574
+ front: [...worn.front],
2575
+ back: []
2576
+ };
2577
+ const layer = slot === "glasses" ? "glasses" : slot === "nose" || slot === "cheeks" ? "features" : slot === "petals" ? "back" : slot === "outfit" || slot === "flowerBase" ? "body" : "figure";
2578
+ const shown = renderFigure(recipe, 0, {}, /* @__PURE__ */ new Set()).grids[layer];
2579
+ const without = renderFigure(recipe, 0, {}, new Set([slot])).grids[layer];
2580
+ const source = (c) => {
2581
+ for (const key of AVATAR_COLORS) for (const tone of [
2582
+ 0,
2583
+ -1,
2584
+ 1,
2585
+ -2,
2586
+ 2
2587
+ ]) if (TONES[tone](recipe[key].toLowerCase()) === c) return [key, tone];
2588
+ return [c, 0];
2589
+ };
2590
+ const front = [];
2591
+ for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) {
2592
+ const c = shown[y][x];
2593
+ if (c && c !== without[y][x]) front.push([
2594
+ x,
2595
+ y,
2596
+ ...source(c)
2597
+ ]);
2598
+ }
2599
+ return {
2600
+ slot,
2601
+ front,
2602
+ back: []
2603
+ };
2604
+ }
2272
2605
 
2273
2606
  //#endregion
2274
2607
  //#region src/svg.ts
@@ -2792,5 +3125,5 @@ function symbolArtCells(rows, color) {
2792
3125
  }
2793
3126
 
2794
3127
  //#endregion
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 };
3128
+ 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, REPLACE_PART_SLOTS, canonicalCustomPart, canonicalRecipe, createCustomPart, createSeededRecipe, createSeededRecipeV2, customPartId, detailedRecipe, ditherThreshold, emptyPartLayer, faceCells, fillPartLayer, gradientPartLayer, hairPieceStart, hiddenChoices, isHairPartSlot, isPixelAvatarRecipe, isPixelCustomPart, isReplacePartSlot, mirrorPartX, noisePartLayer, paintPartLayer, partCells, partLayer, partLinePoints, partRectPoints, partToneColor, pixelAvatarSvg, pixelFigure, pixelSymbolCells, pixelTileColor, replacePartStart, seededRandom, seededRecipe, seededRecipeV2, shadePartLayer, symbolArtCells, withCustomPart, withHeadpiece, withSpecies, wornPart };
2796
3129
  //# sourceMappingURL=index.js.map