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.
- package/.github/workflows/engine-cyberia.cd.yml +1 -0
- package/CHANGELOG.md +56 -1
- package/CLI-HELP.md +2 -4
- package/README.md +139 -0
- package/bin/build.js +5 -0
- package/bin/cyberia.js +385 -71
- package/bin/deploy.js +18 -26
- package/bin/file.js +3 -0
- package/bin/index.js +385 -71
- package/conf.js +32 -3
- package/deployment.yaml +2 -2
- package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/manifests/deployment/dd-test-development/deployment.yaml +2 -2
- package/manifests/ipfs/configmap.yaml +7 -0
- package/package.json +8 -8
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.controller.js +2 -0
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.model.js +7 -0
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.service.js +93 -2
- package/src/api/file/file.controller.js +3 -13
- package/src/api/file/file.ref.json +0 -21
- package/src/api/ipfs/ipfs.controller.js +104 -0
- package/src/api/ipfs/ipfs.model.js +71 -0
- package/src/api/ipfs/ipfs.router.js +31 -0
- package/src/api/ipfs/ipfs.service.js +193 -0
- package/src/api/object-layer/README.md +139 -0
- package/src/api/object-layer/object-layer.controller.js +3 -0
- package/src/api/object-layer/object-layer.model.js +15 -1
- package/src/api/object-layer/object-layer.router.js +6 -10
- package/src/api/object-layer/object-layer.service.js +311 -182
- package/src/cli/cluster.js +30 -38
- package/src/cli/index.js +0 -1
- package/src/cli/run.js +14 -0
- package/src/client/components/core/LoadingAnimation.js +2 -3
- package/src/client/components/core/Modal.js +1 -1
- package/src/client/components/cyberia/ObjectLayerEngineModal.js +4 -5
- package/src/client/components/cyberia/ObjectLayerEngineViewer.js +280 -29
- package/src/client/services/ipfs/ipfs.service.js +144 -0
- package/src/client/services/object-layer/object-layer.management.js +161 -8
- package/src/index.js +1 -1
- package/src/runtime/express/Express.js +1 -1
- package/src/server/auth.js +18 -18
- package/src/server/ipfs-client.js +433 -0
- package/src/server/object-layer.js +649 -18
- package/src/server/semantic-layer-generator.js +1083 -0
- package/src/server/shape-generator.js +952 -0
- package/test/shape-generator.test.js +457 -0
- 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
|
+
}
|