foster-ts-shapes 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +242 -0
  3. package/dist/canonicalize.d.ts +2 -0
  4. package/dist/canonicalize.js +125 -0
  5. package/dist/fmt.d.ts +1 -0
  6. package/dist/fmt.js +3 -0
  7. package/dist/generate.d.ts +2 -0
  8. package/dist/generate.js +140 -0
  9. package/dist/id.d.ts +1 -0
  10. package/dist/id.js +4 -0
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.js +1 -0
  13. package/dist/mask.d.ts +2 -0
  14. package/dist/mask.js +142 -0
  15. package/dist/prng.d.ts +22 -0
  16. package/dist/prng.js +36 -0
  17. package/dist/shapes/blob.d.ts +5 -0
  18. package/dist/shapes/blob.js +35 -0
  19. package/dist/shapes/circle.d.ts +5 -0
  20. package/dist/shapes/circle.js +23 -0
  21. package/dist/shapes/octagon.d.ts +5 -0
  22. package/dist/shapes/octagon.js +24 -0
  23. package/dist/shapes/oval.d.ts +5 -0
  24. package/dist/shapes/oval.js +31 -0
  25. package/dist/shapes/polygon.d.ts +5 -0
  26. package/dist/shapes/polygon.js +23 -0
  27. package/dist/shapes/rectangle.d.ts +5 -0
  28. package/dist/shapes/rectangle.js +34 -0
  29. package/dist/shapes/square.d.ts +5 -0
  30. package/dist/shapes/square.js +34 -0
  31. package/dist/shapes/trapezoid.d.ts +5 -0
  32. package/dist/shapes/trapezoid.js +29 -0
  33. package/dist/shapes/triangle.d.ts +5 -0
  34. package/dist/shapes/triangle.js +28 -0
  35. package/dist/styling.d.ts +3 -0
  36. package/dist/styling.js +65 -0
  37. package/dist/svg.d.ts +2 -0
  38. package/dist/svg.js +22 -0
  39. package/dist/types.d.ts +121 -0
  40. package/dist/types.js +1 -0
  41. package/dist/validate.d.ts +2 -0
  42. package/dist/validate.js +266 -0
  43. package/dist/variation.d.ts +11 -0
  44. package/dist/variation.js +244 -0
  45. package/package.json +41 -0
