cyberia 3.0.1 → 3.0.2

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 (49) hide show
  1. package/.github/workflows/engine-cyberia.cd.yml +1 -0
  2. package/CHANGELOG.md +56 -1
  3. package/CLI-HELP.md +2 -4
  4. package/README.md +139 -0
  5. package/bin/build.js +5 -0
  6. package/bin/cyberia.js +385 -71
  7. package/bin/deploy.js +18 -26
  8. package/bin/file.js +3 -0
  9. package/bin/index.js +385 -71
  10. package/conf.js +32 -3
  11. package/deployment.yaml +2 -2
  12. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  13. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  14. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  15. package/manifests/deployment/dd-test-development/deployment.yaml +2 -2
  16. package/manifests/ipfs/configmap.yaml +7 -0
  17. package/package.json +8 -8
  18. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.controller.js +2 -0
  19. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.model.js +7 -0
  20. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.service.js +93 -2
  21. package/src/api/file/file.controller.js +3 -13
  22. package/src/api/file/file.ref.json +0 -21
  23. package/src/api/ipfs/ipfs.controller.js +104 -0
  24. package/src/api/ipfs/ipfs.model.js +71 -0
  25. package/src/api/ipfs/ipfs.router.js +31 -0
  26. package/src/api/ipfs/ipfs.service.js +193 -0
  27. package/src/api/object-layer/README.md +139 -0
  28. package/src/api/object-layer/object-layer.controller.js +3 -0
  29. package/src/api/object-layer/object-layer.model.js +15 -1
  30. package/src/api/object-layer/object-layer.router.js +6 -10
  31. package/src/api/object-layer/object-layer.service.js +311 -182
  32. package/src/cli/cluster.js +30 -38
  33. package/src/cli/index.js +0 -1
  34. package/src/cli/run.js +14 -0
  35. package/src/client/components/core/LoadingAnimation.js +2 -3
  36. package/src/client/components/core/Modal.js +1 -1
  37. package/src/client/components/cyberia/ObjectLayerEngineModal.js +4 -5
  38. package/src/client/components/cyberia/ObjectLayerEngineViewer.js +280 -29
  39. package/src/client/services/ipfs/ipfs.service.js +144 -0
  40. package/src/client/services/object-layer/object-layer.management.js +161 -8
  41. package/src/index.js +1 -1
  42. package/src/runtime/express/Express.js +1 -1
  43. package/src/server/auth.js +18 -18
  44. package/src/server/ipfs-client.js +433 -0
  45. package/src/server/object-layer.js +649 -18
  46. package/src/server/semantic-layer-generator.js +1083 -0
  47. package/src/server/shape-generator.js +952 -0
  48. package/test/shape-generator.test.js +457 -0
  49. package/bin/ssl.js +0 -63
