@pieai/swimmer-avatar-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +23 -0
  3. package/README.md +125 -0
  4. package/UPSTREAM.md +47 -0
  5. package/dist/avatar.d.ts +2 -0
  6. package/dist/avatar.js +69 -0
  7. package/dist/avatar.js.map +1 -0
  8. package/dist/index.d.ts +3 -0
  9. package/dist/index.js +3 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/materials.d.ts +20 -0
  12. package/dist/materials.js +61 -0
  13. package/dist/materials.js.map +1 -0
  14. package/dist/react-three-fiber.d.ts +16 -0
  15. package/dist/react-three-fiber.js +52 -0
  16. package/dist/react-three-fiber.js.map +1 -0
  17. package/dist/recipe.d.ts +18 -0
  18. package/dist/recipe.js +118 -0
  19. package/dist/recipe.js.map +1 -0
  20. package/dist/types.d.ts +62 -0
  21. package/dist/types.js +2 -0
  22. package/dist/types.js.map +1 -0
  23. package/package.json +102 -0
  24. package/upstream-lock.json +38 -0
  25. package/vendor/kindergrimm/LICENSE +24 -0
  26. package/vendor/kindergrimm/src/gloss/catmullClark.js +129 -0
  27. package/vendor/kindergrimm/src/gloss/gface.d.ts +25 -0
  28. package/vendor/kindergrimm/src/gloss/gface.js +226 -0
  29. package/vendor/kindergrimm/src/gloss/gform.js +201 -0
  30. package/vendor/kindergrimm/src/gloss/ghair.js +534 -0
  31. package/vendor/kindergrimm/src/gloss/glayout.js +365 -0
  32. package/vendor/kindergrimm/src/gloss/gmedia.d.ts +28 -0
  33. package/vendor/kindergrimm/src/gloss/gmedia.js +470 -0
  34. package/vendor/kindergrimm/src/gloss/gpalette.d.ts +10 -0
  35. package/vendor/kindergrimm/src/gloss/gpalette.js +205 -0
  36. package/vendor/kindergrimm/src/gloss/gparts/blush.js +57 -0
  37. package/vendor/kindergrimm/src/gloss/gparts/body.js +47 -0
  38. package/vendor/kindergrimm/src/gloss/gparts/brows.js +55 -0
  39. package/vendor/kindergrimm/src/gloss/gparts/crest.js +88 -0
  40. package/vendor/kindergrimm/src/gloss/gparts/eyes.js +357 -0
  41. package/vendor/kindergrimm/src/gloss/gparts/frame.js +225 -0
  42. package/vendor/kindergrimm/src/gloss/gparts/hair.js +100 -0
  43. package/vendor/kindergrimm/src/gloss/gparts/hat.js +163 -0
  44. package/vendor/kindergrimm/src/gloss/gparts/index.js +33 -0
  45. package/vendor/kindergrimm/src/gloss/gparts/mark.js +139 -0
  46. package/vendor/kindergrimm/src/gloss/gparts/mouth.js +170 -0
  47. package/vendor/kindergrimm/src/gloss/gparts/nose.js +63 -0
  48. package/vendor/kindergrimm/src/gloss/gparts/specs.js +124 -0
  49. package/vendor/kindergrimm/src/gloss/grig.d.ts +41 -0
  50. package/vendor/kindergrimm/src/gloss/grig.js +286 -0
  51. package/vendor/kindergrimm/src/gloss/gshape.js +421 -0
  52. package/vendor/kindergrimm/src/gloss/gspecies.d.ts +10 -0
  53. package/vendor/kindergrimm/src/gloss/gspecies.js +338 -0
  54. package/vendor/kindergrimm/src/gloss/gtexture.js +406 -0
  55. package/vendor/kindergrimm/src/rng.d.ts +11 -0
  56. package/vendor/kindergrimm/src/rng.js +27 -0