@@ -0,0 +1,121 @@
1
+ export interface VariationFields {
2
+ distort?: number;
3
+ sizeVariance?: number;
4
+ clamp?: {
5
+ width: number;
6
+ height: number;
7
+ };
8
+ }
9
+ export interface ColorStop {
10
+ offset: number;
11
+ color: string;
12
+ opacity?: number;
13
+ }
14
+ export interface LinearGradient {
15
+ type: "linear";
16
+ x1?: number;
17
+ y1?: number;
18
+ x2?: number;
19
+ y2?: number;
20
+ stops: ColorStop[];
21
+ }
22
+ export interface RadialGradient {
23
+ type: "radial";
24
+ cx?: number;
25
+ cy?: number;
26
+ r?: number;
27
+ stops: ColorStop[];
28
+ }
29
+ export type Gradient = LinearGradient | RadialGradient;
30
+ export interface StylingFields {
31
+ fill?: string;
32
+ stroke?: string;
33
+ strokeWidth?: number;
34
+ opacity?: number;
35
+ fillGradient?: Gradient;
36
+ strokeGradient?: Gradient;
37
+ }
38
+ export interface BezierFields {
39
+ bezier?: number;
40
+ bezierDirection?: "out" | "in";
41
+ }
42
+ export interface LayerFields {
43
+ layer?: number;
44
+ }
45
+ export interface MaskFields {
46
+ mask?: MaskShape | MaskShape[];
47
+ }
48
+ export type MaskShape = Shape;
49
+ export interface GeneratorInput {
50
+ seed: number;
51
+ canvas: Canvas;
52
+ shapes: Shape[];
53
+ outputMode?: "semantic" | "path";
54
+ }
55
+ export interface Canvas {
56
+ width: number;
57
+ height: number;
58
+ }
59
+ export interface ShapeBase extends VariationFields, StylingFields, BezierFields, LayerFields, MaskFields {
60
+ type: string;
61
+ x: number;
62
+ y: number;
63
+ rotation?: number;
64
+ }
65
+ export interface SquareShape extends ShapeBase {
66
+ type: "square";
67
+ size: number;
68
+ }
69
+ export interface RectangleShape extends ShapeBase {
70
+ type: "rectangle";
71
+ width: number;
72
+ height: number;
73
+ }
74
+ export interface CircleShape extends ShapeBase {
75
+ type: "circle";
76
+ size: number;
77
+ }
78
+ export interface TriangleShape extends ShapeBase {
79
+ type: "triangle";
80
+ size: number;
81
+ }
82
+ export interface TrapezoidShape extends ShapeBase {
83
+ type: "trapezoid";
84
+ topWidth: number;
85
+ bottomWidth: number;
86
+ height: number;
87
+ }
88
+ export interface OctagonShape extends ShapeBase {
89
+ type: "octagon";
90
+ size: number;
91
+ }
92
+ export interface PolygonShape extends ShapeBase {
93
+ type: "polygon";
94
+ sides: number;
95
+ size: number;
96
+ }
97
+ export interface OvalShape extends ShapeBase {
98
+ type: "oval";
99
+ width: number;
100
+ height: number;
101
+ }
102
+ export interface BlobShape extends ShapeBase {
103
+ type: "blob";
104
+ size: number;
105
+ points?: number;
106
+ }
107
+ export type Shape = SquareShape | RectangleShape | CircleShape | TriangleShape | TrapezoidShape | OctagonShape | PolygonShape | OvalShape | BlobShape;
108
+ export interface GeneratorOutput {
109
+ svg: string;
110
+ metadata: {
111
+ shapeCount: number;
112
+ };
113
+ }
114
+ export interface ShapeElement {
115
+ tag: "rect" | "circle" | "polygon" | "path" | "ellipse";
116
+ attrs: Record<string, string | number>;
117
+ id: string;
118
+ rotation?: number;
119
+ cx: number;
120
+ cy: number;
121
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import type { GeneratorInput } from "./types.js";
2
+ export declare function validate(input: GeneratorInput): void;
@@ -0,0 +1,266 @@
1
+ const SUPPORTED_TYPES = [
2
+ "square", "rectangle", "circle", "triangle",
3
+ "trapezoid", "octagon", "polygon", "oval", "blob",
4
+ ];
5
+ function checkFinite(value, path) {
6
+ if (typeof value !== "number")
7
+ return;
8
+ if (Number.isNaN(value)) {
9
+ throw new Error(`Invalid numeric value in ${path}: NaN`);
10
+ }
11
+ if (!Number.isFinite(value)) {
12
+ throw new Error(`Invalid numeric value in ${path}: ${value}`);
13
+ }
14
+ }
15
+ function isPositiveFinite(value, path) {
16
+ checkFinite(value, path);
17
+ if (value <= 0) {
18
+ throw new Error(`${path} must be positive, got ${value}`);
19
+ }
20
+ }
21
+ function validateGradient(gradient, fieldName) {
22
+ const g = gradient;
23
+ if (g.type !== "linear" && g.type !== "radial") {
24
+ throw new Error(`gradient type must be 'linear' or 'radial'`);
25
+ }
26
+ if (!Array.isArray(g.stops) || g.stops.length === 0) {
27
+ throw new Error(`${fieldName}.stops must have at least one stop`);
28
+ }
29
+ for (const stop of g.stops) {
30
+ if (typeof stop.offset !== "number" || !Number.isFinite(stop.offset) || stop.offset < 0 || stop.offset > 1) {
31
+ throw new Error(`gradient stop offset must be a number between 0 and 1`);
32
+ }
33
+ if (typeof stop.color !== "string") {
34
+ throw new Error(`gradient stop color must be a string`);
35
+ }
36
+ if (stop.opacity !== undefined) {
37
+ if (typeof stop.opacity !== "number" || !Number.isFinite(stop.opacity) || stop.opacity < 0 || stop.opacity > 1) {
38
+ throw new Error(`gradient stop opacity must be a number between 0 and 1`);
39
+ }
40
+ }
41
+ }
42
+ if (g.type === "linear") {
43
+ const lg = g;
44
+ for (const val of [lg.x1, lg.y1, lg.x2, lg.y2]) {
45
+ if (val !== undefined && (typeof val !== "number" || !Number.isFinite(val))) {
46
+ throw new Error(`gradient coordinate must be a finite number`);
47
+ }
48
+ }
49
+ }
50
+ else {
51
+ const rg = g;
52
+ for (const val of [rg.cx, rg.cy]) {
53
+ if (val !== undefined && (typeof val !== "number" || !Number.isFinite(val))) {
54
+ throw new Error(`gradient coordinate must be a finite number`);
55
+ }
56
+ }
57
+ if (rg.r !== undefined) {
58
+ if (typeof rg.r !== "number" || !Number.isFinite(rg.r) || rg.r <= 0) {
59
+ throw new Error(`gradient r must be a positive finite number`);
60
+ }
61
+ }
62
+ }
63
+ }
64
+ function validateShape(shape, index) {
65
+ const prefix = `shapes[${index}]`;
66
+ // Type check
67
+ if (!SUPPORTED_TYPES.includes(shape.type)) {
68
+ throw new Error(`Invalid shape type "${shape.type}" in ${prefix}. Must be one of: ${SUPPORTED_TYPES.join(", ")}`);
69
+ }
70
+ // x and y required
71
+ if (shape.x === undefined || shape.x === null) {
72
+ throw new Error(`Missing required field ${prefix}.x`);
73
+ }
74
+ if (shape.y === undefined || shape.y === null) {
75
+ throw new Error(`Missing required field ${prefix}.y`);
76
+ }
77
+ // Numeric safety on x, y, rotation
78
+ checkFinite(shape.x, `${prefix}.x`);
79
+ checkFinite(shape.y, `${prefix}.y`);
80
+ if (shape.rotation !== undefined) {
81
+ checkFinite(shape.rotation, `${prefix}.rotation`);
82
+ }
83
+ // Opacity validation (Stage 3)
84
+ if (shape.opacity !== undefined) {
85
+ checkFinite(shape.opacity, `${prefix}.opacity`);
86
+ if (shape.opacity < 0 || shape.opacity > 1) {
87
+ throw new Error(`opacity must be a number between 0 and 1`);
88
+ }
89
+ }
90
+ // strokeWidth validation (Stage 3)
91
+ if (shape.strokeWidth !== undefined) {
92
+ if (typeof shape.strokeWidth !== "number" || !Number.isFinite(shape.strokeWidth) || shape.strokeWidth <= 0) {
93
+ throw new Error(`strokeWidth must be a positive finite number`);
94
+ }
95
+ }
96
+ // bezier/bezierDirection validation (Stage 3)
97
+ if (shape.bezier !== undefined) {
98
+ checkFinite(shape.bezier, `${prefix}.bezier`);
99
+ if (shape.bezier < 0 || shape.bezier > 1) {
100
+ throw new Error(`bezier must be a number between 0 and 1`);
101
+ }
102
+ }
103
+ if (shape.bezierDirection !== undefined) {
104
+ if (shape.bezierDirection !== "out" && shape.bezierDirection !== "in") {
105
+ throw new Error(`bezierDirection must be "out" or "in"`);
106
+ }
107
+ }
108
+ // Gradient validation (Stage 3)
109
+ if (shape.fillGradient !== undefined)
110
+ validateGradient(shape.fillGradient, "fillGradient");
111
+ if (shape.strokeGradient !== undefined)
112
+ validateGradient(shape.strokeGradient, "strokeGradient");
113
+ // Layer validation (Stage 4)
114
+ if (shape.layer !== undefined && !Number.isFinite(shape.layer)) {
115
+ throw new Error(`layer must be a finite number`);
116
+ }
117
+ // Mask validation (Stage 4) — each MaskShape is validated as a full shape
118
+ // mask/layer on the MaskShapes are not present after canonicalize, so no special handling needed
119
+ if (shape.mask !== undefined) {
120
+ const maskArr = Array.isArray(shape.mask) ? shape.mask : [shape.mask];
121
+ for (let mi = 0; mi < maskArr.length; mi++) {
122
+ validateShape(maskArr[mi], index);
123
+ }
124
+ }
125
+ // Variation field validation (optional on all shape types)
126
+ if (shape.distort !== undefined) {
127
+ checkFinite(shape.distort, `${prefix}.distort`);
128
+ if (shape.distort < 0 || shape.distort > 1) {
129
+ throw new Error(`distort must be a number between 0 and 1`);
130
+ }
131
+ }
132
+ if (shape.sizeVariance !== undefined) {
133
+ checkFinite(shape.sizeVariance, `${prefix}.sizeVariance`);
134
+ if (shape.sizeVariance < 0 || shape.sizeVariance > 1) {
135
+ throw new Error(`sizeVariance must be a number between 0 and 1`);
136
+ }
137
+ }
138
+ if (shape.clamp !== undefined) {
139
+ const w = shape.clamp.width;
140
+ const h = shape.clamp.height;
141
+ if (typeof w !== "number" || !Number.isFinite(w) || w <= 0) {
142
+ throw new Error(`clamp.width must be a positive finite number`);
143
+ }
144
+ if (typeof h !== "number" || !Number.isFinite(h) || h <= 0) {
145
+ throw new Error(`clamp.height must be a positive finite number`);
146
+ }
147
+ }
148
+ // Per-type validation
149
+ switch (shape.type) {
150
+ case "square": {
151
+ if (shape.size === undefined || shape.size === null) {
152
+ throw new Error(`Missing required field ${prefix}.size for square`);
153
+ }
154
+ isPositiveFinite(shape.size, `${prefix}.size`);
155
+ break;
156
+ }
157
+ case "circle": {
158
+ if (shape.size === undefined || shape.size === null) {
159
+ throw new Error(`Missing required field ${prefix}.size for circle`);
160
+ }
161
+ isPositiveFinite(shape.size, `${prefix}.size`);
162
+ break;
163
+ }
164
+ case "triangle": {
165
+ if (shape.size === undefined || shape.size === null) {
166
+ throw new Error(`Missing required field ${prefix}.size for triangle`);
167
+ }
168
+ isPositiveFinite(shape.size, `${prefix}.size`);
169
+ break;
170
+ }
171
+ case "rectangle": {
172
+ if (shape.width === undefined || shape.width === null) {
173
+ throw new Error(`Missing required field ${prefix}.width for rectangle`);
174
+ }
175
+ if (shape.height === undefined || shape.height === null) {
176
+ throw new Error(`Missing required field ${prefix}.height for rectangle`);
177
+ }
178
+ isPositiveFinite(shape.width, `${prefix}.width`);
179
+ isPositiveFinite(shape.height, `${prefix}.height`);
180
+ break;
181
+ }
182
+ case "trapezoid": {
183
+ if (shape.topWidth === undefined || shape.topWidth === null) {
184
+ throw new Error(`Missing required field ${prefix}.topWidth for trapezoid`);
185
+ }
186
+ if (shape.bottomWidth === undefined || shape.bottomWidth === null) {
187
+ throw new Error(`Missing required field ${prefix}.bottomWidth for trapezoid`);
188
+ }
189
+ if (shape.height === undefined || shape.height === null) {
190
+ throw new Error(`Missing required field ${prefix}.height for trapezoid`);
191
+ }
192
+ isPositiveFinite(shape.topWidth, `${prefix}.topWidth`);
193
+ isPositiveFinite(shape.bottomWidth, `${prefix}.bottomWidth`);
194
+ isPositiveFinite(shape.height, `${prefix}.height`);
195
+ break;
196
+ }
197
+ case "octagon": {
198
+ if (shape.size === undefined || shape.size === null) {
199
+ throw new Error(`Missing required field ${prefix}.size for octagon`);
200
+ }
201
+ isPositiveFinite(shape.size, `${prefix}.size`);
202
+ break;
203
+ }
204
+ case "polygon": {
205
+ if (shape.sides === undefined || shape.sides === null) {
206
+ throw new Error(`Missing required field ${prefix}.sides for polygon`);
207
+ }
208
+ if (shape.size === undefined || shape.size === null) {
209
+ throw new Error(`Missing required field ${prefix}.size for polygon`);
210
+ }
211
+ checkFinite(shape.sides, `${prefix}.sides`);
212
+ if (!Number.isInteger(shape.sides) || shape.sides < 3) {
213
+ throw new Error(`${prefix}.sides must be an integer >= 3, got ${shape.sides}`);
214
+ }
215
+ isPositiveFinite(shape.size, `${prefix}.size`);
216
+ break;
217
+ }
218
+ case "oval": {
219
+ if (shape.width === undefined || shape.width === null) {
220
+ throw new Error(`Missing required field ${prefix}.width for oval`);
221
+ }
222
+ if (shape.height === undefined || shape.height === null) {
223
+ throw new Error(`Missing required field ${prefix}.height for oval`);
224
+ }
225
+ isPositiveFinite(shape.width, `${prefix}.width`);
226
+ isPositiveFinite(shape.height, `${prefix}.height`);
227
+ break;
228
+ }
229
+ case "blob": {
230
+ if (shape.size === undefined || shape.size === null) {
231
+ throw new Error(`Missing required field ${prefix}.size for blob`);
232
+ }
233
+ isPositiveFinite(shape.size, `${prefix}.size`);
234
+ if (shape.points !== undefined) {
235
+ checkFinite(shape.points, `${prefix}.points`);
236
+ if (!Number.isInteger(shape.points) || shape.points < 3) {
237
+ throw new Error(`${prefix}.points must be an integer >= 3, got ${shape.points}`);
238
+ }
239
+ }
240
+ break;
241
+ }
242
+ }
243
+ }
244
+ export function validate(input) {
245
+ // seed required
246
+ if (input.seed === undefined || input.seed === null) {
247
+ throw new Error("Missing required field: seed");
248
+ }
249
+ checkFinite(input.seed, "seed");
250
+ // canvas validation
251
+ if (!input.canvas) {
252
+ throw new Error("Missing required field: canvas");
253
+ }
254
+ checkFinite(input.canvas.width, "canvas.width");
255
+ checkFinite(input.canvas.height, "canvas.height");
256
+ if (input.canvas.width <= 0) {
257
+ throw new Error(`canvas.width must be > 0, got ${input.canvas.width}`);
258
+ }
259
+ if (input.canvas.height <= 0) {
260
+ throw new Error(`canvas.height must be > 0, got ${input.canvas.height}`);
261
+ }
262
+ // shapes validation
263
+ for (let i = 0; i < input.shapes.length; i++) {
264
+ validateShape(input.shapes[i], i);
265
+ }
266
+ }
@@ -0,0 +1,11 @@
1
+ import type { Shape } from "./types.js";
2
+ export declare function charSizeOf(shape: Shape): number;
3
+ export declare function extractVertices(shape: Shape, generatorSeed: number, shapeIndex: number): [number, number][];
4
+ export declare function applyDistort(vertices: [number, number][], charSize: number, distort: number, prng: () => number): [number, number][];
5
+ export declare function applyClamp(vertices: [number, number][], cx: number, cy: number, clampWidth: number, clampHeight: number): [number, number][];
6
+ export declare function verticesToPath(vertices: [number, number][], bezier?: number, direction?: "out" | "in"): string;
7
+ export declare function applySizeVariance(shape: Shape, prng: () => number): Shape;
8
+ export declare function renderVaried(shape: Shape, varPrng: () => number, generatorSeed: number, shapeIndex: number, bezier?: number, bezierDirection?: "out" | "in"): {
9
+ tag: "path";
10
+ attrs: Record<string, string | number>;
11
+ };
@@ -0,0 +1,244 @@
1
+ import { fmt } from "./fmt.js";
2
+ import { createPrng, blobSeed } from "./prng.js";
3
+ // ---------------------------------------------------------------------------
4
+ // charSizeOf — characteristic half-size used as distortion magnitude basis
5
+ // ---------------------------------------------------------------------------
6
+ export function charSizeOf(shape) {
7
+ switch (shape.type) {
8
+ case "square": return shape.size / 2;
9
+ case "circle": return shape.size / 2;
10
+ case "triangle": return shape.size / 2;
11
+ case "octagon": return shape.size / 2;
12
+ case "polygon": return shape.size / 2;
13
+ case "blob": return shape.size / 2;
14
+ case "rectangle": return Math.min(shape.width, shape.height) / 2;
15
+ case "oval": return Math.min(shape.width, shape.height) / 2;
16
+ case "trapezoid": return Math.min(shape.topWidth, shape.bottomWidth, shape.height) / 2;
17
+ default: return 1;
18
+ }
19
+ }
20
+ // ---------------------------------------------------------------------------
21
+ // extractVertices — get [x, y] vertex list from a shape
22
+ // For circle/oval: samples 16 points around the perimeter.
23
+ // For blob: re-derives N control points using blobSeed PRNG.
24
+ // ---------------------------------------------------------------------------
25
+ export function extractVertices(shape, generatorSeed, shapeIndex) {
26
+ const { x, y } = shape;
27
+ switch (shape.type) {
28
+ case "square": {
29
+ const h = shape.size / 2;
30
+ return [[x - h, y - h], [x + h, y - h], [x + h, y + h], [x - h, y + h]];
31
+ }
32
+ case "rectangle": {
33
+ const hw = shape.width / 2, hh = shape.height / 2;
34
+ return [[x - hw, y - hh], [x + hw, y - hh], [x + hw, y + hh], [x - hw, y + hh]];
35
+ }
36
+ case "circle": {
37
+ const r = shape.size / 2;
38
+ const pts = [];
39
+ for (let k = 0; k < 16; k++) {
40
+ const angle = (k * Math.PI * 2) / 16;
41
+ pts.push([x + r * Math.cos(angle), y + r * Math.sin(angle)]);
42
+ }
43
+ return pts;
44
+ }
45
+ case "triangle": {
46
+ const h = shape.size * Math.sqrt(3) / 2;
47
+ return [
48
+ [x, y - (2 * h) / 3],
49
+ [x - shape.size / 2, y + h / 3],
50
+ [x + shape.size / 2, y + h / 3],
51
+ ];
52
+ }
53
+ case "trapezoid": {
54
+ const htw = shape.topWidth / 2, hbw = shape.bottomWidth / 2, hh = shape.height / 2;
55
+ return [
56
+ [x - htw, y - hh],
57
+ [x + htw, y - hh],
58
+ [x + hbw, y + hh],
59
+ [x - hbw, y + hh],
60
+ ];
61
+ }
62
+ case "octagon": {
63
+ const pts = [];
64
+ for (let k = 0; k < 8; k++) {
65
+ const angle = k * Math.PI / 4 - Math.PI / 2;
66
+ pts.push([x + shape.size * Math.cos(angle), y + shape.size * Math.sin(angle)]);
67
+ }
68
+ return pts;
69
+ }
70
+ case "polygon": {
71
+ const pts = [];
72
+ for (let k = 0; k < shape.sides; k++) {
73
+ const angle = (k * Math.PI * 2) / shape.sides - Math.PI / 2;
74
+ pts.push([x + shape.size * Math.cos(angle), y + shape.size * Math.sin(angle)]);
75
+ }
76
+ return pts;
77
+ }
78
+ case "oval": {
79
+ const rx = shape.width / 2, ry = shape.height / 2;
80
+ const pts = [];
81
+ for (let k = 0; k < 16; k++) {
82
+ const angle = (k * Math.PI * 2) / 16;
83
+ pts.push([x + rx * Math.cos(angle), y + ry * Math.sin(angle)]);
84
+ }
85
+ return pts;
86
+ }
87
+ case "blob": {
88
+ const n = shape.points ?? 6;
89
+ const prng = createPrng(blobSeed(generatorSeed, shapeIndex));
90
+ const pts = [];
91
+ for (let k = 0; k < n; k++) {
92
+ const angle = (k * Math.PI * 2) / n;
93
+ const radius = shape.size * (0.6 + 0.4 * prng());
94
+ pts.push([x + radius * Math.cos(angle), y + radius * Math.sin(angle)]);
95
+ }
96
+ return pts;
97
+ }
98
+ default:
99
+ return [];
100
+ }
101
+ }
102
+ // ---------------------------------------------------------------------------
103
+ // applyDistort — perturb each vertex by ±(charSize * distort)
104
+ // Consumes 2 prng() draws per vertex (dx, dy).
105
+ // ---------------------------------------------------------------------------
106
+ export function applyDistort(vertices, charSize, distort, prng) {
107
+ const mag = charSize * distort;
108
+ return vertices.map(([vx, vy]) => [
109
+ vx + (prng() * 2 - 1) * mag,
110
+ vy + (prng() * 2 - 1) * mag,
111
+ ]);
112
+ }
113
+ // ---------------------------------------------------------------------------
114
+ // applyClamp — constrain vertices to bounding box [cx±w/2, cy±h/2]
115
+ // ---------------------------------------------------------------------------
116
+ export function applyClamp(vertices, cx, cy, clampWidth, clampHeight) {
117
+ const xMin = cx - clampWidth / 2, xMax = cx + clampWidth / 2;
118
+ const yMin = cy - clampHeight / 2, yMax = cy + clampHeight / 2;
119
+ return vertices.map(([vx, vy]) => [
120
+ Math.max(xMin, Math.min(xMax, vx)),
121
+ Math.max(yMin, Math.min(yMax, vy)),
122
+ ]);
123
+ }
124
+ // ---------------------------------------------------------------------------
125
+ // verticesToPath — build path from vertices with optional bezier corner rounding.
126
+ // When bezier = 0 or absent: straight-line path M x0 y0 L x1 y1 ... Z (unchanged).
127
+ // When bezier > 0: quadratic bezier Q curves at each corner.
128
+ // Handle length: t = bezier × 0.45 (max 45% of min adjacent segment — prevents overlap).
129
+ // For each vertex v[i] with prev and next:
130
+ // P1 = v[i] + t*(prev - v[i]) (approach point on incoming edge)
131
+ // P2 = v[i] + t*(next - v[i]) (departure point on outgoing edge)
132
+ // "out" (convex): ctrl = v[i]
133
+ // "in" (concave): ctrl = P1 + P2 - v[i] (reflection through midpoint)
134
+ // Path: M P1[0] Q ctrl[0] P2[0] L P1[1] Q ctrl[1] P2[1] ... Z
135
+ // ---------------------------------------------------------------------------
136
+ export function verticesToPath(vertices, bezier, direction) {
137
+ const N = vertices.length;
138
+ if (!bezier || bezier <= 0 || N < 2) {
139
+ const [first, ...rest] = vertices;
140
+ const lines = rest.map(([vx, vy]) => `L ${fmt(vx)} ${fmt(vy)}`);
141
+ return `M ${fmt(first[0])} ${fmt(first[1])} ${lines.join(" ")} Z`;
142
+ }
143
+ const t = bezier * 0.45;
144
+ const dir = direction ?? "out";
145
+ const parts = [];
146
+ for (let i = 0; i < N; i++) {
147
+ const [vx, vy] = vertices[i];
148
+ const [px, py] = vertices[(i - 1 + N) % N];
149
+ const [nx, ny] = vertices[(i + 1) % N];
150
+ // Approach point (t-fraction from v toward prev)
151
+ const p1x = vx + t * (px - vx);
152
+ const p1y = vy + t * (py - vy);
153
+ // Departure point (t-fraction from v toward next)
154
+ const p2x = vx + t * (nx - vx);
155
+ const p2y = vy + t * (ny - vy);
156
+ // Control point
157
+ let cx, cy;
158
+ if (dir === "out") {
159
+ cx = vx;
160
+ cy = vy;
161
+ }
162
+ else {
163
+ // Reflect v through midpoint(P1, P2)
164
+ cx = p1x + p2x - vx;
165
+ cy = p1y + p2y - vy;
166
+ }
167
+ if (i === 0) {
168
+ parts.push(`M ${fmt(p1x)} ${fmt(p1y)}`);
169
+ }
170
+ else {
171
+ parts.push(`L ${fmt(p1x)} ${fmt(p1y)}`);
172
+ }
173
+ parts.push(`Q ${fmt(cx)} ${fmt(cy)} ${fmt(p2x)} ${fmt(p2y)}`);
174
+ }
175
+ parts.push("Z");
176
+ return parts.join(" ");
177
+ }
178
+ // ---------------------------------------------------------------------------
179
+ // blobVariedPath — Catmull-Rom → cubic Bézier through perturbed control points
180
+ // ---------------------------------------------------------------------------
181
+ function blobVariedPath(pts) {
182
+ const n = pts.length;
183
+ const p = (i) => pts[((i % n) + n) % n];
184
+ const segments = [];
185
+ for (let i = 0; i < n; i++) {
186
+ const [p0x, p0y] = p(i);
187
+ const [p1x, p1y] = p(i + 1);
188
+ const [pm1x, pm1y] = p(i - 1);
189
+ const [p2x, p2y] = p(i + 2);
190
+ const cp1x = p0x + (p1x - pm1x) / 6;
191
+ const cp1y = p0y + (p1y - pm1y) / 6;
192
+ const cp2x = p1x - (p2x - p0x) / 6;
193
+ const cp2y = p1y - (p2y - p0y) / 6;
194
+ segments.push(`C ${fmt(cp1x)} ${fmt(cp1y)} ${fmt(cp2x)} ${fmt(cp2y)} ${fmt(p1x)} ${fmt(p1y)}`);
195
+ }
196
+ const [startX, startY] = pts[0];
197
+ return `M ${fmt(startX)} ${fmt(startY)} ${segments.join(" ")} Z`;
198
+ }
199
+ // ---------------------------------------------------------------------------
200
+ // applySizeVariance — scale all size dimensions; consumes exactly one prng() draw.
201
+ // Returns a new shape object with scaled dimensions; all other fields unchanged.
202
+ // ---------------------------------------------------------------------------
203
+ export function applySizeVariance(shape, prng) {
204
+ const sv = shape.sizeVariance ?? 0;
205
+ if (sv === 0)
206
+ return shape;
207
+ const f = 1 + (prng() * 2 - 1) * sv;
208
+ const scale = (dim) => Math.max(1, dim * f);
209
+ switch (shape.type) {
210
+ case "square": return { ...shape, size: scale(shape.size) };
211
+ case "circle": return { ...shape, size: scale(shape.size) };
212
+ case "triangle": return { ...shape, size: scale(shape.size) };
213
+ case "octagon": return { ...shape, size: scale(shape.size) };
214
+ case "polygon": return { ...shape, size: scale(shape.size) };
215
+ case "blob": return { ...shape, size: scale(shape.size) };
216
+ case "rectangle": return { ...shape, width: scale(shape.width), height: scale(shape.height) };
217
+ case "oval": return { ...shape, width: scale(shape.width), height: scale(shape.height) };
218
+ case "trapezoid": return {
219
+ ...shape,
220
+ topWidth: scale(shape.topWidth),
221
+ bottomWidth: scale(shape.bottomWidth),
222
+ height: scale(shape.height),
223
+ };
224
+ default: return shape;
225
+ }
226
+ }
227
+ // ---------------------------------------------------------------------------
228
+ // renderVaried — full distortion pipeline (sizeVariance assumed already applied).
229
+ // extractVertices → applyDistort → applyClamp (if shape.clamp) → path
230
+ // Always returns tag: "path".
231
+ // ---------------------------------------------------------------------------
232
+ export function renderVaried(shape, varPrng, generatorSeed, shapeIndex, bezier, bezierDirection) {
233
+ const dt = shape.distort ?? 0;
234
+ const cs = charSizeOf(shape);
235
+ let vertices = extractVertices(shape, generatorSeed, shapeIndex);
236
+ vertices = applyDistort(vertices, cs, dt, varPrng);
237
+ if (shape.clamp) {
238
+ vertices = applyClamp(vertices, shape.x, shape.y, shape.clamp.width, shape.clamp.height);
239
+ }
240
+ const d = shape.type === "blob"
241
+ ? blobVariedPath(vertices)
242
+ : verticesToPath(vertices, bezier, bezierDirection);
243
+ return { tag: "path", attrs: { d } };
244
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "foster-ts-shapes",
3
+ "version": "0.1.0",
4
+ "description": "Deterministic SVG shape generation library",
5
+ "license": "MIT",
6
+ "author": "VisualFinesse",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/VisualFinesse/ShapeGenerator"
10
+ },
11
+ "keywords": [
12
+ "svg",
13
+ "shapes",
14
+ "generator",
15
+ "deterministic"
16
+ ],
17
+ "type": "module",
18
+ "main": "./dist/index.js",
19
+ "types": "./dist/index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "import": "./dist/index.js"
24
+ }
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "README.md"
29
+ ],
30
+ "scripts": {
31
+ "build": "tsc",
32
+ "test": "vitest run",
33
+ "lint": "tsc --noEmit",
34
+ "prepublishOnly": "npm run build"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^25.3.3",
38
+ "typescript": "^5.4.0",
39
+ "vitest": "^1.6.0"
40
+ }
41
+ }