@@ -0,0 +1,952 @@
1
+ /**
2
+ * Parametric shape generator with procedural noise, seeded RNG, and arc-length resampling.
3
+ * Exports a single pure function `generateShape` that returns ordered contour points.
4
+ * @module src/server/shape-generator.js
5
+ * @namespace ShapeGenerator
6
+ */
7
+
8
+ /* ═══════════════════════════════════════════════════════════════════════════
9
+ * SEEDED PRNG – Mulberry32 (32-bit, fast, deterministic)
10
+ * ═══════════════════════════════════════════════════════════════════════════ */
11
+
12
+ /**
13
+ * Creates a seeded pseudo-random number generator (Mulberry32).
14
+ * @param {number} seed - Integer seed value.
15
+ * @returns {function(): number} Returns a function that yields 0..1 on each call.
16
+ * @memberof ShapeGenerator
17
+ */
18
+ export function createRng(seed) {
19
+ let s = seed | 0;
20
+ return function () {
21
+ s |= 0;
22
+ s = (s + 0x6d2b79f5) | 0;
23
+ let t = Math.imul(s ^ (s >>> 15), 1 | s);
24
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
25
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
26
+ };
27
+ }
28
+
29
+ /**
30
+ * Converts an arbitrary seed (string or number) into a 32-bit integer.
31
+ * @param {number|string} seed
32
+ * @returns {number}
33
+ * @memberof ShapeGenerator
34
+ */
35
+ export function seedToInt(seed) {
36
+ if (typeof seed === 'number') return seed | 0;
37
+ let h = 0;
38
+ for (let i = 0; i < seed.length; i++) {
39
+ h = (Math.imul(31, h) + seed.charCodeAt(i)) | 0;
40
+ }
41
+ return h;
42
+ }
43
+
44
+ /* ═══════════════════════════════════════════════════════════════════════════
45
+ * SIMPLEX-LIKE 2-D NOISE (value noise with smooth interpolation)
46
+ * ═══════════════════════════════════════════════════════════════════════════ */
47
+
48
+ /**
49
+ * Creates a 2D value-noise function seeded by the provided RNG.
50
+ * Uses smooth Hermite interpolation for organic feel.
51
+ * @param {function(): number} rng - Seeded random number generator.
52
+ * @returns {function(number, number): number} Noise function returning -1..1.
53
+ * @memberof ShapeGenerator
54
+ */
55
+ export function createNoise2D(rng) {
56
+ const SIZE = 256;
57
+ const perm = new Uint8Array(SIZE * 2);
58
+ const grad = new Float64Array(SIZE);
59
+
60
+ // Fill gradient table
61
+ for (let i = 0; i < SIZE; i++) {
62
+ grad[i] = rng() * 2 - 1;
63
+ }
64
+
65
+ // Fill permutation table
66
+ const p = Array.from({ length: SIZE }, (_, i) => i);
67
+ for (let i = SIZE - 1; i > 0; i--) {
68
+ const j = (rng() * (i + 1)) | 0;
69
+ [p[i], p[j]] = [p[j], p[i]];
70
+ }
71
+ for (let i = 0; i < SIZE * 2; i++) perm[i] = p[i & (SIZE - 1)];
72
+
73
+ function fade(t) {
74
+ return t * t * t * (t * (t * 6 - 15) + 10);
75
+ }
76
+
77
+ function lerp(a, b, t) {
78
+ return a + t * (b - a);
79
+ }
80
+
81
+ function hash(ix, iy) {
82
+ return perm[(perm[ix & (SIZE - 1)] + iy) & (SIZE - 1)];
83
+ }
84
+
85
+ return function noise2D(x, y) {
86
+ const ix = Math.floor(x);
87
+ const iy = Math.floor(y);
88
+ const fx = x - ix;
89
+ const fy = y - iy;
90
+
91
+ const u = fade(fx);
92
+ const v = fade(fy);
93
+
94
+ const g00 = grad[hash(ix, iy) & (SIZE - 1)];
95
+ const g10 = grad[hash(ix + 1, iy) & (SIZE - 1)];
96
+ const g01 = grad[hash(ix, iy + 1) & (SIZE - 1)];
97
+ const g11 = grad[hash(ix + 1, iy + 1) & (SIZE - 1)];
98
+
99
+ const n0 = lerp(g00, g10, u);
100
+ const n1 = lerp(g01, g11, u);
101
+ return lerp(n0, n1, v);
102
+ };
103
+ }
104
+
105
+ /* ═══════════════════════════════════════════════════════════════════════════
106
+ * PARAMETRIC SHAPE REGISTRY
107
+ * Each shape function receives (t, params) where t ∈ [0, 1]
108
+ * and returns {x, y} in arbitrary local coordinates.
109
+ * ═══════════════════════════════════════════════════════════════════════════ */
110
+
111
+ /**
112
+ * @typedef {Object} ShapePoint
113
+ * @property {number} x
114
+ * @property {number} y
115
+ * @memberof ShapeGenerator
116
+ */
117
+
118
+ /**
119
+ * Registry of parametric shape functions.
120
+ * Each entry maps a key string to a function (t, params) → {x, y}.
121
+ * @type {Object<string, function(number, Object): ShapePoint>}
122
+ * @memberof ShapeGenerator
123
+ */
124
+ const shapeRegistry = {};
125
+
126
+ /**
127
+ * Registers a new parametric shape function.
128
+ * @param {string} key - Unique shape identifier.
129
+ * @param {function(number, Object): ShapePoint} fn - Parametric function.
130
+ * @memberof ShapeGenerator
131
+ */
132
+ export function registerShape(key, fn) {
133
+ shapeRegistry[key] = fn;
134
+ }
135
+
136
+ // ---- Built-in shapes -------------------------------------------------------
137
+
138
+ registerShape('circle', (t, params) => {
139
+ const r = params.r ?? 1;
140
+ const angle = t * Math.PI * 2;
141
+ return { x: r * Math.cos(angle), y: r * Math.sin(angle) };
142
+ });
143
+
144
+ registerShape('ellipse', (t, params) => {
145
+ const a = params.a ?? 1;
146
+ const b = params.b ?? 0.6;
147
+ const angle = t * Math.PI * 2;
148
+ return { x: a * Math.cos(angle), y: b * Math.sin(angle) };
149
+ });
150
+
151
+ registerShape('parabola', (t, params) => {
152
+ const a = params.a ?? 1;
153
+ const rangeX = params.rangeX ?? 2;
154
+ const x = (t - 0.5) * rangeX;
155
+ return { x, y: a * x * x };
156
+ });
157
+
158
+ registerShape('heart', (t) => {
159
+ const angle = t * Math.PI * 2;
160
+ const x = 16 * Math.pow(Math.sin(angle), 3);
161
+ const y = 13 * Math.cos(angle) - 5 * Math.cos(2 * angle) - 2 * Math.cos(3 * angle) - Math.cos(4 * angle);
162
+ return { x: x / 17, y: -y / 17 }; // normalize roughly to -1..1 and flip y
163
+ });
164
+
165
+ registerShape('skull-bone', (t) => {
166
+ // Skull-and-crossbones: cranium dome, two eyes, nose V, mouth with teeth,
167
+ // goatee/chin, jaw, flattened X-bones with knobs.
168
+ // All segment endpoints are carefully matched so there are no stray diagonal lines.
169
+ const PI = Math.PI;
170
+ const mix = (ax, ay, bx, by, f) => ({ x: ax + (bx - ax) * f, y: ay + (by - ay) * f });
171
+
172
+ const crR = 0.6; // cranium radius
173
+ const crCY = -0.1; // cranium vertical center (shifted up a bit for room)
174
+ const eyeR = 0.13; // eye socket radius
175
+ const eyeY = 0.01; // eye socket center Y
176
+ const eyeRX = 0.22; // right eye center X
177
+ const eyeLX = -0.22; // left eye center X
178
+ const noseY = 0.27; // nose tip Y
179
+ const mouthY = 0.38; // mouth line Y
180
+ const mouthW = 0.22; // mouth half-width
181
+ const teethN = 3; // number of teeth
182
+ const goateeY = 0.52; // goatee tip Y
183
+ const goateeW = 0.08; // goatee half-width at top
184
+ const jawW = 0.34; // jaw half-width
185
+ const jawY = 0.46; // jaw bottom Y
186
+ const boneStartY = 0.56; // bone origin Y (just below jaw)
187
+ const crossY = 0.68; // bone crossing Y (flatter — closer to start/end)
188
+ const endY = 0.78; // bone knob center Y (much closer to crossY)
189
+ const boneX = 0.52; // bone knob spread X (wider for flatter angle)
190
+ const knob = 0.06; // knob radius
191
+ const crA0 = PI * 1.15; // cranium start angle (left temple)
192
+ const crA1 = PI * 1.85; // cranium end angle (right temple)
193
+
194
+ // Key anchor points for seamless transitions:
195
+ // Right eye: inner-bottom at angle ~(PI*0.75) from center, outer-top at angle ~(-PI*0.35)
196
+ // Left eye: inner-bottom at angle ~(PI*0.25) from center, outer-top at angle ~(-PI*0.65+2PI)
197
+ const reInnerA = PI * 0.75; // right eye angle toward nose (inner-bottom)
198
+ const reOuterA = -PI * 0.35; // right eye angle toward right temple (outer-top)
199
+ const leInnerA = PI * 0.25; // left eye angle toward nose (inner-bottom)
200
+ const leOuterA = PI + PI * 0.35; // left eye angle toward left temple (outer-top)
201
+
202
+ // Precomputed anchor coords
203
+ const rTempleX = Math.cos(crA1) * crR;
204
+ const rTempleY = crCY + Math.sin(crA1) * crR;
205
+ const reOuterX = eyeRX + Math.cos(reOuterA) * eyeR;
206
+ const reOuterY = eyeY + Math.sin(reOuterA) * eyeR;
207
+ const reInnerX = eyeRX + Math.cos(reInnerA) * eyeR;
208
+ const reInnerY = eyeY + Math.sin(reInnerA) * eyeR;
209
+ const leInnerX = eyeLX + Math.cos(leInnerA) * eyeR;
210
+ const leInnerY = eyeY + Math.sin(leInnerA) * eyeR;
211
+ const leOuterX = eyeLX + Math.cos(leOuterA) * eyeR;
212
+ const leOuterY = eyeY + Math.sin(leOuterA) * eyeR;
213
+ const lTempleX = Math.cos(crA0) * crR;
214
+ const lTempleY = crCY + Math.sin(crA0) * crR;
215
+
216
+ // 0.00–0.14 : cranium dome (left temple → top → right temple)
217
+ if (t < 0.14) {
218
+ const a = crA0 + (t / 0.14) * (crA1 - crA0);
219
+ return { x: Math.cos(a) * crR, y: crCY + Math.sin(a) * crR };
220
+ }
221
+ // 0.14–0.16 : right temple → right eye outer-top (short connector)
222
+ if (t < 0.16) {
223
+ return mix(rTempleX, rTempleY, reOuterX, reOuterY, (t - 0.14) / 0.02);
224
+ }
225
+ // 0.16–0.22 : right eye socket (from outer-top, clockwise full circle back to outer-top)
226
+ if (t < 0.22) {
227
+ const a = reOuterA - ((t - 0.16) / 0.06) * PI * 2;
228
+ return { x: eyeRX + Math.cos(a) * eyeR, y: eyeY + Math.sin(a) * eyeR };
229
+ }
230
+ // 0.22–0.23 : right eye outer-top → right eye inner-bottom (short connector along bottom)
231
+ if (t < 0.23) {
232
+ // small arc along bottom of right eye from outer to inner
233
+ const aStart = reOuterA - PI * 2; // same as reOuterA but after full circle
234
+ const aEnd = reInnerA;
235
+ // go the short way: outer-top → bottom → inner-bottom
236
+ const a = reOuterA + ((t - 0.22) / 0.01) * (reInnerA - reOuterA);
237
+ return { x: eyeRX + Math.cos(a) * eyeR, y: eyeY + Math.sin(a) * eyeR };
238
+ }
239
+ // 0.23–0.27 : nose V (inner-bottom of right eye → nose tip → inner-bottom of left eye)
240
+ if (t < 0.27) {
241
+ const s = (t - 0.23) / 0.04;
242
+ if (s < 0.5) return mix(reInnerX, reInnerY, 0, noseY, s / 0.5);
243
+ return mix(0, noseY, leInnerX, leInnerY, (s - 0.5) / 0.5);
244
+ }
245
+ // 0.27–0.28 : left eye inner-bottom → left eye outer-top (short connector along bottom)
246
+ if (t < 0.28) {
247
+ const a = leInnerA + ((t - 0.27) / 0.01) * (leOuterA - leInnerA);
248
+ return { x: eyeLX + Math.cos(a) * eyeR, y: eyeY + Math.sin(a) * eyeR };
249
+ }
250
+ // 0.28–0.34 : left eye socket (from outer-top, counter-clockwise full circle)
251
+ if (t < 0.34) {
252
+ const a = leOuterA + ((t - 0.28) / 0.06) * PI * 2;
253
+ return { x: eyeLX + Math.cos(a) * eyeR, y: eyeY + Math.sin(a) * eyeR };
254
+ }
255
+ // 0.34–0.36 : left eye outer-top → left temple (short connector)
256
+ if (t < 0.36) {
257
+ return mix(leOuterX, leOuterY, lTempleX, lTempleY, (t - 0.34) / 0.02);
258
+ }
259
+ // 0.36–0.38 : left temple → mouth start (travel down along left cheek to mouth)
260
+ if (t < 0.38) {
261
+ return mix(lTempleX, lTempleY, -mouthW, mouthY, (t - 0.36) / 0.02);
262
+ }
263
+ // 0.38–0.44 : mouth with teeth (zigzag line from left to right)
264
+ if (t < 0.44) {
265
+ const s = (t - 0.38) / 0.06; // 0..1 across mouth
266
+ const x = -mouthW + s * mouthW * 2;
267
+ // zigzag teeth: go up/down based on segment
268
+ const toothPhase = s * teethN * 2; // each tooth = up + down
269
+ const inTooth = toothPhase % 2;
270
+ const toothAmp = 0.05;
271
+ const y = mouthY + (inTooth < 1 ? -toothAmp * inTooth : -toothAmp * (2 - inTooth));
272
+ return { x, y };
273
+ }
274
+ // 0.44–0.49 : goatee / chin tuft (down to point and back)
275
+ if (t < 0.49) {
276
+ const s = (t - 0.44) / 0.05;
277
+ if (s < 0.33) {
278
+ // from mouth right end → goatee left start → down to tip
279
+ return mix(0, mouthY + 0.04, 0, goateeY, s / 0.33);
280
+ }
281
+ if (s < 0.66) {
282
+ // goatee tip → back up, widening
283
+ return mix(0, goateeY, goateeW, mouthY + 0.06, (s - 0.33) / 0.33);
284
+ }
285
+ // close back to center bottom
286
+ return mix(goateeW, mouthY + 0.06, 0, jawY, (s - 0.66) / 0.34);
287
+ }
288
+ // 0.49–0.51 : center jaw → left jaw corner
289
+ if (t < 0.51) {
290
+ return mix(0, jawY, -jawW, jawY, (t - 0.49) / 0.02);
291
+ }
292
+ // 0.51–0.53 : left jaw corner → center below jaw (bone start)
293
+ if (t < 0.53) {
294
+ const s = (t - 0.51) / 0.02;
295
+ return mix(-jawW, jawY, 0, boneStartY, s);
296
+ }
297
+ // 0.53–0.565 : left-down bone arm → left-bottom knob arc
298
+ if (t < 0.545) {
299
+ return mix(0, boneStartY, -boneX, endY, (t - 0.53) / 0.015);
300
+ }
301
+ if (t < 0.565) {
302
+ const a = PI * 0.65 + ((t - 0.545) / 0.02) * PI * 1.7;
303
+ return { x: -boneX + Math.cos(a) * knob, y: endY + Math.sin(a) * knob };
304
+ }
305
+ // 0.565–0.605 : left-bottom knob → cross center → right-bottom knob
306
+ if (t < 0.585) {
307
+ return mix(-boneX, endY, 0, crossY, (t - 0.565) / 0.02);
308
+ }
309
+ if (t < 0.605) {
310
+ return mix(0, crossY, boneX, endY, (t - 0.585) / 0.02);
311
+ }
312
+ // 0.605–0.64 : right-bottom knob arc → back up to center
313
+ if (t < 0.625) {
314
+ const a = PI * 0.35 - ((t - 0.605) / 0.02) * PI * 1.7;
315
+ return { x: boneX + Math.cos(a) * knob, y: endY + Math.sin(a) * knob };
316
+ }
317
+ if (t < 0.64) {
318
+ return mix(boneX, endY, 0, boneStartY, (t - 0.625) / 0.015);
319
+ }
320
+ // --- second bone of the X (top-left to bottom-right, crossing the first) ---
321
+ // 0.64–0.675 : center → left-top knob
322
+ if (t < 0.655) {
323
+ return mix(0, boneStartY, -boneX, boneStartY, (t - 0.64) / 0.015);
324
+ }
325
+ if (t < 0.675) {
326
+ const a = -PI * 0.35 - ((t - 0.655) / 0.02) * PI * 1.7;
327
+ return { x: -boneX + Math.cos(a) * knob, y: boneStartY + Math.sin(a) * knob };
328
+ }
329
+ // 0.675–0.715 : left-top knob → cross center → right-top knob area
330
+ if (t < 0.695) {
331
+ return mix(-boneX, boneStartY, 0, crossY, (t - 0.675) / 0.02);
332
+ }
333
+ if (t < 0.715) {
334
+ return mix(0, crossY, boneX, boneStartY, (t - 0.695) / 0.02);
335
+ }
336
+ // 0.715–0.75 : right-top knob arc → back to center
337
+ if (t < 0.735) {
338
+ const a = -PI * 0.65 + ((t - 0.715) / 0.02) * PI * 1.7;
339
+ return { x: boneX + Math.cos(a) * knob, y: boneStartY + Math.sin(a) * knob };
340
+ }
341
+ if (t < 0.75) {
342
+ return mix(boneX, boneStartY, 0, boneStartY, (t - 0.735) / 0.015);
343
+ }
344
+ // 0.75–0.78 : center below jaw → right jaw corner
345
+ if (t < 0.78) {
346
+ return mix(0, boneStartY, jawW, jawY, (t - 0.75) / 0.03);
347
+ }
348
+ // 0.78–0.80 : right jaw corner → right temple
349
+ if (t < 0.8) {
350
+ return mix(jawW, jawY, rTempleX, rTempleY, (t - 0.78) / 0.02);
351
+ }
352
+ // 0.80–1.00 : closing arc around cranium bottom back to left temple
353
+ {
354
+ const s = (t - 0.8) / 0.2;
355
+ const a = crA1 + s * (crA0 + PI * 2 - crA1);
356
+ return { x: Math.cos(a) * crR, y: crCY + Math.sin(a) * crR };
357
+ }
358
+ });
359
+
360
+ registerShape('star', (t, params) => {
361
+ const spikes = params.spikes ?? 5;
362
+ const outerR = params.outerR ?? 1;
363
+ const innerR = params.innerR ?? 0.4;
364
+ const angle = t * Math.PI * 2;
365
+ // Alternate between outer and inner radius based on angular position
366
+ const segAngle = Math.PI / spikes;
367
+ const mod = ((angle % (2 * segAngle)) + 2 * segAngle) % (2 * segAngle);
368
+ const r =
369
+ mod < segAngle
370
+ ? outerR + (innerR - outerR) * (mod / segAngle)
371
+ : innerR + (outerR - innerR) * ((mod - segAngle) / segAngle);
372
+ return { x: r * Math.cos(angle), y: r * Math.sin(angle) };
373
+ });
374
+
375
+ registerShape('cactus', (t) => {
376
+ // Organic cactus silhouette using additive sine harmonics
377
+ const angle = t * Math.PI * 2;
378
+ const r = 0.5 + 0.15 * Math.sin(3 * angle) + 0.1 * Math.sin(5 * angle) + 0.08 * Math.cos(7 * angle);
379
+ return { x: r * Math.cos(angle), y: r * Math.sin(angle) };
380
+ });
381
+
382
+ registerShape('pixel-art', (t, params) => {
383
+ // Generates a blocky pixel-art shape — a rounded square with stepped edges
384
+ const gridSize = params.gridSize ?? 8;
385
+ const angle = t * Math.PI * 2;
386
+ // Superellipse (Lamé curve) for rounded-square feel
387
+ const n = params.n ?? 4;
388
+ const cosA = Math.cos(angle);
389
+ const sinA = Math.sin(angle);
390
+ const r = 1 / Math.pow(Math.pow(Math.abs(cosA), n) + Math.pow(Math.abs(sinA), n), 1 / n);
391
+ let x = r * cosA;
392
+ let y = r * sinA;
393
+ // Snap to grid for pixel-art effect
394
+ x = Math.round(x * gridSize) / gridSize;
395
+ y = Math.round(y * gridSize) / gridSize;
396
+ return { x, y };
397
+ });
398
+
399
+ registerShape('bezier-path', (t, params) => {
400
+ // Cubic Bézier through default control points (or user-supplied)
401
+ const p0 = params.p0 ?? { x: 0, y: 0 };
402
+ const p1 = params.p1 ?? { x: 0.3, y: 1 };
403
+ const p2 = params.p2 ?? { x: 0.7, y: 1 };
404
+ const p3 = params.p3 ?? { x: 1, y: 0 };
405
+ const u = 1 - t;
406
+ const x = u * u * u * p0.x + 3 * u * u * t * p1.x + 3 * u * t * t * p2.x + t * t * t * p3.x;
407
+ const y = u * u * u * p0.y + 3 * u * u * t * p1.y + 3 * u * t * t * p2.y + t * t * t * p3.y;
408
+ return { x, y };
409
+ });
410
+
411
+ /* ═══════════════════════════════════════════════════════════════════════════
412
+ * GEOMETRY HELPERS
413
+ * ═══════════════════════════════════════════════════════════════════════════ */
414
+
415
+ /**
416
+ * Arc-length resamples a parametric curve to produce `count` evenly-spaced points.
417
+ * @param {function(number): ShapePoint} curveFn - Function from t→{x,y}.
418
+ * @param {number} count - Number of output points.
419
+ * @param {boolean} closed - If true, the curve wraps so last point connects to first.
420
+ * @returns {ShapePoint[]}
421
+ * @memberof ShapeGenerator
422
+ */
423
+ function arcLengthResample(curveFn, count, closed) {
424
+ // 1. Sample at high resolution to build cumulative arc-length table
425
+ const SAMPLES = Math.max(count * 8, 2000);
426
+ const rawPoints = [];
427
+ for (let i = 0; i <= SAMPLES; i++) {
428
+ rawPoints.push(curveFn(i / SAMPLES));
429
+ }
430
+
431
+ const cumLen = new Float64Array(SAMPLES + 1);
432
+ cumLen[0] = 0;
433
+ for (let i = 1; i <= SAMPLES; i++) {
434
+ const dx = rawPoints[i].x - rawPoints[i - 1].x;
435
+ const dy = rawPoints[i].y - rawPoints[i - 1].y;
436
+ cumLen[i] = cumLen[i - 1] + Math.sqrt(dx * dx + dy * dy);
437
+ }
438
+
439
+ const totalLen = cumLen[SAMPLES];
440
+ if (totalLen === 0) {
441
+ // Degenerate — all points overlap
442
+ const p = rawPoints[0];
443
+ return Array.from({ length: count }, () => ({ x: p.x, y: p.y }));
444
+ }
445
+
446
+ // 2. Walk the arc-length table to pick `count` evenly-spaced distances
447
+ const numSegments = closed ? count : count - 1;
448
+ const step = totalLen / (numSegments || 1);
449
+ const result = [];
450
+ let idx = 0;
451
+
452
+ for (let i = 0; i < count; i++) {
453
+ const target = i * step;
454
+ while (idx < SAMPLES - 1 && cumLen[idx + 1] < target) idx++;
455
+ // Clamp so we never read past rawPoints[SAMPLES]
456
+ const safeIdx = Math.min(idx, SAMPLES - 1);
457
+ // Linear interpolation between safeIdx and safeIdx+1
458
+ const segLen = cumLen[safeIdx + 1] - cumLen[safeIdx];
459
+ const frac = segLen > 0 ? (target - cumLen[safeIdx]) / segLen : 0;
460
+ const x = rawPoints[safeIdx].x + frac * (rawPoints[safeIdx + 1].x - rawPoints[safeIdx].x);
461
+ const y = rawPoints[safeIdx].y + frac * (rawPoints[safeIdx + 1].y - rawPoints[safeIdx].y);
462
+ result.push({ x, y });
463
+ }
464
+
465
+ return result;
466
+ }
467
+
468
+ /**
469
+ * Applies 2D rotation (in radians) to a set of points around their centroid.
470
+ * @param {ShapePoint[]} points
471
+ * @param {number} radians
472
+ * @returns {ShapePoint[]}
473
+ * @memberof ShapeGenerator
474
+ */
475
+ function rotatePoints(points, radians) {
476
+ if (radians === 0) return points;
477
+ // Compute centroid
478
+ let cx = 0,
479
+ cy = 0;
480
+ for (const p of points) {
481
+ cx += p.x;
482
+ cy += p.y;
483
+ }
484
+ cx /= points.length;
485
+ cy /= points.length;
486
+
487
+ const cosR = Math.cos(radians);
488
+ const sinR = Math.sin(radians);
489
+ return points.map((p) => {
490
+ const dx = p.x - cx;
491
+ const dy = p.y - cy;
492
+ return {
493
+ x: cx + dx * cosR - dy * sinR,
494
+ y: cy + dx * sinR + dy * cosR,
495
+ };
496
+ });
497
+ }
498
+
499
+ /**
500
+ * Scales points from their centroid.
501
+ * @param {ShapePoint[]} points
502
+ * @param {number} sx - X scale factor.
503
+ * @param {number} sy - Y scale factor.
504
+ * @returns {ShapePoint[]}
505
+ * @memberof ShapeGenerator
506
+ */
507
+ function scalePoints(points, sx, sy) {
508
+ if (sx === 1 && sy === 1) return points;
509
+ let cx = 0,
510
+ cy = 0;
511
+ for (const p of points) {
512
+ cx += p.x;
513
+ cy += p.y;
514
+ }
515
+ cx /= points.length;
516
+ cy /= points.length;
517
+ return points.map((p) => ({
518
+ x: cx + (p.x - cx) * sx,
519
+ y: cy + (p.y - cy) * sy,
520
+ }));
521
+ }
522
+
523
+ /**
524
+ * Normalizes points into the 0..1 bounding box.
525
+ * @param {ShapePoint[]} points
526
+ * @returns {ShapePoint[]}
527
+ * @memberof ShapeGenerator
528
+ */
529
+ function normalizePoints(points) {
530
+ let minX = Infinity,
531
+ minY = Infinity,
532
+ maxX = -Infinity,
533
+ maxY = -Infinity;
534
+ for (const p of points) {
535
+ if (p.x < minX) minX = p.x;
536
+ if (p.y < minY) minY = p.y;
537
+ if (p.x > maxX) maxX = p.x;
538
+ if (p.y > maxY) maxY = p.y;
539
+ }
540
+ const rangeX = maxX - minX || 1;
541
+ const rangeY = maxY - minY || 1;
542
+ // Uniform scale to fit inside [0..1] while preserving aspect ratio
543
+ const range = Math.max(rangeX, rangeY);
544
+ const offsetX = (range - rangeX) / 2;
545
+ const offsetY = (range - rangeY) / 2;
546
+ return points.map((p) => ({
547
+ x: (p.x - minX + offsetX) / range,
548
+ y: (p.y - minY + offsetY) / range,
549
+ }));
550
+ }
551
+
552
+ /**
553
+ * Computes the axis-aligned bounding box for a set of points.
554
+ * @param {ShapePoint[]} points
555
+ * @returns {{minX: number, minY: number, maxX: number, maxY: number}}
556
+ * @memberof ShapeGenerator
557
+ */
558
+ function computeBbox(points) {
559
+ let minX = Infinity,
560
+ minY = Infinity,
561
+ maxX = -Infinity,
562
+ maxY = -Infinity;
563
+ for (const p of points) {
564
+ if (p.x < minX) minX = p.x;
565
+ if (p.y < minY) minY = p.y;
566
+ if (p.x > maxX) maxX = p.x;
567
+ if (p.y > maxY) maxY = p.y;
568
+ }
569
+ return { minX, minY, maxX, maxY };
570
+ }
571
+
572
+ /**
573
+ * Converts normalized (0..1) points to pixel coordinates.
574
+ * @param {ShapePoint[]} points - Normalized points.
575
+ * @param {number} width - Target pixel width.
576
+ * @param {number} height - Target pixel height.
577
+ * @returns {ShapePoint[]}
578
+ * @memberof ShapeGenerator
579
+ */
580
+ export function toPixelCoords(points, width, height) {
581
+ return points.map((p) => ({
582
+ x: p.x * width,
583
+ y: p.y * height,
584
+ }));
585
+ }
586
+
587
+ /* ═══════════════════════════════════════════════════════════════════════════
588
+ * BRESENHAM RASTERIZATION – connected integer-grid contour
589
+ * ═══════════════════════════════════════════════════════════════════════════ */
590
+
591
+ /**
592
+ * Bresenham line algorithm — returns all integer grid cells between two points.
593
+ * @param {number} x0
594
+ * @param {number} y0
595
+ * @param {number} x1
596
+ * @param {number} y1
597
+ * @returns {ShapePoint[]}
598
+ * @memberof ShapeGenerator
599
+ */
600
+ function bresenhamLine(x0, y0, x1, y1) {
601
+ const pts = [];
602
+ let dx = Math.abs(x1 - x0);
603
+ let dy = -Math.abs(y1 - y0);
604
+ const sx = x0 < x1 ? 1 : -1;
605
+ const sy = y0 < y1 ? 1 : -1;
606
+ let err = dx + dy;
607
+
608
+ for (;;) {
609
+ pts.push({ x: x0, y: y0 });
610
+ if (x0 === x1 && y0 === y1) break;
611
+ const e2 = 2 * err;
612
+ if (e2 >= dy) {
613
+ err += dy;
614
+ x0 += sx;
615
+ }
616
+ if (e2 <= dx) {
617
+ err += dx;
618
+ y0 += sy;
619
+ }
620
+ }
621
+ return pts;
622
+ }
623
+
624
+ /**
625
+ * Rasterizes a contour of normalized (0..1) points onto an integer grid,
626
+ * using Bresenham lines between consecutive points so the outline is fully
627
+ * connected — even on grids as small as 16×16.
628
+ *
629
+ * @param {ShapePoint[]} normalizedPoints - Points in 0..1.
630
+ * @param {number} gridW - Grid width in cells.
631
+ * @param {number} gridH - Grid height in cells.
632
+ * @param {boolean} closed - Whether to connect last point back to first.
633
+ * @returns {ShapePoint[]} Unique integer {x,y} cells ordered along the contour.
634
+ * @memberof ShapeGenerator
635
+ */
636
+ function rasterizeContour(normalizedPoints, gridW, gridH, closed) {
637
+ const maxX = gridW - 1;
638
+ const maxY = gridH - 1;
639
+
640
+ // Map normalized → grid integers
641
+ const gridPts = normalizedPoints.map((p) => ({
642
+ x: Math.round(Math.min(Math.max(p.x, 0), 1) * maxX),
643
+ y: Math.round(Math.min(Math.max(p.y, 0), 1) * maxY),
644
+ }));
645
+
646
+ // Bresenham between every consecutive pair (+ wrap if closed)
647
+ const allCells = [];
648
+ const len = gridPts.length;
649
+ const segments = closed ? len : len - 1;
650
+
651
+ for (let i = 0; i < segments; i++) {
652
+ const a = gridPts[i];
653
+ const b = gridPts[(i + 1) % len];
654
+ const lineCells = bresenhamLine(a.x, a.y, b.x, b.y);
655
+ // Append all except the last cell (it will be the first of the next segment)
656
+ // to avoid trivial duplicates at joints.
657
+ for (let j = 0; j < lineCells.length - 1; j++) {
658
+ allCells.push(lineCells[j]);
659
+ }
660
+ // For the very last segment, include its endpoint too
661
+ if (i === segments - 1) {
662
+ allCells.push(lineCells[lineCells.length - 1]);
663
+ }
664
+ }
665
+
666
+ // Deduplicate while preserving contour order
667
+ const seen = new Set();
668
+ const unique = [];
669
+ for (const p of allCells) {
670
+ const k = p.x * 100000 + p.y;
671
+ if (!seen.has(k)) {
672
+ seen.add(k);
673
+ unique.push(p);
674
+ }
675
+ }
676
+
677
+ return unique;
678
+ }
679
+
680
+ /**
681
+ * Renders an integer-coordinate ShapeResult as an ASCII grid string.
682
+ * Useful for quick visual verification of pixel-art shapes.
683
+ *
684
+ * @param {ShapeResult} shapeResult - Result from generateShape with intCoords enabled.
685
+ * @param {object} [opts] - Render options.
686
+ * @param {string} [opts.filled='█'] - Character for filled cells.
687
+ * @param {string} [opts.empty='·'] - Character for empty cells.
688
+ * @returns {string} Multi-line ASCII grid.
689
+ * @memberof ShapeGenerator
690
+ */
691
+ export function renderGrid(shapeResult, opts = {}) {
692
+ const { filled = '█', empty = '·' } = opts;
693
+ const { points, metadata } = shapeResult;
694
+ const w = metadata.gridWidth;
695
+ const h = metadata.gridHeight;
696
+
697
+ if (w == null || h == null) {
698
+ throw new Error('renderGrid requires a ShapeResult generated with intCoords enabled.');
699
+ }
700
+
701
+ // Build set of occupied cells
702
+ const occupied = new Set();
703
+ for (const p of points) {
704
+ occupied.add(p.y * w + p.x);
705
+ }
706
+
707
+ const rows = [];
708
+ for (let y = 0; y < h; y++) {
709
+ let row = '';
710
+ for (let x = 0; x < w; x++) {
711
+ row += occupied.has(y * w + x) ? filled : empty;
712
+ }
713
+ rows.push(row);
714
+ }
715
+ return rows.join('\n');
716
+ }
717
+
718
+ /* ═══════════════════════════════════════════════════════════════════════════
719
+ * MAIN PUBLIC API
720
+ * ═══════════════════════════════════════════════════════════════════════════ */
721
+
722
+ /**
723
+ * @typedef {Object} ShapeOptions
724
+ * @property {number} [count=200] - Number of XY points to generate.
725
+ * @property {number|number[]} [scale=1] - Uniform number or [sx, sy] scale factors.
726
+ * @property {number} [rotation=0] - Rotation in degrees.
727
+ * @property {number} [jitter=0] - Max random offset in normalized units (0..1).
728
+ * @property {number} [noise=0] - 0..1 strength of procedural noise displacement.
729
+ * @property {number|string} [seed] - Seed for deterministic randomness.
730
+ * @property {boolean} [closed=true] - Whether last point connects to the first.
731
+ * @property {boolean} [normalize=true] - Return points in 0..1 coordinate box.
732
+ * @property {string} [color] - RGBA or hex color metadata.
733
+ * @property {boolean|number|number[]} [intCoords=false] - Integer pixel-art grid mode.
734
+ * - `true` → use a grid of size `count × count` (or `count` as single dim).
735
+ * - `number` (e.g. 16) → square grid of that size (`16×16`).
736
+ * - `[width, height]` → rectangular grid.
737
+ * When enabled, output points are unique integer {x,y} pairs in `0..gridW-1`
738
+ * with Bresenham rasterization so that even on a 16×16 grid the full contour
739
+ * is visible as a connected set of cells. `count` is ignored in this mode;
740
+ * the number of output points equals the number of unique rasterized cells.
741
+ * @memberof ShapeGenerator
742
+ */
743
+
744
+ /**
745
+ * @typedef {Object} ShapeBbox
746
+ * @property {number} minX
747
+ * @property {number} minY
748
+ * @property {number} maxX
749
+ * @property {number} maxY
750
+ * @memberof ShapeGenerator
751
+ */
752
+
753
+ /**
754
+ * @typedef {Object} ShapeMetadata
755
+ * @property {string} key - The shape key used.
756
+ * @property {number} count - Number of points generated.
757
+ * @property {number} seed - Effective integer seed used.
758
+ * @property {string} [color] - Color metadata if provided.
759
+ * @property {ShapeBbox} [bbox] - Bounding box of the output points.
760
+ * @property {number} [gridWidth] - Grid width when intCoords is used.
761
+ * @property {number} [gridHeight] - Grid height when intCoords is used.
762
+ * @memberof ShapeGenerator
763
+ */
764
+
765
+ /**
766
+ * @typedef {Object} ShapeResult
767
+ * @property {ShapePoint[]} points - Ordered contour points.
768
+ * @property {ShapeMetadata} metadata - Descriptive metadata about the generation.
769
+ * @memberof ShapeGenerator
770
+ */
771
+
772
+ /**
773
+ * Generates a parametric shape as an array of ordered contour points.
774
+ *
775
+ * @param {string} key - Shape identifier (e.g. "circle", "heart", "star").
776
+ * @param {ShapeOptions} [options={}] - Generation options.
777
+ * @returns {ShapeResult} The generated shape data.
778
+ * @throws {Error} If the requested shape key is not registered.
779
+ * @memberof ShapeGenerator
780
+ *
781
+ * @example
782
+ * import { generateShape } from './shape-generator.js';
783
+ *
784
+ * const result = generateShape('heart', {
785
+ * count: 300,
786
+ * scale: 0.9,
787
+ * rotation: 10,
788
+ * jitter: 0.002,
789
+ * noise: 0.15,
790
+ * seed: 'fx-42',
791
+ * color: 'rgba(220,20,60,1)',
792
+ * });
793
+ * // result.points -> [{x: 0.51, y: 0.86}, ...]
794
+ */
795
+ export function generateShape(key, options = {}) {
796
+ const shapeFn = shapeRegistry[key];
797
+ if (!shapeFn) {
798
+ const available = Object.keys(shapeRegistry).join(', ');
799
+ throw new Error(`Unknown shape key "${key}". Available shapes: ${available}`);
800
+ }
801
+
802
+ // ---- Destructure options with defaults ------------------------------------
803
+ const {
804
+ count = 200,
805
+ scale = 1,
806
+ rotation = 0,
807
+ jitter = 0,
808
+ noise = 0,
809
+ seed: rawSeed,
810
+ closed = true,
811
+ normalize = true,
812
+ intCoords = false,
813
+ color,
814
+ ...extraParams
815
+ } = options;
816
+
817
+ // ---- Parse intCoords into grid dimensions ---------------------------------
818
+ let gridW = 0;
819
+ let gridH = 0;
820
+ const useIntCoords = intCoords !== false && intCoords !== 0;
821
+
822
+ if (useIntCoords) {
823
+ if (Array.isArray(intCoords)) {
824
+ gridW = intCoords[0] | 0;
825
+ gridH = intCoords[1] | 0;
826
+ } else if (typeof intCoords === 'number') {
827
+ gridW = intCoords | 0;
828
+ gridH = intCoords | 0;
829
+ } else {
830
+ // true → derive from count, minimum 8 to be useful
831
+ gridW = Math.max(count, 8) | 0;
832
+ gridH = gridW;
833
+ }
834
+ if (gridW < 2 || gridH < 2) {
835
+ throw new Error(`intCoords grid must be at least 2×2, got ${gridW}×${gridH}`);
836
+ }
837
+ }
838
+
839
+ // When rasterizing, oversample the parametric curve so small grids get
840
+ // enough raw points for Bresenham to trace the full contour.
841
+ const sampleCount = useIntCoords ? Math.max(count, (gridW + gridH) * 4) : count;
842
+
843
+ // ---- Seeded RNG -----------------------------------------------------------
844
+ const intSeed = rawSeed != null ? seedToInt(rawSeed) : (Math.random() * 0x7fffffff) | 0;
845
+ const rng = createRng(intSeed);
846
+
847
+ // ---- Noise function -------------------------------------------------------
848
+ let noise2D = null;
849
+ if (noise > 0) {
850
+ noise2D = createNoise2D(rng);
851
+ }
852
+
853
+ // ---- Scale factors --------------------------------------------------------
854
+ const sx = Array.isArray(scale) ? scale[0] : scale;
855
+ const sy = Array.isArray(scale) ? scale[1] : scale;
856
+
857
+ // ---- Build raw parametric curve function -----------------------------------
858
+ const curveFn = (t) => shapeFn(t, extraParams);
859
+
860
+ // ---- Arc-length resample to evenly-spaced points --------------------------
861
+ let points = arcLengthResample(curveFn, sampleCount, closed);
862
+
863
+ // ---- Apply scale ----------------------------------------------------------
864
+ points = scalePoints(points, sx, sy);
865
+
866
+ // ---- Apply rotation (degrees → radians) -----------------------------------
867
+ if (rotation !== 0) {
868
+ points = rotatePoints(points, (rotation * Math.PI) / 180);
869
+ }
870
+
871
+ // ---- Apply procedural noise displacement ----------------------------------
872
+ if (noise > 0 && noise2D) {
873
+ const noiseScale = 3; // frequency multiplier
874
+ points = points.map((p, i) => {
875
+ const t = i / sampleCount;
876
+ const nx = noise2D(t * noiseScale * 10, 0.0) * noise * 0.1;
877
+ const ny = noise2D(0.0, t * noiseScale * 10) * noise * 0.1;
878
+ return { x: p.x + nx, y: p.y + ny };
879
+ });
880
+ }
881
+
882
+ // ---- Apply jitter ---------------------------------------------------------
883
+ if (jitter > 0) {
884
+ points = points.map((p) => ({
885
+ x: p.x + (rng() * 2 - 1) * jitter,
886
+ y: p.y + (rng() * 2 - 1) * jitter,
887
+ }));
888
+ }
889
+
890
+ // ---- Normalize to 0..1 (always needed before intCoords rasterization) -----
891
+ if (normalize || useIntCoords) {
892
+ points = normalizePoints(points);
893
+ }
894
+
895
+ // ---- Integer grid rasterization (Bresenham) -------------------------------
896
+ if (useIntCoords) {
897
+ points = rasterizeContour(points, gridW, gridH, closed);
898
+
899
+ // Build metadata for integer grid mode
900
+ const bbox = computeBbox(points);
901
+ /** @type {ShapeMetadata} */
902
+ const metadata = {
903
+ key,
904
+ count: points.length,
905
+ seed: intSeed,
906
+ gridWidth: gridW,
907
+ gridHeight: gridH,
908
+ bbox: {
909
+ minX: bbox.minX,
910
+ minY: bbox.minY,
911
+ maxX: bbox.maxX,
912
+ maxY: bbox.maxY,
913
+ },
914
+ };
915
+ if (color != null) metadata.color = color;
916
+
917
+ return { points, metadata };
918
+ }
919
+
920
+ // ---- Round for cleanliness (6 decimal places) -----------------------------
921
+ points = points.map((p) => ({
922
+ x: Math.round(p.x * 1e6) / 1e6,
923
+ y: Math.round(p.y * 1e6) / 1e6,
924
+ }));
925
+
926
+ // ---- Build metadata -------------------------------------------------------
927
+ const bbox = computeBbox(points);
928
+ /** @type {ShapeMetadata} */
929
+ const metadata = {
930
+ key,
931
+ count: points.length,
932
+ seed: intSeed,
933
+ bbox: {
934
+ minX: Math.round(bbox.minX * 1e6) / 1e6,
935
+ minY: Math.round(bbox.minY * 1e6) / 1e6,
936
+ maxX: Math.round(bbox.maxX * 1e6) / 1e6,
937
+ maxY: Math.round(bbox.maxY * 1e6) / 1e6,
938
+ },
939
+ };
940
+ if (color != null) metadata.color = color;
941
+
942
+ return { points, metadata };
943
+ }
944
+
945
+ /**
946
+ * Returns the list of currently registered shape keys.
947
+ * @returns {string[]}
948
+ * @memberof ShapeGenerator
949
+ */
950
+ export function listShapes() {
951
+ return Object.keys(shapeRegistry);
952
+ }