@@ -0,0 +1,62 @@
1
+ import type { Group, Material, Texture } from "three";
2
+ export interface AvatarPartRecipe {
3
+ params?: unknown | null;
4
+ rr?: number;
5
+ }
6
+ export interface AvatarRecipe {
7
+ seed: number;
8
+ species: string | null;
9
+ body: string | null;
10
+ stance: string | null;
11
+ palette: string | null;
12
+ colorIx: number | null;
13
+ material: string | null;
14
+ parts: Record<string, AvatarPartRecipe>;
15
+ }
16
+ export type AvatarSeed = number | string;
17
+ export type AvatarExpression = "idle" | "happy" | "angry" | "sad" | "surprised";
18
+ export interface AvatarOption {
19
+ id: string;
20
+ label: string;
21
+ }
22
+ export interface AvatarPaletteOption extends AvatarOption {
23
+ colors: readonly string[];
24
+ }
25
+ export type AvatarMaterialFor = (finish: string, color: string, shell?: boolean, print?: {
26
+ key: string;
27
+ tex: Texture;
28
+ } | null) => Material;
29
+ export interface AvatarBuildStats {
30
+ buildMs: number;
31
+ verts: number;
32
+ meshes: number;
33
+ }
34
+ export interface AvatarBounds {
35
+ w: number;
36
+ h: number;
37
+ cy: number;
38
+ minY: number;
39
+ maxY: number;
40
+ }
41
+ export interface AvatarBuildOptions {
42
+ materialFor: AvatarMaterialFor;
43
+ gaze?: boolean;
44
+ }
45
+ export interface AvatarHandle {
46
+ readonly object: Group;
47
+ readonly recipe: AvatarRecipe;
48
+ readonly stats: AvatarBuildStats;
49
+ readonly bounds: AvatarBounds;
50
+ update(deltaSeconds: number): void;
51
+ getExpression(): AvatarExpression;
52
+ setExpression(expression: AvatarExpression): void;
53
+ isDisposed(): boolean;
54
+ dispose(): void;
55
+ }
56
+ export interface AvatarRecipeDocumentV1 {
57
+ schema: "@pieai/swimmer-avatar-kit/recipe";
58
+ version: 1;
59
+ renderer: "gloss";
60
+ recipe: AvatarRecipe;
61
+ }
62
+ export type AvatarRecipeDocument = AvatarRecipeDocumentV1;
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
package/package.json ADDED
@@ -0,0 +1,102 @@
1
+ {
2
+ "name": "@pieai/swimmer-avatar-kit",
3
+ "version": "0.1.0",
4
+ "description": "Shared procedural 3D avatar recipes, Three.js runtime, and React Three Fiber adapter for PieAI products.",
5
+ "keywords": [
6
+ "avatar",
7
+ "procedural-generation",
8
+ "react-three-fiber",
9
+ "swimmer",
10
+ "threejs",
11
+ "typescript"
12
+ ],
13
+ "homepage": "https://github.com/PieAIStudio/SwimmerAvatarKit#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/PieAIStudio/SwimmerAvatarKit/issues"
16
+ },
17
+ "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/PieAIStudio/SwimmerAvatarKit.git"
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "vendor",
25
+ "README.md",
26
+ "NOTICE.md",
27
+ "UPSTREAM.md",
28
+ "upstream-lock.json",
29
+ "LICENSE"
30
+ ],
31
+ "type": "module",
32
+ "sideEffects": false,
33
+ "main": "./dist/index.js",
34
+ "types": "./dist/index.d.ts",
35
+ "exports": {
36
+ ".": {
37
+ "types": "./dist/index.d.ts",
38
+ "import": "./dist/index.js"
39
+ },
40
+ "./materials": {
41
+ "types": "./dist/materials.d.ts",
42
+ "import": "./dist/materials.js"
43
+ },
44
+ "./react-three-fiber": {
45
+ "types": "./dist/react-three-fiber.d.ts",
46
+ "import": "./dist/react-three-fiber.js"
47
+ },
48
+ "./package.json": "./package.json"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public",
52
+ "registry": "https://registry.npmjs.org/"
53
+ },
54
+ "devDependencies": {
55
+ "@pieai/doc-gov": "0.9.3",
56
+ "@pieai/pro-gov": "0.9.3",
57
+ "@react-three/fiber": "9.6.1",
58
+ "@types/node": "24.13.2",
59
+ "@types/react": "19.2.17",
60
+ "@types/three": "0.185.1",
61
+ "lefthook": "2.1.10",
62
+ "oxfmt": "0.58.0",
63
+ "oxlint": "1.73.0",
64
+ "react": "19.2.7",
65
+ "three": "0.185.1",
66
+ "typescript": "6.0.3",
67
+ "vitest": "4.1.10"
68
+ },
69
+ "peerDependencies": {
70
+ "@react-three/fiber": ">=9 <10",
71
+ "react": ">=19 <20",
72
+ "three": ">=0.180.0 <1"
73
+ },
74
+ "peerDependenciesMeta": {
75
+ "@react-three/fiber": {
76
+ "optional": true
77
+ },
78
+ "react": {
79
+ "optional": true
80
+ }
81
+ },
82
+ "engines": {
83
+ "node": ">=24 <25"
84
+ },
85
+ "scripts": {
86
+ "build": "tsc -p tsconfig.build.json",
87
+ "typecheck": "tsc -p tsconfig.json --noEmit",
88
+ "lint": "oxlint src tests scripts -c ./.oxlintrc.json",
89
+ "format": "oxfmt package.json tsconfig.json tsconfig.build.json .oxlintrc.json src tests scripts --write --ignore-path ./.oxfmtignore",
90
+ "format:check": "oxfmt package.json tsconfig.json tsconfig.build.json .oxlintrc.json src tests scripts --check --ignore-path ./.oxfmtignore",
91
+ "test": "vitest run --pool threads",
92
+ "test:package-consumer": "node scripts/test-package-consumer.mjs",
93
+ "host-links:check": "node scripts/verify-host-links.mjs",
94
+ "vendor:verify": "node scripts/verify-vendor.mjs",
95
+ "upstream:check": "node scripts/check-upstream.mjs",
96
+ "verify": "pnpm typecheck && pnpm lint && pnpm format:check && pnpm vendor:verify && pnpm test && pnpm build",
97
+ "verify:release": "pnpm verify && pnpm docs:check && pnpm test:package-consumer",
98
+ "doc-gov": "doc-gov",
99
+ "pro-gov": "pro-gov",
100
+ "docs:check": "pnpm pro-gov doctor --strict-hooks && pnpm host-links:check && pnpm doc-gov router-check && pnpm doc-gov check && pnpm doc-gov scan --check && pnpm doc-gov links && pnpm doc-gov audit && pnpm doc-gov doctor"
101
+ }
102
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "repository": "https://github.com/albertobeiz/kindergrimm.git",
4
+ "defaultBranch": "main",
5
+ "commit": "811214c6dd5de18cc20335cd3d4ab0a06e45ffd4",
6
+ "commitDate": "2026-08-21T12:51:30+02:00",
7
+ "importedAt": "2026-08-22",
8
+ "license": "Unlicense",
9
+ "excludedEntrypoints": ["src/gloss/gcrowd.js", "src/gloss/gloss.js"],
10
+ "files": {
11
+ "LICENSE": "c290d5a44ba844c2aa271e3be836c25176fe1f8b",
12
+ "src/gloss/catmullClark.js": "3fa76213faa27595836365a451218fb650529fc5",
13
+ "src/gloss/gface.js": "f0e90be3039ddd93adb06254943985ee1a37e0d4",
14
+ "src/gloss/gform.js": "c07865a29b7741fa454238a724e330f05f0f0747",
15
+ "src/gloss/ghair.js": "bfb35aa765415f9d2fe4f14b6dd5cf947ecece4b",
16
+ "src/gloss/glayout.js": "21667e34b7f486b89608698abe2527a9dc565c26",
17
+ "src/gloss/gmedia.js": "aef88b617f3f694b1051237055fd5edcd8be546e",
18
+ "src/gloss/gpalette.js": "31d1fd17decc8a8f779aa06288bf2fe057aec9dd",
19
+ "src/gloss/gparts/blush.js": "daed9e66e45b918569490be71392858fa5e502ec",
20
+ "src/gloss/gparts/body.js": "5f15d76362ec47dc24846a3eacb5f5ba6a435484",
21
+ "src/gloss/gparts/brows.js": "d646a477b73de7e7d0fa4443848aff9d5c328f45",
22
+ "src/gloss/gparts/crest.js": "fb5470509ff75ad2d03addc4d9ba02109f7f5521",
23
+ "src/gloss/gparts/eyes.js": "9188e13c0df544620d951ecf5b0dba05b59ab55b",
24
+ "src/gloss/gparts/frame.js": "957727c53a6f9f9c4bd95399378ae3cdaa5a1423",
25
+ "src/gloss/gparts/hair.js": "b962f6fe0e30bbed537054b4fa0d6193052e5b87",
26
+ "src/gloss/gparts/hat.js": "27fbc817ab9797dd706bde93913dccbdfebb2c48",
27
+ "src/gloss/gparts/index.js": "82fc74c3c2cec69e11b28bfebbdee2df60dc3052",
28
+ "src/gloss/gparts/mark.js": "046143fdedab6bc03c0e7495338b342cef5c4fc9",
29
+ "src/gloss/gparts/mouth.js": "1d6cdcb271b002b37469c985517764aed1647091",
30
+ "src/gloss/gparts/nose.js": "82683b6bda2bb2fcae9b833686aaf9a10c039e52",
31
+ "src/gloss/gparts/specs.js": "780de9e2584f68eb34c199756c1fc045187f0159",
32
+ "src/gloss/grig.js": "2bc489a2bf05d9299a56bca808522a9e2f5fdf49",
33
+ "src/gloss/gshape.js": "c44e0b43ddfebef130d7ec8d6be26f4e9b4e7448",
34
+ "src/gloss/gspecies.js": "1805c18339956068354a6c85c1ca93d39e1758f1",
35
+ "src/gloss/gtexture.js": "00a4c3ddc46496357f7232ea6f68ff537a432697",
36
+ "src/rng.js": "5a6e8fe001552cf8ae860d70318e1c85da58baec"
37
+ }
38
+ }
@@ -0,0 +1,24 @@
1
+ This is free and unencumbered software released into the public domain.
2
+
3
+ Anyone is free to copy, modify, publish, use, compile, sell, or
4
+ distribute this software, either in source code form or as a compiled
5
+ binary, for any purpose, commercial or non-commercial, and by any
6
+ means.
7
+
8
+ In jurisdictions that recognize copyright laws, the author or authors
9
+ of this software dedicate any and all copyright interest in the
10
+ software to the public domain. We make this dedication for the benefit
11
+ of the public at large and to the detriment of our heirs and
12
+ successors. We intend this dedication to be an overt act of
13
+ relinquishment in perpetuity of all present and future rights to this
14
+ software under copyright law.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY
20
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
21
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
22
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
23
+
24
+ For more information, please refer to <https://unlicense.org>
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Catmull-Clark subdivision for closed quad/n-gon meshes.
3
+ *
4
+ * three.js used to ship SubdivisionModifier but it was removed, and the
5
+ * community replacement (three-subdivide) is Loop subdivision on triangles,
6
+ * which does not give the same surface. Catmull-Clark on quads is what C4D and
7
+ * Blender do, and it is about 60 lines, so we just do it.
8
+ *
9
+ * Mesh format is plain arrays:
10
+ * verts: [[x, y, z], ...]
11
+ * faces: [[i, j, k, l], ...] (any arity, wound consistently)
12
+ */
13
+
14
+ function addTo(out, v) {
15
+ out[0] += v[0];
16
+ out[1] += v[1];
17
+ out[2] += v[2];
18
+ }
19
+
20
+ function scale(v, s) {
21
+ return [v[0] * s, v[1] * s, v[2] * s];
22
+ }
23
+
24
+ export function subdivide({ verts, faces }) {
25
+ const nv = verts.length;
26
+
27
+ // 1. face points: the centroid of each face
28
+ const facePoints = faces.map((f) => {
29
+ const c = [0, 0, 0];
30
+ for (const vi of f) addTo(c, verts[vi]);
31
+ return scale(c, 1 / f.length);
32
+ });
33
+
34
+ // 2. edges. Every edge in a closed mesh is shared by exactly two faces.
35
+ const edges = new Map(); // "a_b" (a<b) -> { a, b, faces: [] }
36
+ const edgeKey = (a, b) => (a < b ? `${a}_${b}` : `${b}_${a}`);
37
+ faces.forEach((f, fi) => {
38
+ for (let i = 0; i < f.length; i++) {
39
+ const a = f[i];
40
+ const b = f[(i + 1) % f.length];
41
+ const key = edgeKey(a, b);
42
+ let e = edges.get(key);
43
+ if (!e) edges.set(key, (e = { a: Math.min(a, b), b: Math.max(a, b), faces: [] }));
44
+ e.faces.push(fi);
45
+ }
46
+ });
47
+
48
+ // 3. edge points: average of the two endpoints and the two face points.
49
+ // A border edge (one face) falls back to the midpoint.
50
+ const edgePointIndex = new Map();
51
+ const edgePoints = [];
52
+ for (const [key, e] of edges) {
53
+ const p = [0, 0, 0];
54
+ addTo(p, verts[e.a]);
55
+ addTo(p, verts[e.b]);
56
+ if (e.faces.length === 2) {
57
+ addTo(p, facePoints[e.faces[0]]);
58
+ addTo(p, facePoints[e.faces[1]]);
59
+ edgePointIndex.set(key, nv + faces.length + edgePoints.length);
60
+ edgePoints.push(scale(p, 0.25));
61
+ } else {
62
+ edgePointIndex.set(key, nv + faces.length + edgePoints.length);
63
+ edgePoints.push(scale(p, 0.5));
64
+ }
65
+ }
66
+
67
+ // 4. move the original vertices: (F + 2R + (n-3)P) / n
68
+ const F = Array.from({ length: nv }, () => [0, 0, 0]);
69
+ const R = Array.from({ length: nv }, () => [0, 0, 0]);
70
+ const faceValence = new Array(nv).fill(0);
71
+ const edgeValence = new Array(nv).fill(0);
72
+
73
+ faces.forEach((f, fi) => {
74
+ for (const vi of f) {
75
+ addTo(F[vi], facePoints[fi]);
76
+ faceValence[vi]++;
77
+ }
78
+ });
79
+ for (const e of edges.values()) {
80
+ const mid = scale([
81
+ verts[e.a][0] + verts[e.b][0],
82
+ verts[e.a][1] + verts[e.b][1],
83
+ verts[e.a][2] + verts[e.b][2],
84
+ ], 0.5);
85
+ addTo(R[e.a], mid);
86
+ edgeValence[e.a]++;
87
+ addTo(R[e.b], mid);
88
+ edgeValence[e.b]++;
89
+ }
90
+
91
+ const newVerts = new Array(nv);
92
+ for (let i = 0; i < nv; i++) {
93
+ const n = faceValence[i];
94
+ const f = scale(F[i], 1 / n);
95
+ const r = scale(R[i], 1 / edgeValence[i]);
96
+ const p = verts[i];
97
+ newVerts[i] = [
98
+ (f[0] + 2 * r[0] + (n - 3) * p[0]) / n,
99
+ (f[1] + 2 * r[1] + (n - 3) * p[1]) / n,
100
+ (f[2] + 2 * r[2] + (n - 3) * p[2]) / n,
101
+ ];
102
+ }
103
+
104
+ // 5. each n-gon becomes n quads
105
+ const outVerts = newVerts.concat(facePoints, edgePoints);
106
+ const outFaces = [];
107
+ faces.forEach((f, fi) => {
108
+ const fp = nv + fi;
109
+ for (let i = 0; i < f.length; i++) {
110
+ const prev = f[(i - 1 + f.length) % f.length];
111
+ const cur = f[i];
112
+ const next = f[(i + 1) % f.length];
113
+ outFaces.push([
114
+ cur,
115
+ edgePointIndex.get(edgeKey(cur, next)),
116
+ fp,
117
+ edgePointIndex.get(edgeKey(prev, cur)),
118
+ ]);
119
+ }
120
+ });
121
+
122
+ return { verts: outVerts, faces: outFaces };
123
+ }
124
+
125
+ export function subdivideN(mesh, times) {
126
+ let m = mesh;
127
+ for (let i = 0; i < times; i++) m = subdivide(m);
128
+ return m;
129
+ }
@@ -0,0 +1,25 @@
1
+ import type { GlossBuilt } from "./grig.js";
2
+
3
+ export type GlossExpression = "idle" | "happy" | "angry" | "sad" | "surprised";
4
+
5
+ export const GLOSS_FACES: readonly GlossExpression[];
6
+
7
+ export interface GlossHeadOffset {
8
+ x: number;
9
+ y: number;
10
+ yaw: number;
11
+ pitch: number;
12
+ rot: number;
13
+ }
14
+
15
+ export interface GlossLife {
16
+ head: GlossHeadOffset;
17
+ face: () => GlossExpression;
18
+ setFace: (id: GlossExpression) => void;
19
+ update: (elapsedSeconds: number, deltaSeconds: number) => GlossHeadOffset;
20
+ }
21
+
22
+ export function createGlossFace(
23
+ built: GlossBuilt,
24
+ options?: { gaze?: boolean },
25
+ ): GlossLife;
@@ -0,0 +1,226 @@
1
+ // ---------------------------------------------------------------
2
+ // THE FACE LIFE — the autonomic half of `anim.js`, ported to a character
3
+ // that has no bones.
4
+ //
5
+ // The drawn rig gets its life from blink, gaze, sway and breath. Only
6
+ // one of those needs a skeleton, so the other three come over intact,
7
+ // and gaze is the one that matters:
8
+ //
9
+ // SOMETHING CATCHES THE EYE, gets looked at, and is let go. The eyes
10
+ // move FIRST and FAST — a saccade is ballistic, it lands and stops —
11
+ // and the head follows late, slower, and only some of the time,
12
+ // because most real glances are eyes-only. Two separate springs,
13
+ // both critically damped: an under-damped head was tried (the
14
+ // cartoon whip-and-overshoot) and it read as bouncing, not looking.
15
+ // The life is in the eyes leading, not in the head ringing.
16
+ //
17
+ // Here the character IS the head, so the head spring drives the whole body —
18
+ // and because every feature is its own mesh (see `gshape.js`), the
19
+ // eyes leading the head is two translations, not a rebuild.
20
+ //
21
+ // EVERYTHING WRITTEN HERE IS AN OFFSET FROM REST. Each frame the
22
+ // features are put back where the rig left them and the offsets are
23
+ // re-applied, so expressions blend and nothing accumulates drift —
24
+ // the same rule the drawn poses follow.
25
+ // ---------------------------------------------------------------
26
+
27
+ import { Vector3 } from 'three';
28
+
29
+ // the ball-eye lid's hinge. WORLD x, not the lid's own: a wide-set
30
+ // eye's normal tips ~30° off the camera, and a lid rolled about its
31
+ // own tilted axis closes off-camera and leaves a rim of white showing
32
+ const X_AXIS = new Vector3(1, 0, 0);
33
+
34
+ const DIRS = ['left', 'right', 'up', 'down'];
35
+ const OPPOSITE = { left: 'right', right: 'left', up: 'down', down: 'up' };
36
+
37
+ // Expressions are OFFSETS, not drawings. A gloss face cannot swap a
38
+ // mouth without rebuilding it, but it can raise a brow, narrow an eye
39
+ // and open a mouth — which turns out to be most of what an expression
40
+ // is anyway.
41
+ const FACES = {
42
+ idle: {},
43
+ happy: { eyeSY: .78, browLift: .16, mouthSY: 1.14 },
44
+ angry: { eyeSY: .88, browLift: -.26, browRoll: -.55 },
45
+ sad: { eyeSY: .94, browLift: .12, browRoll: .48, mouthSY: .9 },
46
+ surprised: { eyeS: 1.13, browLift: .36, mouthSY: 1.3 },
47
+ };
48
+ export const GLOSS_FACES = Object.keys(FACES);
49
+
50
+ const isEye = id => id.startsWith('eye');
51
+ const isBrow = id => id.startsWith('brow');
52
+
53
+ /**
54
+ * built → an animator that owns every write to the face meshes.
55
+ * `update(t, dt)` then leaves the head's offset on `.head`, which the
56
+ * page adds to whatever else it is doing with that character.
57
+ */
58
+ export function createGlossFace(built, opts = {}) {
59
+ const u = built.L.s; // one unit: the body radius
60
+ const rest = new Map();
61
+ for (const [id, m] of Object.entries(built.face)) {
62
+ rest.set(id, {
63
+ pos: m.position.clone(), quat: m.quaternion.clone(),
64
+ scale: m.scale.clone(), side: id.endsWith('L') || id.includes('L') ? -1 : 1,
65
+ });
66
+ }
67
+
68
+ let nextBlink = 1 + Math.random() * 3, blinkT = -1;
69
+ let dir = null, until = 0, next = 1 + Math.random() * 4, queue = [];
70
+ let ex = 0, ey = 0, evx = 0, evy = 0; // the eyes — a saccade
71
+ let gx = 0, gy = 0, gvx = 0, gvy = 0; // the head — settles, never rings
72
+ let headFollow = 1; // does THIS glance turn the head?
73
+ let cur = 'idle', target = 'idle', w = 1;
74
+
75
+ const api = {
76
+ // x/y in the CHARACTER's own units (a page at cell scale must multiply),
77
+ // yaw/pitch/rot in radians
78
+ head: { x: 0, y: 0, yaw: 0, pitch: 0, rot: 0 },
79
+ face: () => target,
80
+ setFace(id) {
81
+ if (!FACES[id] || id === target) return;
82
+ cur = target; target = id; w = 0;
83
+ // pull a blink forward: a face that changes behind shut lids
84
+ // reads as a decision, and one that changes in the open reads
85
+ // as a glitch
86
+ if (blinkT <= 0) { blinkT = .13; nextBlink = 1.2 + Math.random() * 3; }
87
+ },
88
+
89
+ update(t, dt) {
90
+ // ---- blink ----
91
+ if (blinkT > 0) blinkT -= dt;
92
+ else if (t > nextBlink) { blinkT = .13; nextBlink = t + 1.4 + Math.random() * 3.4; }
93
+ const shut = blinkT > 0 ? Math.max(.06, 1 - Math.sin((1 - blinkT / .13) * Math.PI)) : 1;
94
+
95
+ // ---- gaze: caught, held, let go ----
96
+ if (opts.gaze !== false) {
97
+ if (!dir && t > next) {
98
+ const d = DIRS[(Math.random() * 4) | 0];
99
+ // a third of the time it looks back the other way, which is
100
+ // what stops the sheet reading as everyone scanning a room
101
+ queue = Math.random() < .3 ? [d, OPPOSITE[d]] : [d];
102
+ dir = queue.shift();
103
+ // most real glances are eyes-only — the head stays put and
104
+ // only the eyes dart. The head joining in is the exception,
105
+ // and an eyes-only glance is quicker: a dart, not a stare
106
+ headFollow = Math.random() < .45 ? 1 : 0;
107
+ until = t + (headFollow ? .5 + Math.random() * 1.4
108
+ : .3 + Math.random() * .8);
109
+ } else if (dir && t > until) {
110
+ dir = queue.shift() ?? null;
111
+ if (dir) until = t + .4 + Math.random() * 1.1;
112
+ else next = t + 1.4 + Math.random() * 4;
113
+ }
114
+ }
115
+ const tx = dir === 'left' ? -1 : dir === 'right' ? 1 : 0;
116
+ const ty = dir === 'up' ? 1 : dir === 'down' ? -1 : 0;
117
+ // Both springs are stepped with the EXACT critically-damped
118
+ // solution, not Euler: the eye spring is stiff (ω·dt > 1 on any
119
+ // long frame, and building a character costs ~20ms), and an Euler step
120
+ // there flips the damping term's sign and flings every eye into
121
+ // a corner. The closed form cannot blow up at any dt.
122
+ const step = (x, v, tgt, om, h) => {
123
+ const dx = x - tgt, e = Math.exp(-om * h), b = v + om * dx;
124
+ return [tgt + (dx + b * h) * e, (v - om * b * h) * e];
125
+ };
126
+ // the EYES: fast — a saccade is ballistic, it lands and stops,
127
+ // it does not ease and it does not ring
128
+ const eo = 24;
129
+ [ex, evx] = step(ex, evx, tx, eo, dt);
130
+ [ey, evy] = step(ey, evy, ty, eo, dt);
131
+ // the HEAD: slower, chasing the same target only when this
132
+ // glance turns it — so the eyes visibly arrive first and the
133
+ // head settles after them without bouncing
134
+ const ho = 6;
135
+ [gx, gvx] = step(gx, gvx, tx * headFollow, ho, dt);
136
+ [gy, gvy] = step(gy, gvy, ty * headFollow, ho, dt);
137
+
138
+ // ---- expression blend ----
139
+ if (w < 1) w = Math.min(1, w + dt * 4);
140
+ const A = FACES[cur], B = FACES[target];
141
+ const mix = (key, d = 0) => (A[key] ?? d) * (1 - w) + (B[key] ?? d) * w;
142
+ const eyeSY = mix('eyeSY', 1), eyeS = mix('eyeS', 1);
143
+ const browLift = mix('browLift'), browRoll = mix('browRoll');
144
+ const mouthSY = mix('mouthSY', 1);
145
+
146
+ // ---- write ----
147
+ for (const [id, m] of Object.entries(built.face)) {
148
+ const r = rest.get(id);
149
+ m.position.copy(r.pos);
150
+ m.quaternion.copy(r.quat);
151
+ m.scale.copy(r.scale);
152
+
153
+ if (isEye(id)) {
154
+ // A feature carrying its own budget uses it: that is a PUPIL
155
+ // crossing its white, or the white itself barely stirring.
156
+ // Anything else is a whole eye, and it has to travel far
157
+ // enough to see — an offset you must look for is the same as
158
+ // no offset at all.
159
+ //
160
+ // The budget is scaled by `shut` so a pupil is back at centre
161
+ // by the time the lid closes over it: the white squashes
162
+ // about the eye's middle and a pupil left out at the edge
163
+ // would be squashed about somewhere else and slide clear.
164
+ const tv = m.userData.travel;
165
+ if (tv) { m.translateX(ex * tv[0] * shut); m.translateY(ey * tv[1] * shut); }
166
+ else { m.translateX(ex * u * .17); m.translateY(ey * u * .13); }
167
+
168
+ // A parked pupil comes back to the middle as the lid closes.
169
+ // The white squashes about the EYE's centre; a pupil resting
170
+ // up at the top would squash about its OWN and slide clear.
171
+ const park = m.userData.anchorY;
172
+ if (park) m.translateY(-park * (1 - shut));
173
+
174
+ // A LID does not squash, it COMES DOWN. Scaling it with the
175
+ // eye would just shrink it out of the way, which is the one
176
+ // thing a closing lid must not do.
177
+ const drop = m.userData.lidDrop;
178
+ if (drop) { m.translateY(-(1 - shut) * drop); continue; }
179
+
180
+ // A BALL eye's lid does not slide either, it ROLLS: the cap
181
+ // is centred on the ball, so pitching it forward wraps it
182
+ // over the front the way a real lid wraps an eyeball. A
183
+ // translate here would slide the cap off its own sphere.
184
+ const roll = m.userData.lidRoll;
185
+ if (roll) { m.rotateOnWorldAxis(X_AXIS, roll * (1 - shut)); continue; }
186
+
187
+ m.scale.set(r.scale.x * eyeS, r.scale.y * eyeS * eyeSY * (m.userData.shut ? 1 : shut), r.scale.z);
188
+ } else if (isBrow(id)) {
189
+ // brows belong to the eyes, not the skull — they ride the
190
+ // saccade, so an eyes-only glance still moves them
191
+ m.translateX(ex * u * .13);
192
+ m.translateY(ey * u * .12 + browLift * u * .11);
193
+ m.rotateZ(browRoll * r.side);
194
+ } else if (id.startsWith('mouth')) {
195
+ // ALL the mouth's plates: a maw is an outline, an interior,
196
+ // teeth and a tongue, and an expression that opened only the
197
+ // outline would leave the furniture poking through the lip
198
+ m.translateX(gx * u * .07);
199
+ m.translateY(gy * u * .04);
200
+ m.scale.set(r.scale.x, r.scale.y * mouthSY, r.scale.z);
201
+ } else if (id.startsWith('hair') || id.startsWith('hat')) {
202
+ // HAIR AND HATS DO NOT RIDE. Every other feature slides a
203
+ // little with the gaze because it is painted on a face that
204
+ // is turning. These are not on the face — the group's own
205
+ // spring already carries them, and a second offset on top
206
+ // slides the cut off the skull and the hat off the head.
207
+ continue;
208
+ } else {
209
+ m.translateX(gx * u * .09); // nose, cheeks: they ride
210
+ m.translateY(gy * u * .07);
211
+ }
212
+ }
213
+
214
+ // What the head does about it. The TURN is what sells a look —
215
+ // a head that only slides is a head on a rail — so the yaw and
216
+ // pitch carry it and the shift is the follow-through.
217
+ api.head.x = gx * u * .16;
218
+ api.head.y = gy * u * .11;
219
+ api.head.yaw = gx * .34;
220
+ api.head.pitch = -gy * .22;
221
+ api.head.rot = -gx * .07;
222
+ return api.head;
223
+ },
224
+ };
225
+ return api;
226
+ }