@doan-labs/peek 1.0.0 → 1.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/dist/svg.js CHANGED
@@ -121,8 +121,25 @@ var PARTS = {
121
121
  cheeks: ["oval", "dots", "lines"],
122
122
  trait: ["square", "fin", "ring", "dot", "peak"]
123
123
  };
124
+ var ACCESSORY_INK = { knot: "#E5412D" };
125
+ var ACCESSORIES = {
126
+ eyewear: ["none", "glasses", "sunglasses"],
127
+ headwear: ["none", "bow", "cap", "sprout"],
128
+ neckwear: ["none", "tie"]
129
+ };
124
130
  var VERSIONS = {
125
- 1: { face: 4, color: 7, eyes: 3, brows: 4, mouth: 4, cheeks: 3, trait: 5 }
131
+ 1: {
132
+ face: 4,
133
+ color: 7,
134
+ eyes: 3,
135
+ brows: 4,
136
+ mouth: 4,
137
+ cheeks: 3,
138
+ trait: 5,
139
+ eyewear: 1,
140
+ headwear: 1,
141
+ neckwear: 1
142
+ }
126
143
  };
127
144
  var LATEST = 1;
128
145
  var BASE = {
@@ -335,6 +352,9 @@ var f2 = (v) => String(Math.round(v * 100) / 100);
335
352
  var f3 = (v) => String(Math.round(v * 1000) / 1000);
336
353
  var strokeFor = (px) => px >= 96 ? 1 : px >= 56 ? 1.25 : px >= 40 ? 1.6 : px >= 30 ? 2 : 2.4;
337
354
  var RAD = Math.PI / 180;
355
+ var STEM = "M0 0Q3 -19 0 -36";
356
+ var LEAF0 = "M0 -33C-24 -30 -38 -52 -25 -53C-12 -54 -2 -43 0 -33Z";
357
+ var LEAF1 = "M0 -33C5 -54 28 -61 27 -48C26 -36 11 -31 0 -33Z";
338
358
  function frameOf(face) {
339
359
  const baseY = FLOOR + face.sink - face.bottom;
340
360
  const eyeY = face.eyes[0][1];
@@ -709,7 +729,7 @@ function draw(who, pose, o) {
709
729
  const side = i === 0 ? -1 : 1;
710
730
  const hw = sh.brow.hw;
711
731
  const c = -sh.brow.arch * arch;
712
- const [w, ca, k2] = who.brows === "bar" ? [hw * 0.9, c * 0.3, 1.75] : who.brows === "dash" ? [hw * 0.42, c * 0.25, 1.9] : [hw, c, 1];
732
+ const [w2, ca, k2] = who.brows === "bar" ? [hw * 0.9, c * 0.3, 1.75] : who.brows === "dash" ? [hw * 0.42, c * 0.25, 1.9] : [hw, c, 1];
713
733
  const ti = bw * 1.7;
714
734
  const to = bw * 0.45;
715
735
  const [tl, tr] = side < 0 ? [to, ti] : [ti, to];
@@ -722,7 +742,7 @@ function draw(who, pose, o) {
722
742
  const sin = Math.sin(side * btilt * RAD);
723
743
  const inside = (dy) => {
724
744
  for (let t = 0;t <= 1; t += 0.25) {
725
- const lx = w * (2 * t - 1);
745
+ const lx = w2 * (2 * t - 1);
726
746
  const ly = 2 * t * (1 - t) * ca;
727
747
  const px = bx + lx * cos - ly * sin;
728
748
  const py = by + dy + lx * sin + ly * cos;
@@ -757,7 +777,7 @@ function draw(who, pose, o) {
757
777
  });
758
778
  }
759
779
  return n("path", key, {
760
- d: `M${f2(-w)} 0Q0 ${f2(ca)} ${f2(w)} 0`,
780
+ d: `M${f2(-w2)} 0Q0 ${f2(ca)} ${f2(w2)} 0`,
761
781
  transform,
762
782
  fill: "none",
763
783
  stroke: INK.ink,
@@ -847,8 +867,8 @@ function draw(who, pose, o) {
847
867
  });
848
868
  } else if (who.trait === "ring") {
849
869
  const b = 35 - hair * 14 - (hair < 0 ? hair * -21 : 0) + lag * 0.9;
850
- const w = ringWalk(sh);
851
- const s = seat(sh.crown, 8, Math.max(w.min, w.rest + 158 * (b - 35) * RAD));
870
+ const w2 = ringWalk(sh);
871
+ const s = seat(sh.crown, 8, Math.max(w2.min, w2.rest + 158 * (b - 35) * RAD));
852
872
  traitT = `translate(${f2(s.x)} ${f2(s.y)}) scale(${f2(1 + Math.max(0, hair) * 0.1)})`;
853
873
  shape = n("circle", "trait.shape", {
854
874
  cx: 0,
@@ -882,6 +902,169 @@ function draw(who, pose, o) {
882
902
  });
883
903
  }
884
904
  const trait = n("g", "trait", { transform: traitT }, [shape]);
905
+ const wardrobe = [];
906
+ const w = (px) => f2(px * Math.min(st, 1.6));
907
+ const line = {
908
+ fill: "none",
909
+ stroke: INK.ink,
910
+ "stroke-width": w(5.25),
911
+ "stroke-linecap": "round",
912
+ "stroke-linejoin": "round"
913
+ };
914
+ if (who.eyewear !== "none") {
915
+ const lx = sh.eyes[0][0] - spread;
916
+ const rr = sh.eyes[1][0] + spread;
917
+ const ey = sh.eyes[0][1];
918
+ const gap = rr - lx;
919
+ const erx = Math.min(rx + 9, (gap - 15) / 2);
920
+ const ery = ry + 9;
921
+ const glasses = who.eyewear === "glasses";
922
+ const lensX = glasses ? erx : Math.min(rx + 11, (gap - 12) / 2);
923
+ const top = glasses ? ey - 4 : ey - ry - 2;
924
+ const frames = [
925
+ n("path", "eyewear.bridge", {
926
+ ...line,
927
+ d: `M${f2(lx + lensX)} ${f2(top)}Q${f2((lx + rr) / 2)} ${f2(top - (glasses ? 13 : 0))} ${f2(rr - lensX)} ${f2(top)}`
928
+ })
929
+ ];
930
+ for (const [i, cx] of [lx, rr].entries()) {
931
+ const key = `eyewear.lens${i}`;
932
+ frames.push(n("path", `${key}.arm`, {
933
+ ...line,
934
+ d: i === 0 ? `M${f2(cx - lensX - 9)} ${f2(top + 2)}L${f2(cx - lensX)} ${f2(top)}` : `M${f2(cx + lensX)} ${f2(top)}L${f2(cx + lensX + 9)} ${f2(top + 2)}`
935
+ }));
936
+ if (glasses) {
937
+ frames.push(n("ellipse", key, {
938
+ ...line,
939
+ cx: f2(cx),
940
+ cy: ey,
941
+ rx: f2(erx),
942
+ ry: f2(ery)
943
+ }));
944
+ frames.push(n("path", `${key}.glint`, {
945
+ ...line,
946
+ stroke: INK.paper,
947
+ "stroke-width": w(3),
948
+ d: `M${f2(cx - erx * 0.65)} ${f2(ey - ery * 0.45)}Q${f2(cx - erx * 0.5)} ${f2(ey - ery * 0.8)} ${f2(cx - erx * 0.2)} ${f2(ey - ery * 0.86)}`
949
+ }));
950
+ } else {
951
+ const h = ry * 2 + 9;
952
+ frames.push(n("path", key, {
953
+ d: `M${f2(cx - lensX)} ${f2(top)}H${f2(cx + lensX)}V${f2(top + h * 0.55)}Q${f2(cx + lensX)} ${f2(top + h)} ${f2(cx)} ${f2(top + h)}Q${f2(cx - lensX)} ${f2(top + h)} ${f2(cx - lensX)} ${f2(top + h * 0.55)}Z`,
954
+ fill: INK.ink,
955
+ stroke: INK.ink,
956
+ "stroke-width": w(3),
957
+ "stroke-linejoin": "round"
958
+ }));
959
+ frames.push(n("path", `${key}.glint`, {
960
+ ...line,
961
+ stroke: INK.paper,
962
+ "stroke-width": w(4),
963
+ d: `M${f2(cx - lensX * 0.58)} ${f2(top + 19)}l8 -8m-1 15 6 -6`
964
+ }), n("circle", `${key}.pin`, {
965
+ cx: f2(cx + lensX * 0.55),
966
+ cy: f2(top + h - 9),
967
+ r: 1.7,
968
+ fill: INK.paper
969
+ }));
970
+ }
971
+ }
972
+ wardrobe.push(n("g", "eyewear", {}, frames));
973
+ }
974
+ if (who.headwear !== "none") {
975
+ const side = who.trait === "ring" || who.trait === "dot" ? -1 : 1;
976
+ const cap = who.headwear === "cap";
977
+ const sprout = who.headwear === "sprout";
978
+ const inset = sprout ? -6 : cap ? -8 : 0;
979
+ const offset = sprout ? 34 : 40;
980
+ let lo = 0;
981
+ let hi = 160;
982
+ for (let i = 0;i < 16; i++) {
983
+ const mid2 = (lo + hi) / 2;
984
+ const p = seat(sh.crown, inset, side * mid2);
985
+ if (side * (p.x - 170) < offset)
986
+ lo = mid2;
987
+ else
988
+ hi = mid2;
989
+ }
990
+ const anchor = seat(sh.crown, inset, side * (lo + hi) / 2);
991
+ const angle = clamp(anchor.a, -40, 40);
992
+ const parts = [];
993
+ if (cap) {
994
+ parts.push(n("path", "headwear.crown", {
995
+ ...line,
996
+ fill: INK.paper,
997
+ d: "M-33 0C-35 -20 -20 -39 0 -39C20 -39 35 -20 33 0Z"
998
+ }), n("path", "headwear.seam", {
999
+ ...line,
1000
+ "stroke-width": w(3.5),
1001
+ d: "M-4 -38Q9 -24 8 -1"
1002
+ }), n("path", "headwear.brim", {
1003
+ ...line,
1004
+ "stroke-width": w(7),
1005
+ d: side > 0 ? "M-35 1Q8 4 52 3" : "M-52 3Q-8 4 35 1"
1006
+ }), n("circle", "headwear.button", {
1007
+ cx: 0,
1008
+ cy: -40,
1009
+ r: 3.5,
1010
+ fill: INK.ink
1011
+ }));
1012
+ } else if (sprout) {
1013
+ parts.push(n("path", "headwear.halo", {
1014
+ ...line,
1015
+ stroke: fill,
1016
+ "stroke-width": w(11),
1017
+ d: `${STEM}${LEAF0}${LEAF1}`
1018
+ }), n("path", "headwear.stem", {
1019
+ ...line,
1020
+ "stroke-width": w(4.5),
1021
+ d: STEM
1022
+ }), n("path", "headwear.leaf0", {
1023
+ ...line,
1024
+ "stroke-width": w(4),
1025
+ fill: COLORS.mint.body,
1026
+ d: LEAF0
1027
+ }), n("path", "headwear.leaf1", {
1028
+ ...line,
1029
+ "stroke-width": w(4),
1030
+ fill: COLORS.mint.deep,
1031
+ d: LEAF1
1032
+ }));
1033
+ } else {
1034
+ parts.push(n("path", "headwear.wings", {
1035
+ d: "M-3 0C-13 -16 -28 -23 -29 -10C-31 2 -26 19 -16 13L-3 4L3 4L16 13C26 19 31 2 29 -10C28 -23 13 -16 3 0Z",
1036
+ fill: INK.ink,
1037
+ stroke: fill,
1038
+ "stroke-width": w(6),
1039
+ "stroke-linejoin": "round",
1040
+ "paint-order": "stroke"
1041
+ }), n("ellipse", "headwear.knot", {
1042
+ cx: 0,
1043
+ cy: 1,
1044
+ rx: 6,
1045
+ ry: 8,
1046
+ fill: ACCESSORY_INK.knot
1047
+ }));
1048
+ }
1049
+ wardrobe.push(n("g", "headwear", {
1050
+ transform: `translate(${f2(anchor.x)} ${f2(anchor.y)}) rotate(${f2(sprout ? angle * 0.5 : angle)})`
1051
+ }, parts));
1052
+ }
1053
+ if (who.neckwear === "tie") {
1054
+ const skew = Math.abs(Math.sin(g("mk") * RAD)) * mw;
1055
+ const bottom = Math.max(0, mt * kb, mb * kb) + skew + m.sw * st;
1056
+ wardrobe.push(n("g", "neckwear", {
1057
+ transform: `translate(${m.x} ${f2(m.y + g("my") + bottom + 8)})`,
1058
+ fill: INK.ink
1059
+ }, [
1060
+ n("path", "neckwear.knot", {
1061
+ d: "M-9 0Q-11 0 -9 4L-5 11Q0 15 5 11L9 4Q11 0 9 0Z"
1062
+ }),
1063
+ n("path", "neckwear.blade", {
1064
+ d: "M-4 15L-13 63Q-14 66 -11 69L0 79L11 69Q14 66 13 63L4 15Z"
1065
+ })
1066
+ ]));
1067
+ }
885
1068
  const off = (dx, dy) => riso ? `translate(${f2(dx * k * upp)} ${f2(dy * k * upp)})` : undefined;
886
1069
  const av = n("g", "av", {
887
1070
  transform: `translate(${f2(x)} ${f2(ty)}) rotate(${f2(rot)} 170 ${B}) translate(170 ${B}) scale(${sc(sx)} ${sc(sy)}) translate(-170 ${-B})`,
@@ -894,7 +1077,7 @@ function draw(who, pose, o) {
894
1077
  n("g", "plateB", {
895
1078
  transform: off(0.6, -0.5),
896
1079
  filter: riso ? `url(#${id}-pB)` : undefined
897
- }, [...eyes, ...brows, mouth])
1080
+ }, [...eyes, ...brows, mouth, ...wardrobe])
898
1081
  ]);
899
1082
  const scene = [];
900
1083
  if (o.frame !== "none") {
@@ -960,7 +1143,8 @@ function mulberry32(a) {
960
1143
  var LISTS = {
961
1144
  face: Object.keys(FACES),
962
1145
  color: Object.keys(COLORS),
963
- ...PARTS
1146
+ ...PARTS,
1147
+ ...ACCESSORIES
964
1148
  };
965
1149
  var AXES = Object.keys(LISTS);
966
1150
  function seed(name, axis, version = LATEST) {
@@ -1004,11 +1188,15 @@ function identify(name, { version = LATEST } = {}) {
1004
1188
  mouth: pick("mouth"),
1005
1189
  cheeks: pick("cheeks"),
1006
1190
  trait: pick("trait"),
1191
+ eyewear: pick("eyewear"),
1192
+ headwear: pick("headwear"),
1193
+ neckwear: pick("neckwear"),
1007
1194
  persona
1008
1195
  };
1009
1196
  }
1010
1197
 
1011
1198
  // src/svg.ts
1199
+ var wear = (o, base, s) => ACCESSORIES[s].includes(o[s]) ? o[s] : base[s];
1012
1200
  function settle(name, o, live = false) {
1013
1201
  const base = identify(name, { version: o.version });
1014
1202
  const who = {
@@ -1019,7 +1207,10 @@ function settle(name, o, live = false) {
1019
1207
  brows: o.brows ?? base.brows,
1020
1208
  mouth: o.mouth ?? base.mouth,
1021
1209
  cheeks: o.cheeks ?? base.cheeks,
1022
- trait: o.trait ?? base.trait
1210
+ trait: o.trait ?? base.trait,
1211
+ eyewear: wear(o, base, "eyewear"),
1212
+ headwear: wear(o, base, "headwear"),
1213
+ neckwear: wear(o, base, "neckwear")
1023
1214
  };
1024
1215
  const pose = restPose(who, o.expression ?? "normal", o.gaze);
1025
1216
  const look = {
@@ -1029,7 +1220,9 @@ function settle(name, o, live = false) {
1029
1220
  riso: o.riso ?? false,
1030
1221
  live
1031
1222
  };
1032
- const id = o.id ?? `peek-${fnv1a(JSON.stringify([who, pose, look])).toString(36)}`;
1223
+ const { eyewear, headwear, neckwear, ...legacy } = who;
1224
+ const wardrobe = eyewear !== "none" || headwear !== "none" || neckwear !== "none";
1225
+ const id = o.id ?? `peek-${fnv1a(JSON.stringify([wardrobe ? who : legacy, pose, look])).toString(36)}`;
1033
1226
  return { who, pose, opts: { ...look, id, title: o.title ?? name } };
1034
1227
  }
1035
1228
  var ILLEGAL = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
package/dist/tables.d.ts CHANGED
@@ -218,6 +218,19 @@ export declare const PARTS: {
218
218
  readonly cheeks: readonly ["oval", "dots", "lines"];
219
219
  readonly trait: readonly ["square", "fin", "ring", "dot", "peak"];
220
220
  };
221
+ export declare const ACCESSORY_INK: {
222
+ readonly knot: "#E5412D";
223
+ };
224
+ /** Independent wardrobe slots. peek@1 picks none; callers can dress it. */
225
+ export declare const ACCESSORIES: {
226
+ readonly eyewear: readonly ["none", "glasses", "sunglasses"];
227
+ readonly headwear: readonly ["none", "bow", "cap", "sprout"];
228
+ readonly neckwear: readonly ["none", "tie"];
229
+ };
230
+ export type Eyewear = (typeof ACCESSORIES.eyewear)[number];
231
+ export type Headwear = (typeof ACCESSORIES.headwear)[number];
232
+ export type Neckwear = (typeof ACCESSORIES.neckwear)[number];
233
+ export type AccessorySlot = keyof typeof ACCESSORIES;
221
234
  export type Face = keyof typeof FACES;
222
235
  export type Color = keyof typeof COLORS;
223
236
  export type Part = keyof typeof PARTS;
@@ -226,7 +239,7 @@ export type Brows = (typeof PARTS.brows)[number];
226
239
  export type Mouth = (typeof PARTS.mouth)[number];
227
240
  export type Cheeks = (typeof PARTS.cheeks)[number];
228
241
  export type Trait = (typeof PARTS.trait)[number];
229
- export type Axis = 'face' | 'color' | Part;
242
+ export type Axis = 'face' | 'color' | Part | AccessorySlot;
230
243
  /** How much of each list a version may pick from. Append-only too. */
231
244
  export declare const VERSIONS: Record<number, Record<Axis, number>>;
232
245
  export declare const LATEST = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doan-labs/peek",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -39,6 +39,8 @@
39
39
  "typescript": "^6.0.2"
40
40
  },
41
41
  "description": "Deterministic SVG avatars from a name, with expressions and a React component.",
42
+ "license": "MIT",
43
+ "author": "Doan Labs",
42
44
  "homepage": "https://peek.doan-labs.com",
43
45
  "repository": {
44
46
  "type": "git",
package/src/draw.ts CHANGED
@@ -11,6 +11,7 @@
11
11
 
12
12
  import type { Axes, Persona } from './identity'
13
13
  import {
14
+ ACCESSORY_INK,
14
15
  type Channel,
15
16
  type Channels,
16
17
  COLORS,
@@ -74,6 +75,10 @@ const f3 = (v: number) => String(Math.round(v * 1000) / 1000)
74
75
  const strokeFor = (px: number) =>
75
76
  px >= 96 ? 1 : px >= 56 ? 1.25 : px >= 40 ? 1.6 : px >= 30 ? 2 : 2.4
76
77
  const RAD = Math.PI / 180
78
+ // the sprout, drawn upward from where it is planted
79
+ const STEM = 'M0 0Q3 -19 0 -36'
80
+ const LEAF0 = 'M0 -33C-24 -30 -38 -52 -25 -53C-12 -54 -2 -43 0 -33Z'
81
+ const LEAF1 = 'M0 -33C5 -54 28 -61 27 -48C26 -36 11 -31 0 -33Z'
77
82
 
78
83
  /** Where a face rests: the body offset, the eye line, one altitude unit. */
79
84
  export function frameOf(face: FaceGeom) {
@@ -760,6 +765,221 @@ export function draw(who: Drawable, pose: Pose, o: DrawOptions): Node {
760
765
  }
761
766
  const trait = n('g', 'trait', { transform: traitT }, [shape])
762
767
 
768
+ // Wardrobe lives on the same body transform as the face. Glasses grow
769
+ // around the eye sockets, never around the pupils, so gaze stays free.
770
+ const wardrobe: Node[] = []
771
+ // strokes follow the same ramp as the face, capped so small hats stay hats
772
+ const w = (px: number) => f2(px * Math.min(st, 1.6))
773
+ const line = {
774
+ fill: 'none',
775
+ stroke: INK.ink,
776
+ 'stroke-width': w(5.25),
777
+ 'stroke-linecap': 'round',
778
+ 'stroke-linejoin': 'round',
779
+ }
780
+ if (who.eyewear !== 'none') {
781
+ const lx = sh.eyes[0][0] - spread
782
+ const rr = sh.eyes[1][0] + spread
783
+ const ey = sh.eyes[0][1]
784
+ const gap = rr - lx
785
+ const erx = Math.min(rx + 9, (gap - 15) / 2)
786
+ const ery = ry + 9
787
+ const glasses = who.eyewear === 'glasses'
788
+ const lensX = glasses ? erx : Math.min(rx + 11, (gap - 12) / 2)
789
+ const top = glasses ? ey - 4 : ey - ry - 2
790
+ const frames: Node[] = [
791
+ n('path', 'eyewear.bridge', {
792
+ ...line,
793
+ d: `M${f2(lx + lensX)} ${f2(top)}Q${f2((lx + rr) / 2)} ${f2(top - (glasses ? 13 : 0))} ${f2(rr - lensX)} ${f2(top)}`,
794
+ }),
795
+ ]
796
+ for (const [i, cx] of [lx, rr].entries()) {
797
+ const key = `eyewear.lens${i}`
798
+ frames.push(
799
+ n('path', `${key}.arm`, {
800
+ ...line,
801
+ d:
802
+ i === 0
803
+ ? `M${f2(cx - lensX - 9)} ${f2(top + 2)}L${f2(cx - lensX)} ${f2(top)}`
804
+ : `M${f2(cx + lensX)} ${f2(top)}L${f2(cx + lensX + 9)} ${f2(top + 2)}`,
805
+ }),
806
+ )
807
+ if (glasses) {
808
+ frames.push(
809
+ n('ellipse', key, {
810
+ ...line,
811
+ cx: f2(cx),
812
+ cy: ey,
813
+ rx: f2(erx),
814
+ ry: f2(ery),
815
+ }),
816
+ )
817
+ frames.push(
818
+ n('path', `${key}.glint`, {
819
+ ...line,
820
+ stroke: INK.paper,
821
+ 'stroke-width': w(3),
822
+ d: `M${f2(cx - erx * 0.65)} ${f2(ey - ery * 0.45)}Q${f2(cx - erx * 0.5)} ${f2(ey - ery * 0.8)} ${f2(cx - erx * 0.2)} ${f2(ey - ery * 0.86)}`,
823
+ }),
824
+ )
825
+ } else {
826
+ const h = ry * 2 + 9
827
+ frames.push(
828
+ n('path', key, {
829
+ d: `M${f2(cx - lensX)} ${f2(top)}H${f2(cx + lensX)}V${f2(top + h * 0.55)}Q${f2(cx + lensX)} ${f2(top + h)} ${f2(cx)} ${f2(top + h)}Q${f2(cx - lensX)} ${f2(top + h)} ${f2(cx - lensX)} ${f2(top + h * 0.55)}Z`,
830
+ fill: INK.ink,
831
+ stroke: INK.ink,
832
+ 'stroke-width': w(3),
833
+ 'stroke-linejoin': 'round',
834
+ }),
835
+ )
836
+ frames.push(
837
+ n('path', `${key}.glint`, {
838
+ ...line,
839
+ stroke: INK.paper,
840
+ 'stroke-width': w(4),
841
+ d: `M${f2(cx - lensX * 0.58)} ${f2(top + 19)}l8 -8m-1 15 6 -6`,
842
+ }),
843
+ n('circle', `${key}.pin`, {
844
+ cx: f2(cx + lensX * 0.55),
845
+ cy: f2(top + h - 9),
846
+ r: 1.7,
847
+ fill: INK.paper,
848
+ }),
849
+ )
850
+ }
851
+ }
852
+ wardrobe.push(n('g', 'eyewear', {}, frames))
853
+ }
854
+ if (who.headwear !== 'none') {
855
+ // Reserve the side opposite the crest. The seat stays fixed through
856
+ // expressions, so a moving crest never makes a hat switch sides.
857
+ const side = who.trait === 'ring' || who.trait === 'dot' ? -1 : 1
858
+ const cap = who.headwear === 'cap'
859
+ const sprout = who.headwear === 'sprout'
860
+ // The sheet measures placement across the crown, not along its arc.
861
+ // Solve that horizontal offset so low, round faces keep the brim above
862
+ // their brows instead of walking the hat down the side of the body.
863
+ const inset = sprout ? -6 : cap ? -8 : 0
864
+ const offset = sprout ? 34 : 40
865
+ let lo = 0
866
+ let hi = 160
867
+ for (let i = 0; i < 16; i++) {
868
+ const mid = (lo + hi) / 2
869
+ const p = seat(sh.crown, inset, side * mid)
870
+ if (side * (p.x - 170) < offset) lo = mid
871
+ else hi = mid
872
+ }
873
+ const anchor = seat(sh.crown, inset, (side * (lo + hi)) / 2)
874
+ const angle = clamp(anchor.a, -40, 40)
875
+ const parts: Node[] = []
876
+ if (cap) {
877
+ parts.push(
878
+ n('path', 'headwear.crown', {
879
+ ...line,
880
+ fill: INK.paper,
881
+ d: 'M-33 0C-35 -20 -20 -39 0 -39C20 -39 35 -20 33 0Z',
882
+ }),
883
+ n('path', 'headwear.seam', {
884
+ ...line,
885
+ 'stroke-width': w(3.5),
886
+ d: 'M-4 -38Q9 -24 8 -1',
887
+ }),
888
+ n('path', 'headwear.brim', {
889
+ ...line,
890
+ 'stroke-width': w(7),
891
+ d: side > 0 ? 'M-35 1Q8 4 52 3' : 'M-52 3Q-8 4 35 1',
892
+ }),
893
+ n('circle', 'headwear.button', {
894
+ cx: 0,
895
+ cy: -40,
896
+ r: 3.5,
897
+ fill: INK.ink,
898
+ }),
899
+ )
900
+ } else if (sprout) {
901
+ parts.push(
902
+ // a body-colored halo keeps the ink stem off an ink ground
903
+ n('path', 'headwear.halo', {
904
+ ...line,
905
+ stroke: fill,
906
+ 'stroke-width': w(11),
907
+ d: `${STEM}${LEAF0}${LEAF1}`,
908
+ }),
909
+ n('path', 'headwear.stem', {
910
+ ...line,
911
+ 'stroke-width': w(4.5),
912
+ d: STEM,
913
+ }),
914
+ n('path', 'headwear.leaf0', {
915
+ ...line,
916
+ 'stroke-width': w(4),
917
+ fill: COLORS.mint.body,
918
+ d: LEAF0,
919
+ }),
920
+ n('path', 'headwear.leaf1', {
921
+ ...line,
922
+ 'stroke-width': w(4),
923
+ fill: COLORS.mint.deep,
924
+ d: LEAF1,
925
+ }),
926
+ )
927
+ } else {
928
+ parts.push(
929
+ n('path', 'headwear.wings', {
930
+ d: 'M-3 0C-13 -16 -28 -23 -29 -10C-31 2 -26 19 -16 13L-3 4L3 4L16 13C26 19 31 2 29 -10C28 -23 13 -16 3 0Z',
931
+ fill: INK.ink,
932
+ // the same halo, painted under the fill
933
+ stroke: fill,
934
+ 'stroke-width': w(6),
935
+ 'stroke-linejoin': 'round',
936
+ 'paint-order': 'stroke',
937
+ }),
938
+ n('ellipse', 'headwear.knot', {
939
+ cx: 0,
940
+ cy: 1,
941
+ rx: 6,
942
+ ry: 8,
943
+ fill: ACCESSORY_INK.knot,
944
+ }),
945
+ )
946
+ }
947
+ wardrobe.push(
948
+ n(
949
+ 'g',
950
+ 'headwear',
951
+ {
952
+ transform: `translate(${f2(anchor.x)} ${f2(anchor.y)}) rotate(${f2(sprout ? angle * 0.5 : angle)})`,
953
+ },
954
+ parts,
955
+ ),
956
+ )
957
+ }
958
+ if (who.neckwear === 'tie') {
959
+ // The knot follows the mouth's lowest edge, including its skew. The
960
+ // floor clips the blade, just as it clips the creature itself.
961
+ const skew = Math.abs(Math.sin(g('mk') * RAD)) * mw
962
+ const bottom = Math.max(0, mt * kb, mb * kb) + skew + m.sw * st
963
+ wardrobe.push(
964
+ n(
965
+ 'g',
966
+ 'neckwear',
967
+ {
968
+ transform: `translate(${m.x} ${f2(m.y + g('my') + bottom + 8)})`,
969
+ fill: INK.ink,
970
+ },
971
+ [
972
+ n('path', 'neckwear.knot', {
973
+ d: 'M-9 0Q-11 0 -9 4L-5 11Q0 15 5 11L9 4Q11 0 9 0Z',
974
+ }),
975
+ n('path', 'neckwear.blade', {
976
+ d: 'M-4 15L-13 63Q-14 66 -11 69L0 79L11 69Q14 66 13 63L4 15Z',
977
+ }),
978
+ ],
979
+ ),
980
+ )
981
+ }
982
+
763
983
  // the plates sit a hair out of register at large sizes
764
984
  const off = (dx: number, dy: number) =>
765
985
  riso ? `translate(${f2(dx * k * upp)} ${f2(dy * k * upp)})` : undefined
@@ -787,7 +1007,7 @@ export function draw(who: Drawable, pose: Pose, o: DrawOptions): Node {
787
1007
  transform: off(0.6, -0.5),
788
1008
  filter: riso ? `url(#${id}-pB)` : undefined,
789
1009
  },
790
- [...eyes, ...brows, mouth],
1010
+ [...eyes, ...brows, mouth, ...wardrobe],
791
1011
  ),
792
1012
  ],
793
1013
  )
package/src/identity.ts CHANGED
@@ -9,16 +9,20 @@
9
9
  */
10
10
 
11
11
  import {
12
+ ACCESSORIES,
12
13
  type Axis,
13
14
  type Brows,
14
15
  type Cheeks,
15
16
  COLORS,
16
17
  type Color,
17
18
  type Eyes,
19
+ type Eyewear,
18
20
  FACES,
19
21
  type Face,
22
+ type Headwear,
20
23
  LATEST,
21
24
  type Mouth,
25
+ type Neckwear,
22
26
  PARTS,
23
27
  type Trait,
24
28
  VERSIONS,
@@ -74,6 +78,9 @@ export type Axes = {
74
78
  mouth: Mouth
75
79
  cheeks: Cheeks
76
80
  trait: Trait
81
+ eyewear: Eyewear
82
+ headwear: Headwear
83
+ neckwear: Neckwear
77
84
  }
78
85
 
79
86
  export type Identity = Axes & {
@@ -90,6 +97,7 @@ export const LISTS: { readonly [A in Axis]: readonly Axes[A][] } = {
90
97
  face: Object.keys(FACES) as Face[],
91
98
  color: Object.keys(COLORS) as Color[],
92
99
  ...PARTS,
100
+ ...ACCESSORIES,
93
101
  }
94
102
  /** The axes in readout order. */
95
103
  export const AXES = Object.keys(LISTS) as Axis[]
@@ -142,6 +150,9 @@ export function identify(
142
150
  mouth: pick('mouth'),
143
151
  cheeks: pick('cheeks'),
144
152
  trait: pick('trait'),
153
+ eyewear: pick('eyewear'),
154
+ headwear: pick('headwear'),
155
+ neckwear: pick('neckwear'),
145
156
  persona,
146
157
  }
147
158
  }
package/src/index.ts CHANGED
@@ -1,11 +1,14 @@
1
- export type { Frame, Gaze } from './draw'
1
+ export type { Frame, Gaze, Node } from './draw'
2
+ /** Low level: the drawing tree that toSvg and <Peek> both render. */
3
+ export { draw } from './draw'
2
4
  export type { Axes, Identity, Persona } from './identity'
3
5
  export { AXES, identify, LISTS, tidy } from './identity'
4
6
  export type { PeekProps } from './peek'
5
7
  export { Peek } from './peek'
6
8
  export type { PeekOptions } from './svg'
7
- export { toSvg } from './svg'
9
+ export { settle, toSvg } from './svg'
8
10
  export type {
11
+ AccessorySlot,
9
12
  Axis,
10
13
  Brows,
11
14
  Channel,
@@ -13,11 +16,15 @@ export type {
13
16
  Color,
14
17
  Expression,
15
18
  Eyes,
19
+ Eyewear,
16
20
  Face,
21
+ Headwear,
17
22
  Mouth,
23
+ Neckwear,
18
24
  Trait,
19
25
  } from './tables'
20
26
  export {
27
+ ACCESSORIES,
21
28
  COLORS,
22
29
  EXPRESSIONS,
23
30
  FACES,
package/src/peek.tsx CHANGED
@@ -3,7 +3,7 @@
3
3
  * come from useId, so the server and the client agree.
4
4
  *
5
5
  * A name decides identity unless the caller overrides an axis: pass `face`,
6
- * `color`, `eyes`, `brows`, `mouth`, `cheeks` or `trait` and that axis wins
6
+ * `color`, a part, or a wardrobe slot and that axis wins
7
7
  * over the hash while every other axis stays as the name made it.
8
8
  *
9
9
  * With `animate`, the first paint (server and hydration) is still the static