wobnimo 0.1.3 → 0.2.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 (71) hide show
  1. package/AGENTS.md +18 -4
  2. package/README.md +38 -7
  3. package/dist/characters/bao.d.ts +6 -0
  4. package/dist/characters/bao.js +1 -0
  5. package/dist/characters/berry.d.ts +6 -0
  6. package/dist/characters/berry.js +1 -0
  7. package/dist/characters/boo.js +1 -1
  8. package/dist/characters/bumble.d.ts +6 -0
  9. package/dist/characters/bumble.js +1 -0
  10. package/dist/characters/cacti.d.ts +6 -0
  11. package/dist/characters/cacti.js +1 -0
  12. package/dist/characters/cap.d.ts +6 -0
  13. package/dist/characters/cap.js +1 -0
  14. package/dist/characters/drift.d.ts +6 -0
  15. package/dist/characters/drift.js +1 -0
  16. package/dist/characters/ember.d.ts +6 -0
  17. package/dist/characters/ember.js +1 -0
  18. package/dist/characters/hop.d.ts +6 -0
  19. package/dist/characters/hop.js +1 -0
  20. package/dist/characters/index.d.ts +11 -1
  21. package/dist/characters/index.js +11 -11
  22. package/dist/characters/inky.js +1 -1
  23. package/dist/characters/latte.d.ts +6 -0
  24. package/dist/characters/latte.js +1 -0
  25. package/dist/characters/microduck.js +1 -1
  26. package/dist/characters/miso.js +1 -1
  27. package/dist/characters/mochi.js +1 -1
  28. package/dist/characters/pebble.js +1 -1
  29. package/dist/characters/pip.js +1 -1
  30. package/dist/characters/rex.js +1 -1
  31. package/dist/characters/swirl.d.ts +6 -0
  32. package/dist/characters/swirl.js +1 -0
  33. package/dist/characters/tad.js +1 -1
  34. package/dist/characters/volt.js +1 -1
  35. package/dist/cli.js +1888 -0
  36. package/dist/element.d.ts +20 -2
  37. package/dist/element.js +11 -11
  38. package/dist/index-Cm5wWKoT.d.ts +12 -0
  39. package/dist/index.d.ts +7 -2
  40. package/dist/index.js +6 -6
  41. package/dist/{status-DVudJbtp.d.ts → mascot-DZ91_ss-.d.ts} +1 -10
  42. package/dist/react.d.ts +2 -1
  43. package/dist/react.js +16 -16
  44. package/dist/status-C14-19fT.d.ts +12 -0
  45. package/dist/wobnimo.js +15 -15
  46. package/package.json +30 -4
  47. package/skill/SKILL.md +719 -0
  48. package/skill/examples/bao.ts +73 -0
  49. package/skill/examples/berry.ts +97 -0
  50. package/skill/examples/boo.ts +87 -0
  51. package/skill/examples/bumble.ts +70 -0
  52. package/skill/examples/cacti.ts +79 -0
  53. package/skill/examples/cap.ts +59 -0
  54. package/skill/examples/drift.ts +74 -0
  55. package/skill/examples/ember.ts +78 -0
  56. package/skill/examples/hop.ts +66 -0
  57. package/skill/examples/inky.ts +168 -0
  58. package/skill/examples/latte.ts +73 -0
  59. package/skill/examples/microduck.ts +222 -0
  60. package/skill/examples/miso.ts +182 -0
  61. package/skill/examples/mochi.ts +78 -0
  62. package/skill/examples/pebble.ts +174 -0
  63. package/skill/examples/pip.ts +170 -0
  64. package/skill/examples/rex.ts +193 -0
  65. package/skill/examples/swirl.ts +69 -0
  66. package/skill/examples/tad.ts +117 -0
  67. package/skill/examples/volt.ts +214 -0
  68. package/skill/reference/recipes.md +551 -0
  69. package/skill/reference/spec.md +258 -0
  70. package/skill/templates/starter.ts +73 -0
  71. package/dist/element-DRDEWSGV.d.ts +0 -8
package/dist/cli.js ADDED
@@ -0,0 +1,1888 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync, spawn } from 'child_process';
3
+ import { readFileSync, existsSync, mkdirSync, cpSync, statSync, watch, writeFileSync, mkdtempSync, rmSync, readdirSync } from 'fs';
4
+ import { createServer } from 'http';
5
+ import { resolve as resolve$1, dirname, join, relative, basename, extname, delimiter } from 'path';
6
+ import { fileURLToPath, pathToFileURL } from 'url';
7
+ import { tmpdir, homedir } from 'os';
8
+
9
+ // ../engine/src/behavior/states.ts
10
+ var STATES = ["idle", "thinking", "listening", "speaking", "sleeping", "working", "error", "surprised", "celebrating", "sad"];
11
+ var EXPRESSIONS = [...STATES, "happy"];
12
+ Object.fromEntries(EXPRESSIONS.map((e, i) => [e, i]));
13
+
14
+ // ../engine/src/compiler/resolve.ts
15
+ var SpecError = class extends Error {
16
+ constructor(character, message) {
17
+ super(`${character}: ${message}`);
18
+ this.name = "SpecError";
19
+ }
20
+ };
21
+ function resolve(spec) {
22
+ const fail2 = (msg) => {
23
+ throw new SpecError(spec.id, msg);
24
+ };
25
+ const bones = [{ name: "root", pivot: [0, 0, 0], index: 0, parentIndex: -1 }];
26
+ const byName = /* @__PURE__ */ new Map([["root", 0]]);
27
+ spec.bones.forEach((b, i) => {
28
+ if (byName.has(b.name)) fail2(`bone "${b.name}" is declared twice`);
29
+ const parent = b.parent ?? "root";
30
+ const parentIndex = byName.get(parent);
31
+ if (parentIndex === void 0) fail2(`bone "${b.name}" names parent "${parent}", which is not declared before it`);
32
+ byName.set(b.name, i + 1);
33
+ bones.push({ ...b, index: i + 1, parentIndex });
34
+ });
35
+ const materials = Object.entries(spec.materials).map(([name, m], i) => ({ name, id: i + 1, spec: m }));
36
+ const matByName = new Map(materials.map((m) => [m.name, m.id]));
37
+ const eyeIds = [materials.length + 1, materials.length + 2];
38
+ const boneIndex = (name, where) => byName.get(name) ?? fail2(`${where} names bone "${name}", which does not exist`);
39
+ const materialId = (name, where) => matByName.get(name) ?? fail2(`${where} names material "${name}", which does not exist`);
40
+ materials.forEach((m) => {
41
+ if (m.spec.shadeOf !== void 0) materialId(m.spec.shadeOf, `materials.${m.name}.shadeOf`);
42
+ });
43
+ spec.parts.forEach((p, i) => {
44
+ boneIndex(p.bone, `parts[${i}]`);
45
+ materialId(p.material, `parts[${i}]`);
46
+ });
47
+ spec.paints?.forEach((p, i) => {
48
+ boneIndex(p.bone, `paints[${i}]`);
49
+ p.on.forEach((m) => materialId(m, `paints[${i}]`));
50
+ });
51
+ const acting = bones.map(() => []);
52
+ for (const [expression, perBone] of Object.entries(spec.acting ?? {})) {
53
+ for (const [name, axes] of Object.entries(perBone)) {
54
+ const i = boneIndex(name, `acting.${expression}`);
55
+ for (const [axis, motion] of Object.entries(axes)) acting[i].push([expression, axis, motion]);
56
+ }
57
+ }
58
+ const anchors = {};
59
+ for (const [name, a] of Object.entries(spec.anchors ?? {})) anchors[name] = { bone: boneIndex(a.bone, `anchors.${name}`), at: a.at };
60
+ for (const [trigger, emitters] of Object.entries(spec.fx ?? {})) {
61
+ if (trigger === "replace") continue;
62
+ emitters.forEach((e, i) => {
63
+ if (!anchors[e.anchor]) fail2(`fx.${trigger}[${i}] names anchor "${e.anchor}", which does not exist`);
64
+ });
65
+ }
66
+ return { spec, bones, boneIndex, materials, materialId, eyeIds, acting, anchors };
67
+ }
68
+
69
+ // ../engine/src/math/scalar.ts
70
+ var clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
71
+ function rng(seed) {
72
+ let a = Math.floor(seed * 4294967295) >>> 0 || 1;
73
+ return () => {
74
+ a = a + 1831565813 >>> 0;
75
+ let t = a;
76
+ t = Math.imul(t ^ t >>> 15, t | 1);
77
+ t ^= t + Math.imul(t ^ t >>> 7, t | 61);
78
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
79
+ };
80
+ }
81
+
82
+ // ../engine/src/math/mat4.ts
83
+ var mat4 = () => identity(new Float32Array(16));
84
+ function identity(out) {
85
+ out.fill(0);
86
+ out[0] = out[5] = out[10] = out[15] = 1;
87
+ return out;
88
+ }
89
+ function multiply(out, a, b) {
90
+ const r = new Float32Array(16);
91
+ for (let c = 0; c < 4; c++) {
92
+ for (let row = 0; row < 4; row++) {
93
+ r[c * 4 + row] = a[row] * b[c * 4] + a[4 + row] * b[c * 4 + 1] + a[8 + row] * b[c * 4 + 2] + a[12 + row] * b[c * 4 + 3];
94
+ }
95
+ }
96
+ out.set(r);
97
+ return out;
98
+ }
99
+ function fromTranslation(out, x, y, z) {
100
+ identity(out);
101
+ out[12] = x;
102
+ out[13] = y;
103
+ out[14] = z;
104
+ return out;
105
+ }
106
+ function fromEuler(out, rx, ry, rz) {
107
+ const cx = Math.cos(rx), sx = Math.sin(rx);
108
+ const cy = Math.cos(ry), sy = Math.sin(ry);
109
+ const cz = Math.cos(rz), sz = Math.sin(rz);
110
+ identity(out);
111
+ out[0] = cz * cy;
112
+ out[1] = sz * cy;
113
+ out[2] = -sy;
114
+ out[4] = cz * sy * sx - sz * cx;
115
+ out[5] = sz * sy * sx + cz * cx;
116
+ out[6] = cy * sx;
117
+ out[8] = cz * sy * cx + sz * sx;
118
+ out[9] = sz * sy * cx - cz * sx;
119
+ out[10] = cy * cx;
120
+ return out;
121
+ }
122
+ function fromAxisAngle(out, axis, angle) {
123
+ const [x, y, z] = axis;
124
+ const c = Math.cos(angle), s = Math.sin(angle), t = 1 - c;
125
+ identity(out);
126
+ out[0] = t * x * x + c;
127
+ out[1] = t * x * y + s * z;
128
+ out[2] = t * x * z - s * y;
129
+ out[4] = t * x * y - s * z;
130
+ out[5] = t * y * y + c;
131
+ out[6] = t * y * z + s * x;
132
+ out[8] = t * x * z + s * y;
133
+ out[9] = t * y * z - s * x;
134
+ out[10] = t * z * z + c;
135
+ return out;
136
+ }
137
+ function invert(out, m) {
138
+ const [a00, a01, a02, a03, a10, a11, a12, a13, a20, a21, a22, a23, a30, a31, a32, a33] = m;
139
+ const b00 = a00 * a11 - a01 * a10, b01 = a00 * a12 - a02 * a10, b02 = a00 * a13 - a03 * a10;
140
+ const b03 = a01 * a12 - a02 * a11, b04 = a01 * a13 - a03 * a11, b05 = a02 * a13 - a03 * a12;
141
+ const b06 = a20 * a31 - a21 * a30, b07 = a20 * a32 - a22 * a30, b08 = a20 * a33 - a23 * a30;
142
+ const b09 = a21 * a32 - a22 * a31, b10 = a21 * a33 - a23 * a31, b11 = a22 * a33 - a23 * a32;
143
+ const det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06;
144
+ if (!det) return identity(out);
145
+ const d = 1 / det;
146
+ out[0] = (a11 * b11 - a12 * b10 + a13 * b09) * d;
147
+ out[1] = (a02 * b10 - a01 * b11 - a03 * b09) * d;
148
+ out[2] = (a31 * b05 - a32 * b04 + a33 * b03) * d;
149
+ out[3] = (a22 * b04 - a21 * b05 - a23 * b03) * d;
150
+ out[4] = (a12 * b08 - a10 * b11 - a13 * b07) * d;
151
+ out[5] = (a00 * b11 - a02 * b08 + a03 * b07) * d;
152
+ out[6] = (a32 * b02 - a30 * b05 - a33 * b01) * d;
153
+ out[7] = (a20 * b05 - a22 * b02 + a23 * b01) * d;
154
+ out[8] = (a10 * b10 - a11 * b08 + a13 * b06) * d;
155
+ out[9] = (a01 * b08 - a00 * b10 - a03 * b06) * d;
156
+ out[10] = (a30 * b04 - a31 * b02 + a33 * b00) * d;
157
+ out[11] = (a21 * b02 - a20 * b04 - a23 * b00) * d;
158
+ out[12] = (a11 * b07 - a10 * b09 - a12 * b06) * d;
159
+ out[13] = (a00 * b09 - a01 * b07 + a02 * b06) * d;
160
+ out[14] = (a31 * b01 - a30 * b03 - a32 * b00) * d;
161
+ out[15] = (a20 * b03 - a21 * b01 + a22 * b00) * d;
162
+ return out;
163
+ }
164
+ function transformPoint(m, p) {
165
+ const [x, y, z] = p;
166
+ return [m[0] * x + m[4] * y + m[8] * z + m[12], m[1] * x + m[5] * y + m[9] * z + m[13], m[2] * x + m[6] * y + m[10] * z + m[14]];
167
+ }
168
+ function transformDir(m, d) {
169
+ const [x, y, z] = d;
170
+ return [m[0] * x + m[4] * y + m[8] * z, m[1] * x + m[5] * y + m[9] * z, m[2] * x + m[6] * y + m[10] * z];
171
+ }
172
+
173
+ // ../engine/src/math/vec3.ts
174
+ var add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
175
+ var sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
176
+ var scale = (a, s) => [a[0] * s, a[1] * s, a[2] * s];
177
+ var dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
178
+ var cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
179
+ var length = (a) => Math.hypot(a[0], a[1], a[2]);
180
+ function normalize(a) {
181
+ const l = length(a) || 1;
182
+ return [a[0] / l, a[1] / l, a[2] / l];
183
+ }
184
+
185
+ // ../engine/src/rig/rig.ts
186
+ var AXES = ["rx", "ry", "rz"];
187
+ function evaluate(m, time) {
188
+ if (typeof m === "number") return m;
189
+ const [base, amp = 0, hz = 0, phase = 0] = m;
190
+ return base + amp * Math.sin(time * hz * Math.PI * 2 + phase);
191
+ }
192
+ var Rig = class {
193
+ constructor(bones, acting = bones.map(() => [])) {
194
+ this.bones = bones;
195
+ this.acting = acting;
196
+ this.world = bones.map(() => mat4());
197
+ this.inverse = new Float32Array(bones.length * 16);
198
+ this.tips = bones.map(() => null);
199
+ this.spun = bones.map(() => ({ rx: 0, ry: 0, rz: 0 }));
200
+ }
201
+ bones;
202
+ acting;
203
+ world;
204
+ /** World → bone, packed for upload: 16 floats per bone. */
205
+ inverse;
206
+ tips;
207
+ /** Spin accumulated by acting, per bone and axis. */
208
+ spun;
209
+ scratch = { t: mat4(), r: mat4(), j: mat4(), inv: mat4() };
210
+ /** A point in a bone's space, in world space, as of the last update. */
211
+ point(bone, at) {
212
+ return transformPoint(this.world[bone], at);
213
+ }
214
+ /**
215
+ * Poses the skeleton. `root` places the whole character, `weights` blends
216
+ * each expression's acting, `dt` steps the jiggle springs and spins.
217
+ */
218
+ update(root, ch, weights, time, dt) {
219
+ const { t, r, j, inv } = this.scratch;
220
+ this.world[0].set(root);
221
+ for (let i = 1; i < this.bones.length; i++) {
222
+ const bone = this.bones[i];
223
+ const angle = { rx: bone.rest?.rx ?? 0, ry: bone.rest?.ry ?? 0, rz: bone.rest?.rz ?? 0 };
224
+ for (const axis of AXES) {
225
+ for (const [channel, weight] of bone.drive?.[axis] ?? []) angle[axis] += ch[channel] * weight;
226
+ const w = bone.wave?.[axis];
227
+ if (w) angle[axis] += w[0] * ch.energy * Math.sin(time * w[1] * Math.PI * 2 + w[2]);
228
+ }
229
+ for (const [expression, axis, motion] of this.acting[i]) {
230
+ const weight = weights[expression];
231
+ if (weight < 1e-3 && !(Array.isArray(motion) && motion[4])) continue;
232
+ angle[axis] += weight * evaluate(motion, time);
233
+ if (Array.isArray(motion) && motion[4]) this.spun[i][axis] += weight * motion[4] * dt;
234
+ }
235
+ for (const axis of AXES) angle[axis] += this.spun[i][axis];
236
+ const out = this.world[i];
237
+ multiply(out, this.world[bone.parentIndex], fromTranslation(t, ...bone.pivot));
238
+ multiply(out, out, fromEuler(r, angle.rx, angle.ry, angle.rz));
239
+ if (bone.jiggle) multiply(out, out, this.jiggle(i, out, dt, j, inv));
240
+ }
241
+ for (let i = 0; i < this.bones.length; i++) this.inverse.set(invert(inv, this.world[i]), i * 16);
242
+ }
243
+ /* The bone's tip is a point mass chasing where the tip would be at rest.
244
+ Returns the extra rotation, in the bone's space, that points it there. */
245
+ jiggle(i, posed, dt, out, inv) {
246
+ const { axis, length: len, freq, damping, maxAngle = 0.9 } = this.bones[i].jiggle;
247
+ const anchor = transformPoint(posed, [0, 0, 0]);
248
+ const restTip = add(anchor, scale(normalize(transformDir(posed, axis)), len));
249
+ let tip = this.tips[i];
250
+ if (!tip || !(dt > 0)) {
251
+ tip = this.tips[i] = tip ?? { x: restTip, v: [0, 0, 0] };
252
+ if (!(dt > 0)) return fromAxisAngle(out, [0, 1, 0], 0);
253
+ }
254
+ const w = freq * Math.PI * 2;
255
+ for (let k = 0; k < 3; k++) {
256
+ tip.v[k] += (w * w * (restTip[k] - tip.x[k]) - 2 * damping * w * tip.v[k]) * dt;
257
+ tip.x[k] += tip.v[k] * dt;
258
+ }
259
+ tip.x = add(anchor, scale(normalize(sub(tip.x, anchor)), len));
260
+ invert(inv, posed);
261
+ const want = normalize(transformDir(inv, sub(tip.x, anchor)));
262
+ const rest = normalize(axis);
263
+ const turn = cross(rest, want);
264
+ const s = length(turn);
265
+ if (s < 1e-6) return fromAxisAngle(out, [0, 1, 0], 0);
266
+ const angle = clamp(Math.atan2(s, dot(rest, want)), 0, maxAngle);
267
+ return fromAxisAngle(out, scale(turn, 1 / s), angle);
268
+ }
269
+ };
270
+
271
+ // ../engine/src/check/sdf.ts
272
+ var len2 = (x, y) => Math.hypot(x, y);
273
+ var clamp2 = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
274
+ function sdEllipsoid(p, r) {
275
+ const k0 = Math.hypot(p[0] / r[0], p[1] / r[1], p[2] / r[2]);
276
+ const k1 = Math.hypot(p[0] / (r[0] * r[0]), p[1] / (r[1] * r[1]), p[2] / (r[2] * r[2]));
277
+ return k0 * (k0 - 1) / Math.max(k1, 1e-6);
278
+ }
279
+ function sdRoundBox(p, b, r) {
280
+ const qx = Math.abs(p[0]) - b[0] + r, qy = Math.abs(p[1]) - b[1] + r, qz = Math.abs(p[2]) - b[2] + r;
281
+ return Math.hypot(Math.max(qx, 0), Math.max(qy, 0), Math.max(qz, 0)) + Math.min(Math.max(qx, qy, qz), 0) - r;
282
+ }
283
+ function sdCapsule(p, a, b, r) {
284
+ const pa = [p[0] - a[0], p[1] - a[1], p[2] - a[2]];
285
+ const ba = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
286
+ const h = clamp2((pa[0] * ba[0] + pa[1] * ba[1] + pa[2] * ba[2]) / (ba[0] * ba[0] + ba[1] * ba[1] + ba[2] * ba[2] || 1e-9), 0, 1);
287
+ return Math.hypot(pa[0] - ba[0] * h, pa[1] - ba[1] * h, pa[2] - ba[2] * h) - r;
288
+ }
289
+ function sdRoundCone(p, a, b, r1, r22) {
290
+ const ba = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
291
+ const l2 = ba[0] * ba[0] + ba[1] * ba[1] + ba[2] * ba[2] || 1e-9;
292
+ const rr = r1 - r22;
293
+ const a2 = l2 - rr * rr;
294
+ const il2 = 1 / l2;
295
+ const pa = [p[0] - a[0], p[1] - a[1], p[2] - a[2]];
296
+ const y = pa[0] * ba[0] + pa[1] * ba[1] + pa[2] * ba[2];
297
+ const z = y - l2;
298
+ const xv = [pa[0] * l2 - ba[0] * y, pa[1] * l2 - ba[1] * y, pa[2] * l2 - ba[2] * y];
299
+ const x2 = xv[0] * xv[0] + xv[1] * xv[1] + xv[2] * xv[2];
300
+ const y2 = y * y * l2;
301
+ const z2 = z * z * l2;
302
+ const k = Math.sign(rr) * rr * rr * x2;
303
+ if (Math.sign(z) * a2 * z2 > k) return Math.sqrt(x2 + z2) * il2 - r22;
304
+ if (Math.sign(y) * a2 * y2 < k) return Math.sqrt(x2 + y2) * il2 - r1;
305
+ return (Math.sqrt(x2 * a2 * il2) + y * rr) * il2 - r1;
306
+ }
307
+ function sdCylinder(p, r, h, rr) {
308
+ const dx = len2(p[0], p[2]) - r + rr;
309
+ const dy = Math.abs(p[1]) - h + rr;
310
+ return Math.min(Math.max(dx, dy), 0) + len2(Math.max(dx, 0), Math.max(dy, 0)) - rr;
311
+ }
312
+ function sdRoundRect(x, y, b, r) {
313
+ const qx = Math.abs(x) - b[0] + r, qy = Math.abs(y) - b[1] + r;
314
+ return len2(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - r;
315
+ }
316
+ function sdFrame(p, b, corner, w, depth) {
317
+ const ring = Math.abs(sdRoundRect(p[0], p[1], b, corner)) - w;
318
+ const ez = Math.abs(p[2]) - depth;
319
+ return Math.min(Math.max(ring, ez), 0) + len2(Math.max(ring, 0), Math.max(ez, 0));
320
+ }
321
+ function sdPlate(p, b, corner, depth) {
322
+ const e = sdRoundRect(p[0], p[1], b, corner);
323
+ const ez = Math.abs(p[2]) - depth;
324
+ return Math.min(Math.max(e, ez), 0) + len2(Math.max(e, 0), Math.max(ez, 0));
325
+ }
326
+ function smin(a, b, k) {
327
+ const h = Math.max(k - Math.abs(a - b), 0) / k;
328
+ return Math.min(a, b) - h * h * k * 0.25;
329
+ }
330
+ var smax = (a, b, k) => -smin(-a, -b, k);
331
+ function partDistance(part, l) {
332
+ switch (part.shape) {
333
+ case "sphere":
334
+ return Math.hypot(l[0], l[1], l[2]) - part.radius;
335
+ case "ellipsoid":
336
+ return sdEllipsoid(l, part.radii);
337
+ case "roundBox":
338
+ return sdRoundBox(l, part.half, part.round);
339
+ case "capsule":
340
+ return sdCapsule(l, part.from, part.to, part.radius);
341
+ case "roundCone":
342
+ return sdRoundCone(l, part.from, part.to, part.r1, part.r2);
343
+ case "cylinder":
344
+ return sdCylinder(l, part.radius, part.half, part.round);
345
+ case "frame":
346
+ return sdFrame(l, part.half, part.corner, part.width, part.depth);
347
+ case "plate":
348
+ return sdPlate(l, part.half, part.corner, part.depth);
349
+ case "hem":
350
+ return Infinity;
351
+ }
352
+ }
353
+ function partBox(part) {
354
+ const sym = (x, y, z) => [[-x, -y, -z], [x, y, z]];
355
+ const seg = (a, b, r) => [
356
+ [Math.min(a[0], b[0]) - r, Math.min(a[1], b[1]) - r, Math.min(a[2], b[2]) - r],
357
+ [Math.max(a[0], b[0]) + r, Math.max(a[1], b[1]) + r, Math.max(a[2], b[2]) + r]
358
+ ];
359
+ switch (part.shape) {
360
+ case "sphere":
361
+ return sym(part.radius, part.radius, part.radius);
362
+ case "ellipsoid":
363
+ return sym(...part.radii);
364
+ case "roundBox":
365
+ return sym(...part.half);
366
+ case "capsule":
367
+ return seg(part.from, part.to, part.radius);
368
+ case "roundCone":
369
+ return seg(part.from, part.to, Math.max(part.r1, part.r2));
370
+ case "cylinder":
371
+ return sym(part.radius, part.half, part.radius);
372
+ case "frame":
373
+ return sym(part.half[0] + part.width, part.half[1] + part.width, part.depth);
374
+ case "plate":
375
+ return sym(part.half[0], part.half[1], part.depth);
376
+ case "hem":
377
+ return null;
378
+ }
379
+ }
380
+
381
+ // ../engine/src/check/field.ts
382
+ var TAN_HALF_FOV = 0.2;
383
+ var CAMERA_PITCH = 0.1;
384
+ function pose(r, expression = "idle", time = 0, drive = {}) {
385
+ const hover = r.spec.motion?.float ?? 0;
386
+ const rig = new Rig(r.bones, r.acting);
387
+ const weights = Object.fromEntries(EXPRESSIONS.map((e) => [e, e === expression ? 1 : 0]));
388
+ const channels = { yaw: 0, pitch: 0, roll: 0, flap: 0, talk: 0, energy: 0, ...drive };
389
+ rig.update(fromTranslation(mat4(), 0, hover, 0), channels, weights, time, 0);
390
+ return { world: rig.world.map((m) => m.slice()), inverse: rig.world.map((m) => invert(mat4(), m)) };
391
+ }
392
+ var rowMajor = (rot) => {
393
+ const m = fromEuler(mat4(), rot[0], rot[1], rot[2]);
394
+ return [m[0], m[4], m[8], m[1], m[5], m[9], m[2], m[6], m[10]];
395
+ };
396
+ var unit = (v) => {
397
+ const n = Math.hypot(...v) || 1;
398
+ return [v[0] / n, v[1] / n, v[2] / n];
399
+ };
400
+ var Field = class {
401
+ constructor(spec) {
402
+ this.spec = spec;
403
+ this.r = resolve(spec);
404
+ this.parts = spec.parts.map((p, i) => ({
405
+ spec: p,
406
+ bone: this.r.boneIndex(p.bone, `parts[${i}]`),
407
+ at: p.at ?? [0, 0, 0],
408
+ rot: p.rot ? rowMajor(p.rot) : null,
409
+ clip: p.clip ? { normal: unit(p.clip.normal), offset: p.clip.offset ?? 0 } : null
410
+ }));
411
+ this.posed = pose(this.r);
412
+ }
413
+ spec;
414
+ r;
415
+ parts;
416
+ posed;
417
+ /** A point from a bone's space into the world, in the current pose. */
418
+ toWorld(bone, at) {
419
+ return transformPoint(this.posed.world[bone], at);
420
+ }
421
+ toBone(bone, p) {
422
+ return transformPoint(this.posed.inverse[bone], p);
423
+ }
424
+ /** A world point into part `i`'s own space. */
425
+ toPart(i, p) {
426
+ const part = this.parts[i];
427
+ const b = this.toBone(part.bone, p);
428
+ const d = [b[0] - part.at[0], b[1] - part.at[1], b[2] - part.at[2]];
429
+ const m = part.rot;
430
+ return m ? [m[0] * d[0] + m[3] * d[1] + m[6] * d[2], m[1] * d[0] + m[4] * d[1] + m[7] * d[2], m[2] * d[0] + m[5] * d[1] + m[8] * d[2]] : d;
431
+ }
432
+ /** A point in part `i`'s own space back into the world. */
433
+ fromPart(i, l) {
434
+ const part = this.parts[i];
435
+ const m = part.rot;
436
+ const b = m ? [m[0] * l[0] + m[1] * l[1] + m[2] * l[2], m[3] * l[0] + m[4] * l[1] + m[5] * l[2], m[6] * l[0] + m[7] * l[1] + m[8] * l[2]] : l;
437
+ return this.toWorld(part.bone, [b[0] + part.at[0], b[1] + part.at[1], b[2] + part.at[2]]);
438
+ }
439
+ /** Part `i` on its own, clip included, in its own space. */
440
+ localDistance(i, l) {
441
+ const part = this.parts[i];
442
+ const d = partDistance(part.spec, l);
443
+ const c = part.clip;
444
+ return c ? Math.max(d, l[0] * c.normal[0] + l[1] * c.normal[1] + l[2] * c.normal[2] - c.offset) : d;
445
+ }
446
+ /** Part `i` on its own, at a world point. */
447
+ partDistance(i, p) {
448
+ return this.localDistance(i, this.toPart(i, p));
449
+ }
450
+ /**
451
+ * The whole character at `p`, exactly as the shader's map() combines it.
452
+ * `skip` leaves one part out; `eyes: false` leaves the eyes out.
453
+ */
454
+ map(p, skip = -1, eyes = true) {
455
+ let d = 1e9;
456
+ let id = 0;
457
+ for (let i = 0; i < this.parts.length; i++) {
458
+ if (i === skip) continue;
459
+ const part = this.parts[i].spec;
460
+ if (part.shape === "hem") {
461
+ const l = this.toPart(i, p);
462
+ d = smax(d, part.y + part.amplitude * Math.sin(Math.atan2(l[2], l[0]) * part.waves) - l[1], part.blend);
463
+ continue;
464
+ }
465
+ const di = this.partDistance(i, p);
466
+ const k = part.blend ?? 0;
467
+ if (part.op === "sub") {
468
+ d = k > 0 ? smax(d, -di, k) : Math.max(d, -di);
469
+ } else {
470
+ if (di < d) id = this.r.materialId(part.material, "");
471
+ d = k > 0 ? smin(d, di, k) : Math.min(d, di);
472
+ }
473
+ }
474
+ if (eyes && this.spec.eyes) {
475
+ for (let e = 0; e < 2; e++) {
476
+ const de = this.eyeDistance(e, p);
477
+ if (de < d) {
478
+ d = de;
479
+ id = this.spec.eyes.kind === "ball" ? this.r.eyeIds[e] : this.r.materialId(this.spec.eyes.material, "eyes");
480
+ }
481
+ }
482
+ }
483
+ return { d, id };
484
+ }
485
+ /** One eye, open and looking ahead, the way it rests in idle. */
486
+ eyeDistance(e, p) {
487
+ const eyes = this.spec.eyes;
488
+ const b = this.toBone(this.r.boneIndex(eyes.bone, "eyes"), p);
489
+ const at = eyes.at[e];
490
+ const l = [b[0] - at[0], b[1] - at[1], b[2] - at[2]];
491
+ if (eyes.kind === "ball") return Math.hypot(...l) - eyes.radius;
492
+ const [rx, ry, rz] = eyes.radii;
493
+ const k0 = Math.hypot(l[0] / rx, l[1] / ry, l[2] / rz);
494
+ const k1 = Math.hypot(l[0] / (rx * rx), l[1] / (ry * ry), l[2] / (rz * rz));
495
+ return k0 * (k0 - 1) / Math.max(k1, 1e-6);
496
+ }
497
+ /** The surface normal at `p`, from the field's gradient. */
498
+ normal(p, skip = -1, eyes = true) {
499
+ const e = 6e-4;
500
+ const g = [0, 0, 0];
501
+ for (let k = 0; k < 3; k++) {
502
+ const a = [p[0], p[1], p[2]];
503
+ const b = [p[0], p[1], p[2]];
504
+ a[k] += e;
505
+ b[k] -= e;
506
+ g[k] = this.map(a, skip, eyes).d - this.map(b, skip, eyes).d;
507
+ }
508
+ return unit(g);
509
+ }
510
+ /** Where the camera sits for this character's framing. */
511
+ camera() {
512
+ const distance = this.spec.frame.radius / TAN_HALF_FOV;
513
+ return [0, this.spec.frame.target + distance * Math.sin(CAMERA_PITCH), distance * Math.cos(CAMERA_PITCH)];
514
+ }
515
+ /** Marches from `from` toward `to`; returns the distance travelled at the first hit, or null. */
516
+ march(from, to, eyes = true, epsilon = 15e-4) {
517
+ const dir = [to[0] - from[0], to[1] - from[1], to[2] - from[2]];
518
+ const total = Math.hypot(...dir);
519
+ const u = [dir[0] / total, dir[1] / total, dir[2] / total];
520
+ const far = Math.min(total, this.spec.bound.radius * 2 + Math.hypot(from[0], from[1] - this.spec.bound.y, from[2]) + 0.5);
521
+ let t = 0;
522
+ for (let i = 0; i < 160 && t < far; i++) {
523
+ const at = [from[0] + u[0] * t, from[1] + u[1] * t, from[2] + u[2] * t];
524
+ const hit = this.map(at, -1, eyes);
525
+ if (hit.d < epsilon) return { t, at, hit };
526
+ t += Math.max(hit.d * 0.9, epsilon * 0.5);
527
+ }
528
+ return null;
529
+ }
530
+ /** Can the camera see the surface point `p`? */
531
+ visible(p) {
532
+ const cam = this.camera();
533
+ const n = this.normal(p);
534
+ const v = unit([cam[0] - p[0], cam[1] - p[1], cam[2] - p[2]]);
535
+ if (n[0] * v[0] + n[1] * v[1] + n[2] * v[2] < 0.05) return false;
536
+ const start = [p[0] + n[0] * 6e-3, p[1] + n[1] * 6e-3, p[2] + n[2] * 6e-3];
537
+ return this.march(start, cam) === null;
538
+ }
539
+ /** The material name for an id from map(). */
540
+ materialName(id) {
541
+ const m = this.r.materials.find((x) => x.id === id);
542
+ if (m) return m.name;
543
+ return id === this.r.eyeIds[0] || id === this.r.eyeIds[1] ? "eye" : "nothing";
544
+ }
545
+ };
546
+
547
+ // ../engine/src/check/validate.ts
548
+ var HEX = /^#[0-9a-f]{6}$/i;
549
+ var ID = /^[a-z][a-z0-9-]*$/;
550
+ var SHAPES = ["sphere", "ellipsoid", "roundBox", "capsule", "roundCone", "cylinder", "frame", "plate", "hem"];
551
+ var AXES2 = ["rx", "ry", "rz"];
552
+ var CHANNELS = ["yaw", "pitch", "roll", "flap", "talk"];
553
+ var TEMPERAMENT = ["float", "hop", "squash", "snap", "look", "snapLook", "talkSquash"];
554
+ var SURFACES = ["fur", "scales", "speckle", "ripple"];
555
+ var GLYPHS = ["z", "bubble", "sparkle", "heart", "note", "arc", "puff", "ring", "drop", "spark", "mote", "feather", "dot", "exclaim", "warning", "confetti", "cloud", "star", "gear"];
556
+ var TOP = ["id", "name", "tagline", "accent", "frame", "bound", "motion", "materials", "bones", "parts", "paints", "eyes", "face", "anchors", "acting", "fx", "glowPool"];
557
+ var MATERIAL_KEYS = ["color", "shadeOf", "f0", "spec", "shininess", "metal", "rim", "gloss", "sheen", "sss", "sssColor", "emit", "emitStrength", "glows", "surface"];
558
+ function validate(spec) {
559
+ const issues = [];
560
+ const err = (where, message, fix) => issues.push({ level: "error", where, message, fix });
561
+ const warn = (where, message, fix) => issues.push({ level: "warning", where, message, fix });
562
+ if (!spec || typeof spec !== "object") {
563
+ err("spec", "The spec is not an object.");
564
+ return issues;
565
+ }
566
+ const s = spec;
567
+ const num = (v) => typeof v === "number" && Number.isFinite(v);
568
+ const pos = (v) => num(v) && v > 0;
569
+ const vec = (v, n) => Array.isArray(v) && v.length === n && v.every(num);
570
+ const needNum = (where, v, positive = false) => {
571
+ if (positive ? !pos(v) : !num(v)) err(where, `Expected ${positive ? "a number above 0" : "a number"}, got ${JSON.stringify(v)}.`);
572
+ };
573
+ const needVec = (where, v, n = 3) => {
574
+ if (!vec(v, n)) err(where, `Expected ${n} numbers like [${n === 3 ? "x, y, z" : "x, y"}], got ${JSON.stringify(v)}.`);
575
+ };
576
+ const needHex = (where, v) => {
577
+ if (typeof v !== "string" || !HEX.test(v)) err(where, `Expected a colour like "#ff8a3d", got ${JSON.stringify(v)}.`);
578
+ };
579
+ for (const key of Object.keys(s)) {
580
+ if (!TOP.includes(key)) warn(key, `"${key}" is not a spec field, so it is ignored.`, `Known fields: ${TOP.join(", ")}.`);
581
+ }
582
+ if (typeof s.id !== "string" || !ID.test(s.id)) err("id", `id must be lowercase letters, digits and dashes, starting with a letter, like "sprout". Got ${JSON.stringify(s.id)}.`);
583
+ if (typeof s.name !== "string" || !s.name) err("name", 'name is required: the display name, like "Sprout".');
584
+ if (typeof s.tagline !== "string" || !s.tagline) warn("tagline", "tagline is missing: one line describing the character.");
585
+ needHex("accent", s.accent);
586
+ const frame = s.frame;
587
+ if (!frame || typeof frame !== "object") err("frame", "frame is required: { target: <height the camera looks at>, radius: <half the visible height> }.", "Start with { target: 0.5, radius: 0.75 } for a mascot about 1 unit tall.");
588
+ else {
589
+ needNum("frame.target", frame.target);
590
+ needNum("frame.radius", frame.radius, true);
591
+ }
592
+ const bound = s.bound;
593
+ if (!bound || typeof bound !== "object") err("bound", "bound is required: { y: <centre height>, radius: <reach> }, a sphere that contains the whole body.", "Start with { y: 0.5, radius: 0.72 }.");
594
+ else {
595
+ needNum("bound.y", bound.y);
596
+ needNum("bound.radius", bound.radius, true);
597
+ }
598
+ if (s.motion !== void 0) {
599
+ if (typeof s.motion !== "object" || !s.motion) err("motion", "motion must be an object.");
600
+ else for (const [k, v] of Object.entries(s.motion)) {
601
+ if (!TEMPERAMENT.includes(k)) warn(`motion.${k}`, `"${k}" is not a motion setting.`, `Known: ${TEMPERAMENT.join(", ")}.`);
602
+ else needNum(`motion.${k}`, v);
603
+ }
604
+ }
605
+ const materials = s.materials ?? {};
606
+ const materialNames = Object.keys(materials);
607
+ if (typeof s.materials !== "object" || !materialNames.length) err("materials", 'materials is required: at least one named material, like { skin: { color: "#ffb38a" } }.');
608
+ for (const [name, m] of Object.entries(materials)) {
609
+ const at = `materials.${name}`;
610
+ if (!m || typeof m !== "object") {
611
+ err(at, "A material must be an object with at least a color.");
612
+ continue;
613
+ }
614
+ needHex(`${at}.color`, m.color);
615
+ for (const k of Object.keys(m)) if (!MATERIAL_KEYS.includes(k)) warn(`${at}.${k}`, `"${k}" is not a material setting, so it is ignored.`, `Known: ${MATERIAL_KEYS.join(", ")}.`);
616
+ for (const k of ["f0", "spec", "shininess", "metal", "rim", "gloss", "sheen", "sss", "emitStrength"]) if (m[k] !== void 0) needNum(`${at}.${k}`, m[k]);
617
+ for (const k of ["f0", "metal", "gloss"]) if (num(m[k]) && (m[k] < 0 || m[k] > 1)) warn(`${at}.${k}`, `${k} is meant to be between 0 and 1.`);
618
+ for (const k of ["sssColor", "emit"]) if (m[k] !== void 0) needHex(`${at}.${k}`, m[k]);
619
+ if (m.shadeOf !== void 0 && !materialNames.includes(m.shadeOf)) err(`${at}.shadeOf`, `shadeOf names "${m.shadeOf}", which is not a material.`);
620
+ const surface = m.surface;
621
+ if (surface !== void 0) {
622
+ if (!SURFACES.includes(surface?.kind)) err(`${at}.surface.kind`, `surface.kind must be one of ${SURFACES.join(", ")}.`);
623
+ needNum(`${at}.surface.scale`, surface?.scale, true);
624
+ }
625
+ }
626
+ const boneNames = /* @__PURE__ */ new Set(["root"]);
627
+ if (!Array.isArray(s.bones)) err("bones", 'bones is required: a list, usually starting with { name: "body", pivot: [0, 0, 0] }.');
628
+ else s.bones.forEach((b, i) => {
629
+ const at = `bones[${i}]`;
630
+ if (typeof b?.name !== "string") return err(`${at}.name`, "Every bone needs a name.");
631
+ if (boneNames.has(b.name)) err(`${at}.name`, `Bone "${b.name}" is declared twice${b.name === "root" ? ` ("root" is built in; don't declare it)` : ""}.`);
632
+ const parent = b.parent ?? "root";
633
+ if (!boneNames.has(parent)) err(`${at}.parent`, `Bone "${b.name}" names parent "${parent}", which is not declared before it. Declare parents first.`);
634
+ boneNames.add(b.name);
635
+ needVec(`${at}.pivot`, b.pivot);
636
+ for (const field of ["rest", "drive", "wave"]) {
637
+ const v = b[field];
638
+ if (v === void 0) continue;
639
+ for (const [axis, val] of Object.entries(v)) {
640
+ if (!AXES2.includes(axis)) {
641
+ err(`${at}.${field}.${axis}`, `"${axis}" is not an axis; use rx, ry or rz.`);
642
+ continue;
643
+ }
644
+ if (field === "rest") needNum(`${at}.rest.${axis}`, val);
645
+ if (field === "wave" && !vec(val, 3)) err(`${at}.wave.${axis}`, "A wave is [amplitude, hz, phase].");
646
+ if (field === "drive") {
647
+ if (!Array.isArray(val) || !val.every((d) => Array.isArray(d) && CHANNELS.includes(d[0]) && num(d[1]))) {
648
+ err(`${at}.drive.${axis}`, `A drive is a list of [channel, weight] pairs, like [["yaw", 0.6]]. Channels: ${CHANNELS.join(", ")}.`);
649
+ }
650
+ }
651
+ }
652
+ }
653
+ const j = b.jiggle;
654
+ if (j !== void 0) {
655
+ needVec(`${at}.jiggle.axis`, j?.axis);
656
+ needNum(`${at}.jiggle.length`, j?.length, true);
657
+ needNum(`${at}.jiggle.freq`, j?.freq, true);
658
+ needNum(`${at}.jiggle.damping`, j?.damping, true);
659
+ }
660
+ });
661
+ const needBone = (where, name) => {
662
+ if (!boneNames.has(name)) err(where, `Names bone "${name}", which does not exist.`, `Bones: ${[...boneNames].join(", ")}.`);
663
+ };
664
+ const needMaterial = (where, name) => {
665
+ if (!materialNames.includes(name)) err(where, `Names material "${name}", which does not exist.`, `Materials: ${materialNames.join(", ")}.`);
666
+ };
667
+ if (!Array.isArray(s.parts) || !s.parts.length) err("parts", "parts is required: at least one shape.");
668
+ else s.parts.forEach((p, i) => {
669
+ const at = `parts[${i}]`;
670
+ needBone(`${at}.bone`, p?.bone);
671
+ if (p?.shape !== "hem") needMaterial(`${at}.material`, p?.material);
672
+ if (!SHAPES.includes(p?.shape)) return err(`${at}.shape`, `"${p?.shape}" is not a shape. Shapes: ${SHAPES.join(", ")}.`);
673
+ if (p.at !== void 0) needVec(`${at}.at`, p.at);
674
+ if (p.rot !== void 0) needVec(`${at}.rot`, p.rot);
675
+ if (p.op !== void 0 && p.op !== "add" && p.op !== "sub") err(`${at}.op`, 'op is "add" (the default) or "sub".');
676
+ if (p.op === "sub" && i === 0) err(`${at}.op`, "The first part cannot be a subtraction: there is nothing before it to cut.");
677
+ if (p.blend !== void 0 && (!num(p.blend) || p.blend < 0)) err(`${at}.blend`, "blend is a distance, 0 or more (0.02\u20130.1 is typical).");
678
+ if (p.clip !== void 0) {
679
+ const c = p.clip;
680
+ if (!vec(c?.normal, 3) || c.normal.every((x) => x === 0)) err(`${at}.clip.normal`, "clip.normal is a direction like [0, -1, 0] (keeps the top half).");
681
+ }
682
+ const size = {
683
+ sphere: () => needNum(`${at}.radius`, p.radius, true),
684
+ ellipsoid: () => vec(p.radii, 3) && p.radii.every((x) => x > 0) ? void 0 : err(`${at}.radii`, "radii are three sizes above 0: [x, y, z]."),
685
+ roundBox: () => {
686
+ if (!vec(p.half, 3) || !p.half.every((x) => x > 0)) err(`${at}.half`, "half is three half-sizes above 0: [x, y, z].");
687
+ needNum(`${at}.round`, p.round);
688
+ },
689
+ capsule: () => {
690
+ needVec(`${at}.from`, p.from);
691
+ needVec(`${at}.to`, p.to);
692
+ needNum(`${at}.radius`, p.radius, true);
693
+ },
694
+ roundCone: () => {
695
+ needVec(`${at}.from`, p.from);
696
+ needVec(`${at}.to`, p.to);
697
+ needNum(`${at}.r1`, p.r1, true);
698
+ needNum(`${at}.r2`, p.r2, true);
699
+ },
700
+ cylinder: () => {
701
+ needNum(`${at}.radius`, p.radius, true);
702
+ needNum(`${at}.half`, p.half, true);
703
+ needNum(`${at}.round`, p.round);
704
+ },
705
+ frame: () => {
706
+ needVec(`${at}.half`, p.half, 2);
707
+ ["corner", "width", "depth"].forEach((k) => needNum(`${at}.${k}`, p[k], k !== "corner"));
708
+ },
709
+ plate: () => {
710
+ needVec(`${at}.half`, p.half, 2);
711
+ ["corner", "depth"].forEach((k) => needNum(`${at}.${k}`, p[k], k !== "corner"));
712
+ },
713
+ hem: () => ["y", "amplitude", "waves", "speed", "blend"].forEach((k) => needNum(`${at}.${k}`, p[k]))
714
+ };
715
+ size[p.shape]();
716
+ });
717
+ if (s.paints !== void 0 && !Array.isArray(s.paints)) err("paints", "paints must be a list.");
718
+ else s.paints?.forEach((p, i) => {
719
+ const at = `paints[${i}]`;
720
+ needBone(`${at}.bone`, p?.bone);
721
+ if (!Array.isArray(p?.on) || !p.on.length) err(`${at}.on`, 'on is the list of materials the paint may colour, like ["skin"].');
722
+ else p.on.forEach((m, k) => needMaterial(`${at}.on[${k}]`, m));
723
+ needHex(`${at}.color`, p?.color);
724
+ needVec(`${at}.at`, p?.at);
725
+ if (p?.kind === "sphere") needNum(`${at}.radius`, p.radius, true);
726
+ else if (p?.kind === "ellipse") needVec(`${at}.radii`, p.radii, 2);
727
+ else err(`${at}.kind`, 'kind is "sphere" (a ball of colour around at) or "ellipse" (projected along +z).');
728
+ });
729
+ const eyes = s.eyes;
730
+ if (eyes !== void 0) {
731
+ needBone("eyes.bone", eyes?.bone);
732
+ if (!Array.isArray(eyes?.at) || eyes.at.length !== 2) err("eyes.at", "eyes.at is two points: [[-x, y, z], [x, y, z]].");
733
+ else eyes.at.forEach((v, k) => needVec(`eyes.at[${k}]`, v));
734
+ if (eyes?.kind === "ball") {
735
+ needNum("eyes.radius", eyes.radius, true);
736
+ needHex("eyes.iris", eyes.iris);
737
+ needNum("eyes.irisSize", eyes.irisSize, true);
738
+ needNum("eyes.pupilSize", eyes.pupilSize, true);
739
+ needMaterial("eyes.lid", eyes.lid);
740
+ } else if (eyes?.kind === "bead") {
741
+ if (!vec(eyes.radii, 3) || !eyes.radii.every((x) => x > 0)) err("eyes.radii", "radii are three sizes above 0: [width, height, depth], like [0.03, 0.045, 0.02].");
742
+ needMaterial("eyes.material", eyes.material);
743
+ } else err("eyes.kind", 'eyes.kind is "ball" (white eyeballs with an iris and lids) or "bead" (glossy dark beads).');
744
+ }
745
+ const face = s.face;
746
+ if (face !== void 0) {
747
+ needBone("face.bone", face?.bone);
748
+ if (face?.kind === "mouth") {
749
+ if (!Array.isArray(face.on) || !face.on.length) err("face.on", 'face.on is the list of materials the mouth may be drawn on, like ["skin"].');
750
+ else face.on.forEach((m, k) => needMaterial(`face.on[${k}]`, m));
751
+ needVec("face.at", face.at);
752
+ needNum("face.width", face.width, true);
753
+ needHex("face.color", face.color);
754
+ needHex("face.tongue", face.tongue);
755
+ } else if (face?.kind === "screen") {
756
+ needMaterial("face.material", face.material);
757
+ needVec("face.center", face.center, 2);
758
+ needVec("face.half", face.half, 2);
759
+ needNum("face.cell", face.cell, true);
760
+ needHex("face.color", face.color);
761
+ } else err("face.kind", 'face.kind is "mouth" (a painted mouth) or "screen" (an LED face).');
762
+ }
763
+ const anchors = s.anchors ?? {};
764
+ for (const [name, a] of Object.entries(anchors)) {
765
+ needBone(`anchors.${name}.bone`, a?.bone);
766
+ needVec(`anchors.${name}.at`, a?.at);
767
+ }
768
+ for (const [expression, perBone] of Object.entries(s.acting ?? {})) {
769
+ if (!EXPRESSIONS.includes(expression)) {
770
+ err(`acting.${expression}`, `"${expression}" is not a state.`, `States: ${EXPRESSIONS.join(", ")}.`);
771
+ continue;
772
+ }
773
+ for (const [bone, axes] of Object.entries(perBone ?? {})) {
774
+ needBone(`acting.${expression}.${bone}`, bone);
775
+ for (const [axis, motion] of Object.entries(axes ?? {})) {
776
+ const where = `acting.${expression}.${bone}.${axis}`;
777
+ if (!AXES2.includes(axis)) err(where, `"${axis}" is not an axis; use rx, ry or rz.`);
778
+ else if (!num(motion) && !(Array.isArray(motion) && motion.length >= 1 && motion.length <= 5 && motion.every(num))) {
779
+ err(where, "A motion is a number (a held angle) or [base, amplitude, hz, phase, spin].");
780
+ } else if (Math.abs(Array.isArray(motion) ? motion[0] : motion) + Math.abs(Array.isArray(motion) ? motion[1] ?? 0 : 0) > 3.3) {
781
+ warn(where, "This turns more than 3.3 radians (about 190\xB0), which tends to look broken. Spins belong in the fifth number, spin.");
782
+ }
783
+ }
784
+ }
785
+ }
786
+ const fx = s.fx ?? {};
787
+ for (const [trigger, list] of Object.entries(fx)) {
788
+ if (trigger === "replace") continue;
789
+ const base = trigger.startsWith("enter:") ? trigger.slice(6) : trigger;
790
+ if (!["ambient", "land", "syllable"].includes(trigger) && !EXPRESSIONS.includes(base)) {
791
+ err(`fx.${trigger}`, `"${trigger}" is not a trigger.`, `Use a state name, "enter:<state>", "ambient", "land" or "syllable".`);
792
+ continue;
793
+ }
794
+ if (!Array.isArray(list)) {
795
+ err(`fx.${trigger}`, "Each trigger takes a list of emitters.");
796
+ continue;
797
+ }
798
+ list.forEach((e, i) => {
799
+ const at = `fx.${trigger}[${i}]`;
800
+ if (!GLYPHS.includes(e?.glyph)) err(`${at}.glyph`, `"${e?.glyph}" is not a glyph.`, `Glyphs: ${GLYPHS.join(", ")}.`);
801
+ if (!anchors[e?.anchor]) err(`${at}.anchor`, `Names anchor "${e?.anchor}", which does not exist.`, `Anchors: ${Object.keys(anchors).join(", ") || "none yet"}.`);
802
+ needNum(`${at}.rate`, e?.rate, true);
803
+ needVec(`${at}.velocity`, e?.velocity);
804
+ needVec(`${at}.life`, e?.life, 2);
805
+ needVec(`${at}.size`, e?.size, 2);
806
+ needHex(`${at}.color`, e?.color);
807
+ });
808
+ }
809
+ return issues;
810
+ }
811
+
812
+ // ../engine/src/check/check.ts
813
+ var SAMPLES = 260;
814
+ var SIGHT_SAMPLES = 48;
815
+ var EPS = 4e-3;
816
+ var r3 = (n) => Math.round(n * 1e3) / 1e3;
817
+ var r2 = (n) => Math.round(n * 100) / 100;
818
+ var fmt = (v) => `[${v.map(r3).join(", ")}]`;
819
+ var up2 = (n) => Math.ceil(n * 100) / 100;
820
+ function describePart(spec, i) {
821
+ const p = spec.parts[i];
822
+ return `parts[${i}] (${p.shape}, ${p.material} on ${p.bone})`;
823
+ }
824
+ function checkCharacter(spec) {
825
+ const issues = validate(spec);
826
+ const base = {
827
+ id: String(spec?.id ?? "?"),
828
+ name: String(spec?.name ?? "?"),
829
+ parts: [],
830
+ closeup: { target: Number(spec?.frame?.target) || 0.5, radius: (Number(spec?.frame?.radius) || 0.8) * 0.5 },
831
+ extents: null,
832
+ suggested: null,
833
+ stats: {
834
+ parts: Array.isArray(spec?.parts) ? spec.parts.length : 0,
835
+ bones: Array.isArray(spec?.bones) ? spec.bones.length + 1 : 0,
836
+ materials: spec?.materials ? Object.keys(spec.materials).length : 0,
837
+ bytes: JSON.stringify(spec ?? {}).length
838
+ }
839
+ };
840
+ const done = (more = {}) => {
841
+ const all = [...issues, ...more.issues ?? []];
842
+ const rank = { error: 0, warning: 1, note: 2 };
843
+ all.sort((a, b) => rank[a.level] - rank[b.level]);
844
+ return { ...base, ...more, issues: all, ok: !all.some((i) => i.level === "error") };
845
+ };
846
+ if (issues.some((i) => i.level === "error")) return done();
847
+ let field;
848
+ try {
849
+ field = new Field(spec);
850
+ } catch (e) {
851
+ issues.push({ level: "error", where: "spec", message: e instanceof SpecError ? e.message.replace(/^[^:]+: /, "") : String(e) });
852
+ return done();
853
+ }
854
+ const found = [];
855
+ const add2 = (level, where, message, fix) => found.push({ level, where, message, fix });
856
+ const random = rng(0.37);
857
+ const exposed = [];
858
+ const reports = [];
859
+ const surfaces = /* @__PURE__ */ new Map();
860
+ const backOnly = [];
861
+ const rest = field.posed;
862
+ const moving = [
863
+ pose(field.r, "speaking", 0.2, { talk: 1 }),
864
+ pose(field.r, "celebrating", 0.4, { flap: 1, talk: 1 }),
865
+ pose(field.r, "surprised", 0.3, { talk: 0.6 }),
866
+ pose(field.r, "sad", 0.3),
867
+ pose(field.r, "sleeping", 0.3)
868
+ ];
869
+ spec.parts.forEach((part, i) => {
870
+ const box = partBox(part);
871
+ if (!box || part.op === "sub") return;
872
+ const local = surfaceSamples(field, i, box, random);
873
+ if (!local.length) {
874
+ add2("warning", `parts[${i}]`, `${describePart(spec, i)} has no surface to measure; check its sizes are above zero and its clip does not cut it away entirely.`);
875
+ return;
876
+ }
877
+ const samples = local.map((l) => field.fromPart(i, l));
878
+ surfaces.set(i, samples);
879
+ let shows = 0;
880
+ let buried = 0;
881
+ let pull = 0;
882
+ const containers = /* @__PURE__ */ new Map();
883
+ const mine = [];
884
+ for (const p of samples) {
885
+ const others = field.map(p, i).d;
886
+ const final = field.map(p).d;
887
+ pull = Math.max(pull, others - final);
888
+ if (others < -EPS) {
889
+ buried++;
890
+ const j = container(field, i, p);
891
+ if (j >= 0) containers.set(j, (containers.get(j) ?? 0) + 1);
892
+ } else if (final <= EPS) {
893
+ shows++;
894
+ const bone = field.parts[i].bone;
895
+ mine.push({ part: i, bone, local: field.toBone(bone, p), world: p });
896
+ }
897
+ }
898
+ exposed.push(...mine);
899
+ const sight = mine.filter((_, k) => k % Math.max(1, Math.floor(mine.length / SIGHT_SAMPLES)) === 0).slice(0, SIGHT_SAMPLES);
900
+ const seenCount = sight.filter((s) => field.visible(s.world)).length;
901
+ const report = { index: i, shows: shows / samples.length, seen: sight.length ? seenCount / sight.length * (shows / samples.length) : 0 };
902
+ reports.push(report);
903
+ const shaping = (part.blend ?? 0) > 0 && pull > 3e-3;
904
+ const hidden = shows === 0 && buried > 0;
905
+ const sliver = !hidden && report.shows < 0.04 && buried > 0 && !shaping;
906
+ if ((hidden || sliver) && !shaping) {
907
+ let best = report.shows;
908
+ for (const posed of moving) {
909
+ field.posed = posed;
910
+ const out = local.filter((l) => {
911
+ const p = field.fromPart(i, l);
912
+ return field.map(p, i).d >= -EPS && field.map(p).d <= EPS;
913
+ }).length;
914
+ best = Math.max(best, out / local.length);
915
+ }
916
+ field.posed = rest;
917
+ if (best >= 0.04) return;
918
+ }
919
+ const main2 = [...containers.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
920
+ const inside = main2 !== void 0 ? ` inside ${describePart(spec, main2)}` : " inside the body";
921
+ if (hidden) {
922
+ if (shaping) {
923
+ add2("note", `parts[${i}]`, `${describePart(spec, i)} is buried${inside}; it only rounds out the surface through its blend.`);
924
+ } else {
925
+ const out = escape(field, i, samples);
926
+ add2(
927
+ "error",
928
+ `parts[${i}]`,
929
+ `${describePart(spec, i)} is completely hidden${inside}, so it never shows.`,
930
+ out ? `Move it outward: at ${fmt(out.at)} would bring it to the surface (that is ${r3(out.distance)} along ${out.axis}). Or make it larger.` : "Move it outward or make it larger."
931
+ );
932
+ }
933
+ } else if (sliver) {
934
+ add2("warning", `parts[${i}]`, `${describePart(spec, i)} is ${Math.round((1 - report.shows) * 100)}% buried${inside}; only a sliver shows.`, "If it should read clearly, move it outward or make it larger.");
935
+ } else if (report.shows > 0 && seenCount === 0 && sight.length >= 4) {
936
+ backOnly.push(i);
937
+ }
938
+ });
939
+ if (backOnly.length) {
940
+ add2("note", "parts", `Not visible from the front (on the back, or covered): ${backOnly.map((i) => `parts[${i}] (${spec.parts[i].material})`).join(", ")}. Fine for tails and backs; not for anything the viewer should notice.`);
941
+ }
942
+ floating(field, surfaces).forEach(({ parts, gap, from, to }) => {
943
+ const names = parts.map((i) => `parts[${i}]`);
944
+ const bone = field.parts[parts[0]].bone;
945
+ const a = field.toBone(bone, from);
946
+ const b = field.toBone(bone, to);
947
+ const len = Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]) || 1;
948
+ const k = (gap + 0.012) / len;
949
+ const shift = [(b[0] - a[0]) * k, (b[1] - a[1]) * k, (b[2] - a[2]) * k];
950
+ const moved = parts.map((i) => {
951
+ const at = field.parts[i].at;
952
+ return `parts[${i}].at ${fmt([at[0] + shift[0], at[1] + shift[1], at[2] + shift[2]])}`;
953
+ });
954
+ add2(
955
+ "warning",
956
+ names.length === 1 ? names[0] : names.join(", "),
957
+ `${parts.length === 1 ? describePart(spec, parts[0]) : `${describePart(spec, parts[0])} and ${parts.length - 1} more`} ${parts.length === 1 ? "floats" : "float"} free of the body: the nearest gap is ${r3(gap)}, so it looks detached from every angle.`,
958
+ `Move it ${fmt(shift.map(r3))} in ${spec.parts[parts[0]].bone}'s space so it overlaps the body: ${moved.join(", ")}. If the whole bone is detached, move the bone's pivot by the same amount instead.`
959
+ );
960
+ });
961
+ spec.parts.forEach((part, i) => {
962
+ if (part.op !== "sub") return;
963
+ const box = partBox(part);
964
+ if (!box) return;
965
+ const thin = Math.min(...[0, 1, 2].map((k) => (box[1][k] - box[0][k]) / 2));
966
+ const samples = [0, Math.min(6e-3, thin * 0.3)].flatMap((inset) => surfaceSamples(field, i, box, random, inset).map((l) => field.fromPart(i, l)));
967
+ const carves = samples.some((p) => field.map(p, i).d < 0 && field.map(p).d > field.map(p, i).d + 2e-4);
968
+ if (carves) return;
969
+ const later = spec.parts.findIndex((q, j) => j > i && q.op !== "sub" && q.shape !== "hem" && samples.some((p) => field.partDistance(j, p) < 0));
970
+ add2(
971
+ "warning",
972
+ `parts[${i}]`,
973
+ `${describePart(spec, i)} is a subtraction that cuts nothing.`,
974
+ later >= 0 ? `A subtraction only cuts parts listed before it, and the part it overlaps, parts[${later}], comes after. Move the subtraction after parts[${later}].` : (() => {
975
+ const bone = field.parts[i].bone;
976
+ const near = project(field, field.toWorld(bone, part.at ?? [0, 0, 0]));
977
+ return near ? `It does not reach any part. The nearest surface is at ${fmt(field.toBone(bone, near))} (in ${part.bone}'s space); centre it there, or a little inside, to carve.` : "It does not overlap any part. Move it into the part it should carve.";
978
+ })()
979
+ );
980
+ });
981
+ let extents = null;
982
+ let suggested = null;
983
+ if (exposed.length) {
984
+ const min = [Infinity, Infinity, Infinity];
985
+ const max = [-Infinity, -Infinity, -Infinity];
986
+ for (const s of exposed) for (let k = 0; k < 3; k++) {
987
+ min[k] = Math.min(min[k], s.world[k]);
988
+ max[k] = Math.max(max[k], s.world[k]);
989
+ }
990
+ extents = { min, max };
991
+ const hover = spec.motion?.float ?? 0;
992
+ let lo = min[1], hi = max[1];
993
+ const by = spec.bound.y + hover;
994
+ let worst = { d: 0, state: "idle", part: -1 };
995
+ const step = Math.max(1, Math.floor(exposed.length / 900));
996
+ for (const state of STATES) {
997
+ for (const t of [0, 0.35, 0.7, 1.05]) {
998
+ const posed = pose(field.r, state, t);
999
+ for (let k = 0; k < exposed.length; k += step) {
1000
+ const s = exposed[k];
1001
+ const w = transformPoint(posed.world[s.bone], s.local);
1002
+ lo = Math.min(lo, w[1]);
1003
+ hi = Math.max(hi, w[1]);
1004
+ const d = Math.hypot(w[0], w[1] - by, w[2]);
1005
+ if (d > worst.d) worst = { d, state, part: s.part };
1006
+ }
1007
+ }
1008
+ }
1009
+ const boundY = r2((lo + hi) / 2 - hover);
1010
+ let boundR = 0;
1011
+ for (const state of STATES) {
1012
+ const posed = pose(field.r, state, 0.5);
1013
+ for (let k = 0; k < exposed.length; k += step) {
1014
+ const s = exposed[k];
1015
+ const w = transformPoint(posed.world[s.bone], s.local);
1016
+ boundR = Math.max(boundR, Math.hypot(w[0], w[1] - hover - boundY, w[2]));
1017
+ }
1018
+ }
1019
+ boundR = up2(Math.max(boundR, worst.d - Math.abs(boundY + hover - by)) + 0.05);
1020
+ const halfW = Math.max(Math.abs(min[0]), Math.abs(max[0]));
1021
+ const low = Math.min(min[1], 0);
1022
+ const high = max[1] + (hover > 0 ? 0.05 : 0);
1023
+ const target = r2((low + high) / 2 + 0.02);
1024
+ const frameR = up2(Math.max(halfW, (high - low) / 2) * 1.3 + 0.04);
1025
+ suggested = { bound: { y: boundY, radius: boundR }, frame: { target, radius: frameR } };
1026
+ if (worst.d > spec.bound.radius + 0.012) {
1027
+ add2(
1028
+ worst.d > spec.bound.radius + 0.03 ? "error" : "warning",
1029
+ "bound",
1030
+ `${describePart(spec, worst.part)} reaches ${r3(worst.d)} from the bound's centre in "${worst.state}", past bound.radius ${spec.bound.radius}, so it gets sliced off there.`,
1031
+ `Set bound to { y: ${boundY}, radius: ${boundR} }.`
1032
+ );
1033
+ } else if (spec.bound.radius > boundR * 1.6 + 0.05) {
1034
+ add2("note", "bound", `bound.radius ${spec.bound.radius} is much larger than the body needs; a tighter bound renders faster.`, `bound: { y: ${boundY}, radius: ${boundR} }`);
1035
+ }
1036
+ const f = spec.frame;
1037
+ const top = max[1] - f.target, bottom = f.target - min[1];
1038
+ const over = Math.max(halfW, top, bottom) - f.radius;
1039
+ if (over > 5e-3) {
1040
+ const side = halfW - f.radius > 5e-3 ? "the sides" : top > bottom ? "the top" : "the bottom";
1041
+ add2("error", "frame", `The body is cut off at ${side} of the picture: it reaches ${r3(over)} past frame.radius ${f.radius}.`, `frame: { target: ${target}, radius: ${frameR} }`);
1042
+ } else if (Math.max(halfW, (max[1] - min[1]) / 2) < f.radius * 0.5) {
1043
+ add2("warning", "frame", `The body fills less than half the picture, so it will look small and far away.`, `frame: { target: ${target}, radius: ${frameR} }`);
1044
+ } else if (Math.abs((min[1] + max[1]) / 2 - f.target) > f.radius * 0.28) {
1045
+ add2("warning", "frame", `frame.target ${f.target} is far from the body's middle height (${r2((min[1] + max[1]) / 2)}), so it sits off-centre.`, `frame: { target: ${target}, radius: ${frameR} }`);
1046
+ }
1047
+ if (hover > 0 && min[1] < 0.02) {
1048
+ add2("warning", "parts", `Even hovering at motion.float ${hover}, the lowest point is at height ${r3(min[1])}, so it scrapes the floor.`, `Move the parts up by ${r3(0.05 - min[1])}, or raise motion.float.`);
1049
+ } else if (hover === 0 && min[1] > 0.035) {
1050
+ add2("warning", "parts", `The lowest point of the body is ${r3(min[1])} above the floor, so it hovers, but motion.float is 0.`, `Move the parts down by ${r3(min[1])} (or add feet), or set motion.float if it should hover.`);
1051
+ } else if (hover === 0 && min[1] < -0.03) {
1052
+ add2("warning", "parts", `The body dips ${r3(-min[1])} below the floor (y = 0), so its base disappears into the shadow.`, `Move the parts up by ${r3(-min[1])}, or flatten the base with a clip.`);
1053
+ }
1054
+ }
1055
+ const eyes = spec.eyes;
1056
+ if (eyes) {
1057
+ const bone = field.r.boneIndex(eyes.bone, "eyes");
1058
+ const depth = eyes.kind === "ball" ? eyes.radius : eyes.radii[2];
1059
+ const [a, b] = eyes.at;
1060
+ if (a[0] > b[0]) add2("warning", "eyes.at", "eyes.at[0] is to the right of eyes.at[1]. List the left eye (negative x) first; winks and glances depend on it.");
1061
+ if (Math.abs(a[0] + b[0]) > 0.015 || Math.abs(a[1] - b[1]) > 0.015 || Math.abs(a[2] - b[2]) > 0.015) {
1062
+ add2("warning", "eyes.at", `The eyes are not mirror images (${fmt(a)} and ${fmt(b)}), so the face looks lopsided.`, `Use [-x, y, z] and [x, y, z] with the same y and z.`);
1063
+ }
1064
+ eyes.at.forEach((at, e) => {
1065
+ const where = `eyes.at[${e}]`;
1066
+ if (at[2] <= 0) {
1067
+ add2("error", where, `This eye is at z ${at[2]}, on the back half of the head. Characters face +z, toward the camera.`, "Use a positive z, on the front of the face.");
1068
+ return;
1069
+ }
1070
+ const surface = skinAlongZ(field, bone, at);
1071
+ if (surface === null) {
1072
+ add2("error", where, "There is no head behind this eye: it floats in the air.", "Move it onto the face: give it the x and y of a point on the head.");
1073
+ return;
1074
+ }
1075
+ const front = at[2] + depth - surface;
1076
+ const want = eyes.kind === "ball" ? surface - depth * 0.7 : surface - depth * 0.3;
1077
+ const fixed = [at[0], at[1], r3(want)];
1078
+ if (front <= 2e-3) {
1079
+ add2("error", where, `This eye is buried ${r3(-front + 2e-3)} inside the head, so it cannot be seen.`, `Move it forward to at ${fmt(fixed)} (the face's surface is at z ${r3(surface)} there).`);
1080
+ } else if (at[2] - depth > surface + 4e-3) {
1081
+ add2("error", where, `This eye floats ${r3(at[2] - depth - surface)} in front of the face.`, `Move it back to at ${fmt(fixed)} (the face's surface is at z ${r3(surface)} there).`);
1082
+ } else if (front < depth * (eyes.kind === "ball" ? 0.08 : 0.25)) {
1083
+ add2("warning", where, `This eye barely pokes out of the face (${r3(front)}), so it reads as a dent.`, `Move it forward to at ${fmt(fixed)}.`);
1084
+ } else if (eyes.kind === "bead" && front > depth * 1.9) {
1085
+ add2("warning", where, `This bead eye stands almost entirely in front of the face, so it looks stuck on.`, `Move it back to at ${fmt(fixed)}.`);
1086
+ } else {
1087
+ const tip = field.toWorld(bone, [at[0], at[1], at[2] + depth * 0.9]);
1088
+ const sees = field.march(field.camera(), tip, true, 1e-3);
1089
+ if (sees && Math.hypot(sees.at[0] - tip[0], sees.at[1] - tip[1], sees.at[2] - tip[2]) > depth * 1.5) {
1090
+ add2("warning", where, `Something in front covers this eye from the camera: ${field.materialName(sees.hit.id)}.`, "Move the eye or the part in front of it.");
1091
+ }
1092
+ }
1093
+ });
1094
+ }
1095
+ spec.paints?.forEach((paint, i) => {
1096
+ const where = `paints[${i}]`;
1097
+ const bone = field.r.boneIndex(paint.bone, where);
1098
+ if (paint.kind === "sphere") {
1099
+ const radius = paint.radius ?? 0.1;
1100
+ const centre = field.toWorld(bone, paint.at);
1101
+ const d = field.map(centre).d;
1102
+ const reach = radius * (1 + (paint.soft ?? 0.15));
1103
+ const onSkin = project(field, centre);
1104
+ const hint = onSkin ? ` Its nearest surface point is at ${fmt(field.toBone(bone, onSkin))}.` : "";
1105
+ if (Math.abs(d) > reach) {
1106
+ add2(
1107
+ "error",
1108
+ where,
1109
+ `This paint never touches a surface: its centre is ${r3(Math.abs(d))} ${d < 0 ? "inside" : "outside"} the body, more than its radius ${radius}.`,
1110
+ onSkin ? `Move it to at ${fmt(field.toBone(bone, onSkin))}, on the surface.` : "Move its centre onto the surface."
1111
+ );
1112
+ } else if (Math.abs(d) > radius * 0.9) {
1113
+ add2("warning", where, `Only the very edge of this paint reaches the surface (its centre is ${r3(Math.abs(d))} ${d < 0 ? "inside" : "outside"} the body, radius ${radius}), so it shows as a faint speck.${hint}`);
1114
+ } else {
1115
+ const lands = paintLands(field, centre, radius);
1116
+ if (lands.size && ![...lands].some((m) => paint.on.includes(m))) {
1117
+ const name = [...lands][0];
1118
+ add2("warning", where, `This paint only meets "${[...lands].join('", "')}", not its on: [${paint.on.join(", ")}], so it does not show.`, `Add "${name}" to on, or move it.`);
1119
+ }
1120
+ }
1121
+ } else {
1122
+ const [rx, ry] = paint.radii ?? [0.1, 0.1];
1123
+ const ring = (s) => [0, 1, 2, 3, 4, 5, 6, 7].map((k) => [paint.at[0] + Math.cos(k * 0.785) * rx * s, paint.at[1] + Math.sin(k * 0.785) * ry * s, paint.at[2]]);
1124
+ const probes = [paint.at, ...ring(0.6), ...ring(0.9)];
1125
+ const hits = probes.map((q) => alongZ(field, bone, q)).filter((h) => !!h);
1126
+ const painted = hits.filter((h) => h.z > paint.at[2]);
1127
+ if (!hits.length) {
1128
+ add2("error", where, "There is no surface in front of or behind this ellipse paint.", "Move its x and y onto the body.");
1129
+ } else if (!painted.length) {
1130
+ const z = Math.min(...hits.map((h) => h.z));
1131
+ add2("error", where, `An ellipse paint colours only surface in front of its at.z (${paint.at[2]}), but the surface here is at z ${r3(z)}, behind it, so nothing is painted.`, `Set at.z below ${r3(z)}, e.g. ${r3(z - 0.08)}.`);
1132
+ } else if (!painted.some((h) => paint.on.includes(h.material))) {
1133
+ add2("warning", where, `This ellipse paint lands on "${painted[0].material}", which is not in its on: [${paint.on.join(", ")}].`, `Add "${painted[0].material}" to on, or move it.`);
1134
+ }
1135
+ }
1136
+ });
1137
+ const face = spec.face;
1138
+ if (face?.kind === "mouth") {
1139
+ const bone = field.r.boneIndex(face.bone, "face");
1140
+ const w = face.width;
1141
+ const probes = [-1, -0.5, 0, 0.5, 1].flatMap((u) => [-0.3, 0, 0.3].map((v) => [face.at[0] + u * w, face.at[1] + v * w, face.at[2]]));
1142
+ const hits = probes.map((q) => alongZ(field, bone, q)).filter((h) => !!h);
1143
+ const drawn = hits.filter((h) => h.z >= face.at[2] - 0.02 && face.on.includes(h.material));
1144
+ const centre = alongZ(field, bone, face.at);
1145
+ if (!hits.length || !centre) {
1146
+ add2("error", "face.at", "There is no surface behind the mouth: it floats in the air.", "Move face.at onto the front of the head.");
1147
+ } else if (!hits.some((h) => face.on.includes(h.material))) {
1148
+ add2("error", "face.on", `The mouth lands on "${centre.material}", which is not in face.on [${face.on.join(", ")}], so no mouth appears.`, `Add "${centre.material}" to face.on.`);
1149
+ } else if (!drawn.length) {
1150
+ add2("error", "face.at", `The mouth is drawn only on surface less than 0.02 behind face.at, but the face here is ${r3(face.at[2] - centre.z)} further back, so no mouth appears.`, `Set face.at to ${fmt([face.at[0], face.at[1], r3(centre.z)])}.`);
1151
+ } else if (drawn.length < hits.length * 0.4) {
1152
+ const deepest = Math.min(...hits.filter((h) => face.on.includes(h.material)).map((h) => h.z));
1153
+ add2("warning", "face.at", `Only part of the mouth lands on the face (${drawn.length} of ${hits.length} points), so it shows broken or shortened.`, `Set face.at to ${fmt([face.at[0], face.at[1], r3(deepest + 0.012)])}.`);
1154
+ } else if (!field.visible(field.toWorld(bone, [face.at[0], face.at[1], centre.z]))) {
1155
+ add2("warning", "face.at", "The camera cannot see where the mouth is drawn.", "Move it onto the front of the face.");
1156
+ }
1157
+ if (eyes) {
1158
+ const eyeY = Math.min(...eyes.at.map((at) => field.toWorld(field.r.boneIndex(eyes.bone, "eyes"), at)[1]));
1159
+ const mouthY = field.toWorld(bone, face.at)[1];
1160
+ if (mouthY > eyeY) add2("warning", "face.at", "The mouth is above the eyes.", "Put the mouth below the eyes, usually 0.06\u20130.12 lower.");
1161
+ }
1162
+ }
1163
+ const anchors = spec.anchors ?? {};
1164
+ const needs = [
1165
+ ["top", "thinking, working, celebrating, sad and sleeping effects", "warning"],
1166
+ ["mouth", "speaking effects", "warning"],
1167
+ ["ear", "listening effects", "note"],
1168
+ ["feet", "the landing puff after a hop", "note"]
1169
+ ];
1170
+ for (const [name, what, level] of needs) {
1171
+ if (!anchors[name] && !spec.fx?.replace?.length) add2(level, `anchors.${name}`, `There is no "${name}" anchor, so ${what} are skipped.`, `Add anchors.${name}: { bone, at }.`);
1172
+ }
1173
+ for (const [name, a] of Object.entries(anchors)) {
1174
+ if (name !== "top" && name !== "mouth") continue;
1175
+ const bone = field.r.boneIndex(a.bone, `anchors.${name}`);
1176
+ const d = field.map(field.toWorld(bone, a.at)).d;
1177
+ if (d < -0.06) add2("warning", `anchors.${name}`, `The "${name}" anchor is ${r3(-d)} deep inside the body, so effects from it start out hidden.`, name === "top" ? "Put it on or just above the top of the head." : "Move it onto the surface.");
1178
+ }
1179
+ const usedMaterials = /* @__PURE__ */ new Set([
1180
+ ...spec.parts.map((p) => p.material),
1181
+ ...spec.eyes?.kind === "ball" ? [spec.eyes.lid] : spec.eyes ? [spec.eyes.material] : [],
1182
+ ...spec.face?.kind === "screen" ? [spec.face.material] : [],
1183
+ ...Object.values(spec.materials).map((m) => m.shadeOf).filter((x) => !!x)
1184
+ ]);
1185
+ for (const name of Object.keys(spec.materials)) {
1186
+ if (!usedMaterials.has(name)) add2("note", `materials.${name}`, `Material "${name}" is not used by any part.`);
1187
+ }
1188
+ const usedBones = /* @__PURE__ */ new Set([
1189
+ ...spec.parts.map((p) => p.bone),
1190
+ ...(spec.paints ?? []).map((p) => p.bone),
1191
+ ...spec.eyes ? [spec.eyes.bone] : [],
1192
+ ...spec.face ? [spec.face.bone] : [],
1193
+ ...Object.values(anchors).map((a) => a.bone),
1194
+ ...spec.bones.map((b) => b.parent ?? "root")
1195
+ ]);
1196
+ for (const b of spec.bones) if (!usedBones.has(b.name)) add2("note", `bones`, `Bone "${b.name}" moves nothing: no part, paint, eye, face, anchor or child bone uses it.`);
1197
+ if (spec.parts.length > 48) add2("warning", "parts", `${spec.parts.length} parts is a lot; every part costs shader time on every pixel. Try to stay under 40.`);
1198
+ let closeup = base.closeup;
1199
+ if (eyes) {
1200
+ const bone = field.r.boneIndex(eyes.bone, "eyes");
1201
+ const [a, b] = eyes.at.map((at) => field.toWorld(bone, at));
1202
+ const spread = Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
1203
+ closeup = { target: r3((a[1] + b[1]) / 2 - 0.04), radius: r3(Math.max(0.16, spread * 1.6)) };
1204
+ }
1205
+ return done({ issues: found, parts: reports, extents, suggested, closeup });
1206
+ }
1207
+ function surfaceSamples(field, i, box, random, inset = 0) {
1208
+ const out = [];
1209
+ const [lo, hi] = box;
1210
+ const pad = 0.1;
1211
+ for (let n = 0; n < SAMPLES; n++) {
1212
+ let l = [0, 1, 2].map((k) => {
1213
+ const span = hi[k] - lo[k];
1214
+ return lo[k] - span * pad + random() * span * (1 + 2 * pad);
1215
+ });
1216
+ let d = 0;
1217
+ for (let it = 0; it < 8; it++) {
1218
+ d = field.localDistance(i, l);
1219
+ const g = gradient((q) => field.localDistance(i, q), l);
1220
+ const g2 = g[0] * g[0] + g[1] * g[1] + g[2] * g[2];
1221
+ if (!(g2 > 1e-12)) break;
1222
+ const s = (d + inset) / g2;
1223
+ l = [l[0] - g[0] * s, l[1] - g[1] * s, l[2] - g[2] * s];
1224
+ }
1225
+ if (Math.abs(field.localDistance(i, l) + inset) < 1e-3) out.push(l);
1226
+ }
1227
+ return out;
1228
+ }
1229
+ function gradient(fn, p) {
1230
+ const e = 5e-4;
1231
+ return [0, 1, 2].map((k) => {
1232
+ const a = [p[0], p[1], p[2]];
1233
+ const b = [p[0], p[1], p[2]];
1234
+ a[k] += e;
1235
+ b[k] -= e;
1236
+ return (fn(a) - fn(b)) / (2 * e);
1237
+ });
1238
+ }
1239
+ function container(field, i, p) {
1240
+ let best = -1;
1241
+ let deepest = 0;
1242
+ field.spec.parts.forEach((q, j) => {
1243
+ if (j === i || q.op === "sub" || q.shape === "hem") return;
1244
+ const d = field.partDistance(j, p);
1245
+ if (d < deepest) {
1246
+ deepest = d;
1247
+ best = j;
1248
+ }
1249
+ });
1250
+ return best;
1251
+ }
1252
+ function paintLands(field, centre, radius) {
1253
+ const out = /* @__PURE__ */ new Set();
1254
+ const dirs = [[0, 0, 0], [1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1], [0.7, 0.7, 0], [-0.7, 0.7, 0], [0.7, -0.7, 0], [-0.7, -0.7, 0]];
1255
+ for (const d of dirs) {
1256
+ const q = project(field, [centre[0] + d[0] * radius * 0.6, centre[1] + d[1] * radius * 0.6, centre[2] + d[2] * radius * 0.6]);
1257
+ if (q && Math.hypot(q[0] - centre[0], q[1] - centre[1], q[2] - centre[2]) <= radius) out.add(field.materialName(field.map(q).id));
1258
+ }
1259
+ return out;
1260
+ }
1261
+ function floating(field, surfaces) {
1262
+ const ids = [...surfaces.keys()];
1263
+ if (ids.length < 2) return [];
1264
+ const parent = new Map(ids.map((i) => [i, i]));
1265
+ const root = (i) => parent.get(i) === i ? i : root(parent.get(i));
1266
+ const reach = (i, j) => {
1267
+ let best = Infinity;
1268
+ for (const p of surfaces.get(i)) best = Math.min(best, field.partDistance(j, p));
1269
+ return best;
1270
+ };
1271
+ for (const i of ids) {
1272
+ for (const j of ids) {
1273
+ if (j <= i || root(i) === root(j)) continue;
1274
+ const touch = Math.max(0.015, field.parts[i].spec.blend ?? 0, field.parts[j].spec.blend ?? 0);
1275
+ if (reach(i, j) < touch || reach(j, i) < touch) parent.set(root(j), root(i));
1276
+ }
1277
+ }
1278
+ const body = root(ids[0]);
1279
+ const groups = /* @__PURE__ */ new Map();
1280
+ for (const i of ids) if (root(i) !== body) groups.set(root(i), [...groups.get(root(i)) ?? [], i]);
1281
+ return [...groups.values()].map((parts) => {
1282
+ let gap = Infinity;
1283
+ let from = [0, 0, 0];
1284
+ let to = [0, 0, 0];
1285
+ for (const i of parts) {
1286
+ for (const p of surfaces.get(i)) {
1287
+ let d = Infinity;
1288
+ let j = -1;
1289
+ for (const k of ids) {
1290
+ if (parts.includes(k)) continue;
1291
+ const dk = field.partDistance(k, p);
1292
+ if (dk < d) d = dk, j = k;
1293
+ }
1294
+ if (d < gap && j >= 0) {
1295
+ gap = d;
1296
+ from = p;
1297
+ const e = 6e-4;
1298
+ const g = [0, 1, 2].map((a) => {
1299
+ const q1 = [p[0], p[1], p[2]];
1300
+ const q2 = [p[0], p[1], p[2]];
1301
+ q1[a] += e;
1302
+ q2[a] -= e;
1303
+ return field.partDistance(j, q1) - field.partDistance(j, q2);
1304
+ });
1305
+ const n = Math.hypot(...g) || 1;
1306
+ to = [p[0] - g[0] / n * d, p[1] - g[1] / n * d, p[2] - g[2] / n * d];
1307
+ }
1308
+ }
1309
+ }
1310
+ return { parts: parts.sort((a, b) => a - b), gap, from, to };
1311
+ });
1312
+ }
1313
+ function project(field, p) {
1314
+ let q = [p[0], p[1], p[2]];
1315
+ for (let it = 0; it < 12; it++) {
1316
+ const d = field.map(q).d;
1317
+ if (Math.abs(d) < 3e-4) return q;
1318
+ const n = field.normal(q);
1319
+ q = [q[0] - n[0] * d, q[1] - n[1] * d, q[2] - n[2] * d];
1320
+ }
1321
+ return Math.abs(field.map(q).d) < 2e-3 ? q : null;
1322
+ }
1323
+ function escape(field, i, samples) {
1324
+ const part = field.parts[i];
1325
+ const at = part.at;
1326
+ const axes = [["+z", [0, 0, 1]], ["+y", [0, 1, 0]], ["-x", [-1, 0, 0]], ["+x", [1, 0, 0]], ["-y", [0, -1, 0]], ["-z", [0, 0, -1]]];
1327
+ let best = null;
1328
+ const local = samples.map((p) => field.toBone(part.bone, p));
1329
+ for (const [name, dir] of axes) {
1330
+ for (let t = 0.01; t <= 0.4; t += 0.01) {
1331
+ let clear = 0;
1332
+ for (const l of local) {
1333
+ const w = field.toWorld(part.bone, [l[0] + dir[0] * t, l[1] + dir[1] * t, l[2] + dir[2] * t]);
1334
+ if (field.map(w, i).d > 0) clear++;
1335
+ }
1336
+ if (clear >= local.length * 0.2) {
1337
+ if (!best || t < best.distance) best = { at: [at[0] + dir[0] * (t + 5e-3), at[1] + dir[1] * (t + 5e-3), at[2] + dir[2] * (t + 5e-3)], distance: t + 5e-3, axis: name };
1338
+ break;
1339
+ }
1340
+ }
1341
+ }
1342
+ return best;
1343
+ }
1344
+ function skinAlongZ(field, bone, at) {
1345
+ const from = field.toWorld(bone, [at[0], at[1], at[2] + 1.5]);
1346
+ const to = field.toWorld(bone, [at[0], at[1], at[2] - 1.5]);
1347
+ const hit = field.march(from, to, false, 4e-4);
1348
+ return hit ? field.toBone(bone, hit.at)[2] : null;
1349
+ }
1350
+ function alongZ(field, bone, at) {
1351
+ const from = field.toWorld(bone, [at[0], at[1], at[2] + 1.5]);
1352
+ const to = field.toWorld(bone, [at[0], at[1], at[2] - 1.5]);
1353
+ const hit = field.march(from, to, true, 4e-4);
1354
+ return hit ? { z: field.toBone(bone, hit.at)[2], material: field.materialName(hit.hit.id) } : null;
1355
+ }
1356
+ var MARK = { error: "\u2716 error ", warning: "\u25B2 warning", note: "\xB7 note " };
1357
+ function formatCheck(report) {
1358
+ const lines = [`${report.name} (${report.id}): ${report.stats.parts} parts, ${report.stats.bones} bones, ${report.stats.materials} materials, ${(report.stats.bytes / 1024).toFixed(1)} KB of spec`];
1359
+ for (const issue of report.issues) {
1360
+ lines.push(`${MARK[issue.level]} ${issue.where}: ${issue.message}`);
1361
+ if (issue.fix) lines.push(` fix: ${issue.fix}`);
1362
+ }
1363
+ const counts = ["error", "warning", "note"].map((l) => [l, report.issues.filter((i) => i.level === l).length]);
1364
+ if (!report.issues.length) lines.push("\u2713 No problems found.");
1365
+ else lines.push(counts.map(([l, n]) => `${n} ${l}${n === 1 ? "" : "s"}`).join(", ") + (report.ok ? ". No errors." : "."));
1366
+ return lines.join("\n");
1367
+ }
1368
+ var env = process.env;
1369
+ function candidates() {
1370
+ const own = [env.WOBNIMO_BROWSER, env.CHROME_PATH, env.PUPPETEER_EXECUTABLE_PATH].filter((p) => !!p);
1371
+ const playwright = playwrightChromium();
1372
+ if (process.platform === "darwin") {
1373
+ const apps = ["Google Chrome", "Google Chrome Canary", "Chromium", "Microsoft Edge", "Brave Browser", "Arc", "Vivaldi"];
1374
+ const roots = ["/Applications", join(homedir(), "Applications")];
1375
+ const bins = { Arc: "Arc", "Brave Browser": "Brave Browser", Vivaldi: "Vivaldi" };
1376
+ return [...own, ...roots.flatMap((r) => apps.map((a) => join(r, `${a}.app`, "Contents", "MacOS", bins[a] ?? a))), ...playwright];
1377
+ }
1378
+ if (process.platform === "win32") {
1379
+ const roots = [env.PROGRAMFILES, env["PROGRAMFILES(X86)"], env.LOCALAPPDATA].filter((p) => !!p);
1380
+ const rel = ["Google\\Chrome\\Application\\chrome.exe", "Microsoft\\Edge\\Application\\msedge.exe", "BraveSoftware\\Brave-Browser\\Application\\brave.exe", "Chromium\\Application\\chrome.exe"];
1381
+ return [...own, ...roots.flatMap((r) => rel.map((p) => join(r, p))), ...playwright];
1382
+ }
1383
+ const names = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge", "microsoft-edge-stable", "brave-browser"];
1384
+ const dirs = (env.PATH ?? "").split(delimiter).filter(Boolean);
1385
+ return [...own, ...names.flatMap((n) => dirs.map((d) => join(d, n))), "/snap/bin/chromium", ...playwright];
1386
+ }
1387
+ function playwrightChromium() {
1388
+ const roots = [
1389
+ env.PLAYWRIGHT_BROWSERS_PATH,
1390
+ process.platform === "darwin" ? join(homedir(), "Library", "Caches", "ms-playwright") : "",
1391
+ process.platform === "win32" ? join(env.LOCALAPPDATA ?? "", "ms-playwright") : join(homedir(), ".cache", "ms-playwright")
1392
+ ].filter((r) => !!r && existsSync(r));
1393
+ const out = [];
1394
+ for (const root of roots) {
1395
+ const builds = readdirSync(root).filter((d) => /^chromium(-\d+)?$/.test(d)).sort().reverse();
1396
+ for (const b of builds) {
1397
+ out.push(
1398
+ join(root, b, "chrome-linux", "chrome"),
1399
+ join(root, b, "chrome-linux64", "chrome"),
1400
+ join(root, b, "chrome-mac", "Chromium.app", "Contents", "MacOS", "Chromium"),
1401
+ join(root, b, "chrome-mac-arm64", "Chromium.app", "Contents", "MacOS", "Chromium"),
1402
+ join(root, b, "chrome-win", "chrome.exe"),
1403
+ join(root, b, "chrome-win64", "chrome.exe")
1404
+ );
1405
+ }
1406
+ out.push(join(root, "chromium"), join(root, "chromium", "chrome"));
1407
+ }
1408
+ return out;
1409
+ }
1410
+ function findBrowser(explicit) {
1411
+ const isFile = (p) => {
1412
+ try {
1413
+ return statSync(p).isFile();
1414
+ } catch {
1415
+ return false;
1416
+ }
1417
+ };
1418
+ if (explicit) return isFile(explicit) ? explicit : null;
1419
+ return candidates().find(isFile) ?? null;
1420
+ }
1421
+ function launchHeadless(executable, url, software = true) {
1422
+ const profile = mkdtempSync(join(tmpdir(), "wobnimo-"));
1423
+ const args = [
1424
+ "--headless=new",
1425
+ "--no-first-run",
1426
+ "--no-default-browser-check",
1427
+ "--disable-extensions",
1428
+ "--mute-audio",
1429
+ "--hide-scrollbars",
1430
+ `--user-data-dir=${profile}`,
1431
+ "--window-size=1400,1100",
1432
+ ...software ? ["--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--ignore-gpu-blocklist"] : ["--ignore-gpu-blocklist"],
1433
+ /* Chrome refuses to sandbox as root, which is how containers and CI run */
1434
+ ...process.platform === "linux" && process.getuid?.() === 0 ? ["--no-sandbox"] : [],
1435
+ url
1436
+ ];
1437
+ const child = spawn(executable, args, { stdio: "ignore", detached: process.platform !== "win32" });
1438
+ child.on("error", () => {
1439
+ });
1440
+ return {
1441
+ close() {
1442
+ try {
1443
+ if (process.platform === "win32") spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
1444
+ else if (child.pid) process.kill(-child.pid, "SIGKILL");
1445
+ } catch {
1446
+ child.kill("SIGKILL");
1447
+ }
1448
+ setTimeout(() => rmSync(profile, { recursive: true, force: true, maxRetries: 3 }), 300).unref();
1449
+ }
1450
+ };
1451
+ }
1452
+ function openInBrowser(url) {
1453
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", '""', url]] : ["xdg-open", [url]];
1454
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
1455
+ child.on("error", () => {
1456
+ });
1457
+ child.unref();
1458
+ }
1459
+ var LoadError = class extends Error {
1460
+ };
1461
+ var looksLikeSpec = (v) => !!v && typeof v === "object" && Array.isArray(v.parts) && typeof v.materials === "object";
1462
+ async function loadSpec(file, name) {
1463
+ const path = resolve$1(file);
1464
+ const ext = extname(path).toLowerCase();
1465
+ let exports;
1466
+ try {
1467
+ if (ext === ".json") {
1468
+ const spec = JSON.parse(readFileSync(path, "utf8"));
1469
+ if (spec && typeof spec === "object" && !Array.isArray(spec)) return { spec, path };
1470
+ throw new LoadError(`${file} must hold one character spec as a JSON object.`);
1471
+ } else {
1472
+ exports = await import(`${pathToFileURL(path).href}?t=${Date.now()}`);
1473
+ }
1474
+ } catch (e) {
1475
+ const err = e;
1476
+ if (err.code === "ENOENT" || err.code === "ERR_MODULE_NOT_FOUND" && String(err.message).includes(path)) throw new LoadError(`No file at ${path}.`);
1477
+ if (err.code === "ERR_UNKNOWN_FILE_EXTENSION" && /\.[mc]?ts$/.test(ext)) throw new LoadError(TS_HELP);
1478
+ if (err instanceof SyntaxError && /\.[mc]?ts$/.test(ext) && !runsTypeScript()) throw new LoadError(`${err.message}
1479
+ ${TS_HELP}`);
1480
+ if (err instanceof LoadError) throw err;
1481
+ throw new LoadError(`Could not load ${file}: ${err.message}`);
1482
+ }
1483
+ if (name) {
1484
+ if (!looksLikeSpec(exports[name])) throw new LoadError(`${file} has no character export named "${name}". Character exports: ${specExports(exports).join(", ") || "none"}.`);
1485
+ return { spec: exports[name], path };
1486
+ }
1487
+ if (looksLikeSpec(exports.default)) return { spec: exports.default, path };
1488
+ const found = specExports(exports);
1489
+ if (found.length === 1) return { spec: exports[found[0]], path };
1490
+ if (found.length > 1) throw new LoadError(`${file} exports several characters (${found.join(", ")}). Pick one with --export <name>.`);
1491
+ throw new LoadError(`${file} does not export a character spec. End the file with \`export default <yourCharacter>;\`.`);
1492
+ }
1493
+ function specExports(exports) {
1494
+ return Object.keys(exports).filter((k) => k !== "default" && looksLikeSpec(exports[k]));
1495
+ }
1496
+ var TS_HELP = `This version of Node (${process.versions.node}) cannot run TypeScript files directly.
1497
+ Use Node 22.18 or newer, or save the spec as .mjs (same code without type annotations) or .json.
1498
+ Type-only imports must say so: \`import type { CharacterSpec } from 'wobnimo'\`.`;
1499
+ var [major, minor] = process.versions.node.split(".").map(Number);
1500
+ var stripsByDefault = major > 23 || major === 23 && minor >= 6 || major === 22 && minor >= 18;
1501
+ var stripsWithFlag = major > 22 || major === 22 && minor >= 6;
1502
+ var flagged = () => process.execArgv.some((a) => a.includes("strip-types")) || !!process.env.NODE_OPTIONS?.includes("strip-types");
1503
+ function runsTypeScript() {
1504
+ return stripsByDefault || stripsWithFlag && flagged();
1505
+ }
1506
+ function needsTypeStripFlag(file) {
1507
+ return /\.[mc]?ts$/i.test(file) && !stripsByDefault && stripsWithFlag && !flagged();
1508
+ }
1509
+
1510
+ // src/cli/pages.ts
1511
+ var STYLE = `
1512
+ :root { color-scheme: light; --ink: #16181d; --mute: #6d6a62; --paper: #f4f1e9; --line: #e2ddd1; }
1513
+ body { margin: 0; font: 14px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); background: var(--paper); }
1514
+ `;
1515
+ var SHEET_CELLS = [
1516
+ { label: "front", state: "idle", turn: 0 },
1517
+ { label: "\xBE turn", state: "idle", turn: 0.75 },
1518
+ { label: "side", state: "idle", turn: Math.PI / 2 },
1519
+ { label: "back", state: "idle", turn: Math.PI },
1520
+ { label: "face close-up", state: "idle", turn: 0, closeup: true },
1521
+ { label: "thinking", state: "thinking" },
1522
+ { label: "listening", state: "listening" },
1523
+ { label: "speaking", state: "speaking" },
1524
+ { label: "working", state: "working" },
1525
+ { label: "error", state: "error" },
1526
+ { label: "surprised", state: "surprised" },
1527
+ /* between hops: 1.5 s in, the shared celebration is mid-somersault */
1528
+ { label: "celebrating", state: "celebrating", settle: 2.1 },
1529
+ { label: "sad", state: "sad" },
1530
+ { label: "sleeping", state: "sleeping" },
1531
+ { label: "as a 56px avatar", state: "idle", small: 56 }
1532
+ ];
1533
+ function sheetPage(cell) {
1534
+ return `<!doctype html><meta charset="utf-8"><title>wobnimo preview</title><style>${STYLE}</style>
1535
+ <body><canvas id="sheet"></canvas>
1536
+ <script type="module">
1537
+ const fail = (msg) => fetch('/error', { method: 'POST', body: String(msg) });
1538
+ addEventListener('error', (e) => fail(e.message));
1539
+ addEventListener('unhandledrejection', (e) => fail(e.reason?.message ?? e.reason));
1540
+ try {
1541
+ const { snapshot, compile } = await import('/wobnimo.js');
1542
+ const spec = await (await fetch('/spec.json')).json();
1543
+ const summary = await (await fetch('/summary.json')).json();
1544
+ compile(spec);
1545
+ const cells = ${JSON.stringify(SHEET_CELLS)};
1546
+ const size = ${cell}, cols = 5, label = 26, pad = 10, head = 54;
1547
+ const canvas = document.getElementById('sheet');
1548
+ canvas.width = cols * (size + pad) + pad;
1549
+ canvas.height = head + Math.ceil(cells.length / cols) * (size + label + pad) + pad;
1550
+ const g = canvas.getContext('2d');
1551
+ g.fillStyle = '#f4f1e9';
1552
+ g.fillRect(0, 0, canvas.width, canvas.height);
1553
+ g.fillStyle = '#16181d';
1554
+ g.font = '600 20px system-ui, sans-serif';
1555
+ g.fillText(spec.name + ' (' + spec.id + ')', pad, 30);
1556
+ g.font = '13px system-ui, sans-serif';
1557
+ g.fillStyle = summary.errors ? '#c2410c' : summary.warnings ? '#a16207' : '#3f6212';
1558
+ g.fillText(summary.line, pad, 47);
1559
+ for (const [i, c] of cells.entries()) {
1560
+ const x = pad + (i % cols) * (size + pad);
1561
+ const y = head + Math.floor(i / cols) * (size + label + pad);
1562
+ g.fillStyle = '#ffffff';
1563
+ g.beginPath(); g.roundRect(x, y, size, size, 10); g.fill();
1564
+ /* the close-up re-aims the camera at the eyes */
1565
+ const subject = c.closeup ? { ...spec, frame: summary.closeup } : spec;
1566
+ const shot = await snapshot(subject, { state: c.state, size: c.small ?? size, pixelRatio: 1, seed: 0.3, turn: c.turn ?? 0, settle: c.settle ?? 1.5 });
1567
+ const s = c.small ?? size;
1568
+ g.drawImage(shot, x + (size - s) / 2, y + (size - s) / 2, s, s);
1569
+ g.fillStyle = '#6d6a62';
1570
+ g.font = '13px system-ui, sans-serif';
1571
+ g.fillText(c.label, x + 4, y + size + 18);
1572
+ }
1573
+ const blob = await new Promise((r) => canvas.toBlob(r, 'image/png'));
1574
+ await fetch('/result', { method: 'POST', body: blob });
1575
+ } catch (e) {
1576
+ fail(e?.message ?? e);
1577
+ }
1578
+ </script>`;
1579
+ }
1580
+ function livePage() {
1581
+ return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>wobnimo live preview</title>
1582
+ <style>${STYLE}
1583
+ main { display: grid; grid-template-columns: minmax(280px, 520px) minmax(0, 1fr); gap: 24px; padding: 24px; max-width: 1200px; margin: 0 auto; }
1584
+ @media (max-width: 760px) { main { grid-template-columns: 1fr; } }
1585
+ #stage { aspect-ratio: 1; background: radial-gradient(circle at 50% 40%, #fff, #ebe6da); border-radius: 18px; }
1586
+ h1 { margin: 0 0 4px; font-size: 22px; } p { margin: 0 0 12px; color: var(--mute); }
1587
+ .states { display: flex; flex-wrap: wrap; gap: 6px; margin: 12px 0 18px; }
1588
+ button { font: inherit; border: 1px solid var(--line); background: #fff; border-radius: 999px; padding: 6px 12px; cursor: pointer; }
1589
+ button[aria-pressed="true"] { background: var(--ink); color: #fff; border-color: var(--ink); }
1590
+ pre { white-space: pre-wrap; background: #fff; border: 1px solid var(--line); border-radius: 12px; padding: 14px; font: 12.5px/1.5 ui-monospace, Menlo, Consolas, monospace; overflow-x: auto; }
1591
+ .err { color: #c2410c; }
1592
+ </style>
1593
+ <main>
1594
+ <div id="stage"></div>
1595
+ <section>
1596
+ <h1 id="name">Loading\u2026</h1><p id="tagline"></p>
1597
+ <div class="states" id="states"></div>
1598
+ <pre id="report"></pre>
1599
+ </section>
1600
+ </main>
1601
+ <script type="module">
1602
+ import { mount, STATES } from '/wobnimo.js';
1603
+ let mascot = null, state = 'idle', version = -1;
1604
+ const states = document.getElementById('states');
1605
+ for (const s of STATES) {
1606
+ const b = document.createElement('button');
1607
+ b.textContent = s;
1608
+ b.onclick = () => { state = s; mascot?.setState(s); paint(); };
1609
+ states.append(b);
1610
+ }
1611
+ const paint = () => states.querySelectorAll('button').forEach((b) => b.setAttribute('aria-pressed', String(b.textContent === state)));
1612
+ async function load() {
1613
+ const r = await (await fetch('/state.json')).json();
1614
+ if (r.version === version) return;
1615
+ version = r.version;
1616
+ document.getElementById('report').textContent = r.report;
1617
+ if (!r.spec) { document.getElementById('report').className = 'err'; return; }
1618
+ document.getElementById('report').className = '';
1619
+ document.getElementById('name').textContent = r.spec.name;
1620
+ document.getElementById('tagline').textContent = r.spec.tagline;
1621
+ try {
1622
+ if (mascot) mascot.setSpec(r.spec);
1623
+ else mascot = mount(document.getElementById('stage'), r.spec, { state });
1624
+ } catch (e) {
1625
+ document.getElementById('report').textContent = String(e.message) + '\\n\\n' + r.report;
1626
+ document.getElementById('report').className = 'err';
1627
+ }
1628
+ paint();
1629
+ }
1630
+ await load();
1631
+ setInterval(load, 600);
1632
+ </script>`;
1633
+ }
1634
+
1635
+ // src/cli/index.ts
1636
+ var ROOT = resolve$1(dirname(fileURLToPath(import.meta.url)), "..");
1637
+ var SKILL = join(ROOT, "skill");
1638
+ var ENGINE = join(ROOT, "dist", "wobnimo.js");
1639
+ var HELP = `wobnimo: design your own mascot
1640
+
1641
+ Usage
1642
+ npx wobnimo new <id> Start a mascot from the starter template (writes mascots/<id>.ts)
1643
+ npx wobnimo check <file> Find what would look wrong once rendered, with exact fixes
1644
+ npx wobnimo preview <file> Render every state and angle to one PNG, and check it
1645
+ npx wobnimo preview <file> --live Open a live preview that reloads as you edit
1646
+ npx wobnimo skill Add the mascot-design skill for Claude Code, Codex, Cursor and others
1647
+
1648
+ Options
1649
+ --export <name> Which export to use when a file has several characters
1650
+ --out <file> Where preview writes its PNG (default .wobnimo/<id>.png)
1651
+ --size <px> Size of each picture on the sheet (default 240)
1652
+ --json check: print the report as JSON
1653
+ --browser <path> preview: the Chrome, Edge or Chromium to use (or set WOBNIMO_BROWSER)
1654
+ --dir <path> new: folder for the file (default mascots); skill: where to install
1655
+
1656
+ Docs: https://pouryaak.github.io/wobnimo/llms.txt`;
1657
+ function parse(argv) {
1658
+ const flags = {};
1659
+ const rest = [];
1660
+ for (let i = 0; i < argv.length; i++) {
1661
+ const a = argv[i];
1662
+ if (a.startsWith("--")) {
1663
+ const [k, v] = a.slice(2).split("=");
1664
+ const next = argv[i + 1];
1665
+ if (v !== void 0) flags[k] = v;
1666
+ else if (next !== void 0 && !next.startsWith("--") && !["live", "json", "force"].includes(k)) flags[k] = argv[++i];
1667
+ else flags[k] = true;
1668
+ } else if (a === "-h") flags.help = true;
1669
+ else rest.push(a);
1670
+ }
1671
+ return { command: rest[0] ?? "help", target: rest[1], flags };
1672
+ }
1673
+ var str = (v) => typeof v === "string" ? v : void 0;
1674
+ function fail(message, code = 1) {
1675
+ process.stderr.write(`${message}
1676
+ `);
1677
+ process.exit(code);
1678
+ }
1679
+ async function main() {
1680
+ const args = parse(process.argv.slice(2));
1681
+ if (args.flags.version || args.command === "version") return console.log(JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).version);
1682
+ if (args.flags.help || args.command === "help") return console.log(HELP);
1683
+ if (args.target && needsTypeStripFlag(args.target)) {
1684
+ const r = spawnSync(process.execPath, ["--experimental-strip-types", "--no-warnings", ...process.argv.slice(1)], { stdio: "inherit" });
1685
+ process.exit(r.status ?? 1);
1686
+ }
1687
+ switch (args.command) {
1688
+ case "new":
1689
+ return create(args);
1690
+ case "check":
1691
+ return check(args);
1692
+ case "preview":
1693
+ return args.flags.live ? live(args) : preview(args);
1694
+ case "skill":
1695
+ return skill(args);
1696
+ default:
1697
+ fail(`Unknown command "${args.command}".
1698
+
1699
+ ${HELP}`);
1700
+ }
1701
+ }
1702
+ function create(args) {
1703
+ const id = args.target;
1704
+ if (!id || !/^[a-z][a-z0-9-]*$/.test(id)) fail("Give the mascot an id: lowercase letters, digits and dashes, like `npx wobnimo new sprout`.");
1705
+ const dir = resolve$1(str(args.flags.dir) ?? "mascots");
1706
+ const file = join(dir, `${id}.ts`);
1707
+ if (existsSync(file) && !args.flags.force) fail(`${relative(process.cwd(), file)} already exists. Use --force to overwrite it.`);
1708
+ const name = id.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
1709
+ const template = readFileSync(join(SKILL, "templates", "starter.ts"), "utf8").replaceAll("__ID__", id).replaceAll("__NAME__", name).replaceAll("__CONST__", id.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase()));
1710
+ mkdirSync(dir, { recursive: true });
1711
+ writeFileSync(file, template);
1712
+ console.log(`Wrote ${relative(process.cwd(), file)}: a simple round character that passes every check.
1713
+ Reshape it into yours, then run:
1714
+ npx wobnimo check ${relative(process.cwd(), file)}
1715
+ npx wobnimo preview ${relative(process.cwd(), file)}`);
1716
+ }
1717
+ async function check(args) {
1718
+ if (!args.target) fail("Which file? `npx wobnimo check mascots/sprout.ts`");
1719
+ const { spec } = await load(args);
1720
+ const report = checkCharacter(spec);
1721
+ if (args.flags.json) console.log(JSON.stringify(report, null, 2));
1722
+ else {
1723
+ console.log(formatCheck(report));
1724
+ console.log(nextStep(report, args.target));
1725
+ }
1726
+ process.exit(report.ok ? 0 : 1);
1727
+ }
1728
+ function nextStep(report, file) {
1729
+ if (!report.ok) return `
1730
+ Fix the errors (each says what to change), then run the check again.`;
1731
+ return `
1732
+ Next: npx wobnimo preview ${file}, then look at the picture.`;
1733
+ }
1734
+ async function load(args) {
1735
+ try {
1736
+ return await loadSpec(args.target, str(args.flags.export));
1737
+ } catch (e) {
1738
+ fail(e instanceof LoadError ? e.message : String(e));
1739
+ }
1740
+ }
1741
+ function summaryLine(report) {
1742
+ const n = (l) => report.issues.filter((i) => i.level === l).length;
1743
+ if (!report.issues.length) return "check: no problems found";
1744
+ return `check: ${n("error")} errors, ${n("warning")} warnings, ${n("note")} notes (run wobnimo check for details)`;
1745
+ }
1746
+ function serve(routes) {
1747
+ const server = createServer((req, res) => {
1748
+ const chunks = [];
1749
+ req.on("data", (c) => chunks.push(c));
1750
+ req.on("end", () => {
1751
+ const path = new URL(req.url ?? "/", "http://x").pathname;
1752
+ const route = routes[path];
1753
+ if (!route) {
1754
+ res.writeHead(404);
1755
+ return res.end();
1756
+ }
1757
+ route(req, res, Buffer.concat(chunks));
1758
+ });
1759
+ });
1760
+ return new Promise(
1761
+ (done) => server.listen(0, "127.0.0.1", () => {
1762
+ const { port } = server.address();
1763
+ done({ url: `http://127.0.0.1:${port}/`, close: () => server.close() });
1764
+ })
1765
+ );
1766
+ }
1767
+ var send = (type, body) => (_, res) => {
1768
+ res.writeHead(200, { "content-type": type, "cache-control": "no-store" });
1769
+ res.end(body);
1770
+ };
1771
+ function engineBundle() {
1772
+ if (!existsSync(ENGINE)) fail(`The engine bundle is missing (${ENGINE}). Reinstall wobnimo.`);
1773
+ return readFileSync(ENGINE);
1774
+ }
1775
+ async function preview(args) {
1776
+ if (!args.target) fail("Which file? `npx wobnimo preview mascots/sprout.ts`");
1777
+ const { spec } = await load(args);
1778
+ const report = checkCharacter(spec);
1779
+ const size = Math.max(96, Math.min(512, Number(str(args.flags.size) ?? 240) || 240));
1780
+ const out = resolve$1(str(args.flags.out) ?? join(".wobnimo", `${spec.id}.png`));
1781
+ if (report.issues.some((i) => i.where === "spec" || i.level === "error" && !report.extents)) {
1782
+ console.log(formatCheck(report));
1783
+ fail("\nThe spec has structural errors, so there is nothing to draw yet. Fix them first.");
1784
+ }
1785
+ let settle = () => {
1786
+ };
1787
+ const result = new Promise((r4) => settle = r4);
1788
+ const server = await serve({
1789
+ "/": send("text/html", sheetPage(size)),
1790
+ "/wobnimo.js": send("text/javascript", engineBundle()),
1791
+ "/spec.json": send("application/json", JSON.stringify(spec)),
1792
+ "/summary.json": send(
1793
+ "application/json",
1794
+ JSON.stringify({ line: summaryLine(report), errors: !report.ok, warnings: report.issues.some((i) => i.level === "warning"), closeup: report.closeup })
1795
+ ),
1796
+ "/result": (_, res, body) => {
1797
+ res.end();
1798
+ settle({ png: body });
1799
+ },
1800
+ "/error": (_, res, body) => {
1801
+ res.end();
1802
+ settle({ error: body.toString() });
1803
+ }
1804
+ });
1805
+ const browser = findBrowser(str(args.flags.browser) ?? void 0);
1806
+ let headless = null;
1807
+ if (browser) {
1808
+ headless = launchHeadless(browser, server.url);
1809
+ } else {
1810
+ console.log(`No Chrome, Edge or Chromium found to render with (set WOBNIMO_BROWSER to one).
1811
+ Opening your browser instead; the picture is saved once it has drawn. Or open ${server.url} yourself.`);
1812
+ openInBrowser(server.url);
1813
+ }
1814
+ const timeout = new Promise((r4) => setTimeout(() => r4({ error: "Timed out after 120 seconds waiting for the browser to draw." }), 12e4).unref());
1815
+ const r = await Promise.race([result, timeout]);
1816
+ headless?.close();
1817
+ server.close();
1818
+ if (r.error || !r.png) fail(`Preview failed: ${r.error}`);
1819
+ mkdirSync(dirname(out), { recursive: true });
1820
+ writeFileSync(out, r.png);
1821
+ const rel = relative(process.cwd(), out);
1822
+ console.log(`Wrote ${rel}: front, \xBE, side, back, a face close-up, nine states and a 56px avatar.
1823
+ Open it and look: is every part where you meant it, does each state read, does it look like "${spec.tagline}"?
1824
+ `);
1825
+ console.log(formatCheck(report));
1826
+ ignoreOutput();
1827
+ process.exit(0);
1828
+ }
1829
+ function ignoreOutput() {
1830
+ const dir = resolve$1(".wobnimo");
1831
+ if (existsSync(dir) && !existsSync(join(dir, ".gitignore"))) writeFileSync(join(dir, ".gitignore"), "*\n");
1832
+ }
1833
+ async function live(args) {
1834
+ if (!args.target) fail("Which file? `npx wobnimo preview mascots/sprout.ts --live`");
1835
+ let version = 0;
1836
+ let state = { version, spec: null, report: "Loading\u2026" };
1837
+ const refresh = async () => {
1838
+ version++;
1839
+ try {
1840
+ const { spec } = await loadSpec(args.target, str(args.flags.export));
1841
+ const report = checkCharacter(spec);
1842
+ state = { version, spec: report.ok || report.extents ? spec : null, report: formatCheck(report) };
1843
+ } catch (e) {
1844
+ state = { version, spec: null, report: e instanceof Error ? e.message : String(e) };
1845
+ }
1846
+ };
1847
+ await refresh();
1848
+ const server = await serve({
1849
+ "/": send("text/html", livePage()),
1850
+ "/wobnimo.js": send("text/javascript", engineBundle()),
1851
+ "/state.json": (req, res) => send("application/json", JSON.stringify(state))(req, res)
1852
+ });
1853
+ const file = resolve$1(args.target);
1854
+ let timer;
1855
+ let last = statSync(file).mtimeMs;
1856
+ watch(dirname(file), (_, name) => {
1857
+ if (name && basename(file) !== String(name)) return;
1858
+ clearTimeout(timer);
1859
+ timer = setTimeout(() => {
1860
+ try {
1861
+ const now = statSync(file).mtimeMs;
1862
+ if (now === last) return;
1863
+ last = now;
1864
+ } catch {
1865
+ return;
1866
+ }
1867
+ void refresh();
1868
+ }, 120);
1869
+ });
1870
+ console.log(`Live preview of ${args.target}: ${server.url}
1871
+ It reloads when the file changes. Ctrl+C to stop.`);
1872
+ openInBrowser(server.url);
1873
+ }
1874
+ function skill(args) {
1875
+ if (!existsSync(join(SKILL, "SKILL.md"))) fail(`The skill is missing from this install (${SKILL}).`);
1876
+ const explicit = str(args.flags.dir);
1877
+ const targets = explicit ? [resolve$1(explicit)] : [resolve$1(".claude", "skills", "wobnimo-mascot"), resolve$1(".agents", "skills", "wobnimo-mascot")];
1878
+ for (const t of targets) {
1879
+ mkdirSync(t, { recursive: true });
1880
+ cpSync(SKILL, t, { recursive: true, force: true });
1881
+ console.log(`Added the skill to ${relative(process.cwd(), t) || t}`);
1882
+ }
1883
+ console.log(`
1884
+ Now ask your agent for a mascot in one sentence, for example:
1885
+ "Use the wobnimo-mascot skill to make a mascot for this app: a sleepy cloud wearing headphones."
1886
+ Agents without skills can read ${relative(process.cwd(), join(targets[0], "SKILL.md"))} and follow it.`);
1887
+ }
1888
+ main().catch((e) => fail(e instanceof Error ? e.stack ?? e.message : String(e)));