@clawdraw/skill 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.
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Fill/texture primitives: hatchFill, crossHatch, stipple, gradientFill, colorWash, solidFill.
3
+ */
4
+
5
+ import { clamp, lerp, lerpColor, makeStroke, clipLineToRect } from './helpers.mjs';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Metadata
9
+ // ---------------------------------------------------------------------------
10
+
11
+ export const METADATA = [
12
+ {
13
+ name: 'hatchFill', description: 'Parallel line shading (hatching)', category: 'fills',
14
+ parameters: {
15
+ cx: { type: 'number', required: true }, cy: { type: 'number', required: true },
16
+ width: { type: 'number', min: 10, max: 600, default: 100, description: 'Area width' },
17
+ height: { type: 'number', min: 10, max: 600, default: 100, description: 'Area height' },
18
+ angle: { type: 'number', description: 'Line angle in degrees' },
19
+ spacing: { type: 'number', min: 2, max: 50, default: 8, description: 'Line spacing' },
20
+ color: { type: 'string' }, brushSize: { type: 'number', min: 3, max: 100 },
21
+ opacity: { type: 'number', min: 0.01, max: 1, default: 0.5 },
22
+ colorEnd: { type: 'string', description: 'End color for gradient' },
23
+ pressureStyle: { type: 'string' },
24
+ },
25
+ },
26
+ {
27
+ name: 'crossHatch', description: 'Two-angle crosshatch shading', category: 'fills',
28
+ parameters: {
29
+ cx: { type: 'number', required: true }, cy: { type: 'number', required: true },
30
+ width: { type: 'number', min: 10, max: 600, description: 'Area width' },
31
+ height: { type: 'number', min: 10, max: 600, description: 'Area height' },
32
+ spacing: { type: 'number', min: 2, max: 50, description: 'Line spacing' },
33
+ color: { type: 'string' }, brushSize: { type: 'number', min: 3, max: 100 },
34
+ opacity: { type: 'number', min: 0.01, max: 1 },
35
+ pressureStyle: { type: 'string' },
36
+ },
37
+ },
38
+ {
39
+ name: 'stipple', description: 'Random dot pattern fill', category: 'fills',
40
+ parameters: {
41
+ cx: { type: 'number', required: true }, cy: { type: 'number', required: true },
42
+ width: { type: 'number', min: 10, max: 600, default: 100, description: 'Area width' },
43
+ height: { type: 'number', min: 10, max: 600, default: 100, description: 'Area height' },
44
+ density: { type: 'number', min: 0.1, max: 1, default: 0.5, description: 'Dot density' },
45
+ color: { type: 'string' }, brushSize: { type: 'number', min: 3, max: 100 },
46
+ dotCount: { type: 'number', min: 10, max: 500, description: 'Exact dot count' },
47
+ pressureStyle: { type: 'string' },
48
+ },
49
+ },
50
+ {
51
+ name: 'gradientFill', description: 'Color gradient via stroke density', category: 'fills',
52
+ parameters: {
53
+ cx: { type: 'number', required: true }, cy: { type: 'number', required: true },
54
+ width: { type: 'number', min: 10, max: 600, default: 200, description: 'Area width' },
55
+ height: { type: 'number', min: 10, max: 600, default: 200, description: 'Area height' },
56
+ colorStart: { type: 'string', description: 'Start color' },
57
+ colorEnd: { type: 'string', description: 'End color' },
58
+ angle: { type: 'number', description: 'Gradient angle in degrees' },
59
+ density: { type: 'number', min: 0.1, max: 1, default: 0.5, description: 'Line density' },
60
+ brushSize: { type: 'number', min: 3, max: 100 },
61
+ pressureStyle: { type: 'string' },
62
+ },
63
+ },
64
+ {
65
+ name: 'colorWash', description: 'Seamless color wash fill', category: 'fills',
66
+ parameters: {
67
+ cx: { type: 'number', required: true }, cy: { type: 'number', required: true },
68
+ width: { type: 'number', min: 10, max: 800, default: 200, description: 'Area width' },
69
+ height: { type: 'number', min: 10, max: 800, default: 200, description: 'Area height' },
70
+ color: { type: 'string' }, opacity: { type: 'number', min: 0.01, max: 0.6, default: 0.35 },
71
+ pressureStyle: { type: 'string' },
72
+ },
73
+ },
74
+ {
75
+ name: 'solidFill', description: 'Solid color fill (alias for colorWash)', category: 'fills',
76
+ parameters: {
77
+ cx: { type: 'number', required: true }, cy: { type: 'number', required: true },
78
+ width: { type: 'number', min: 10, max: 800, description: 'Area width' },
79
+ height: { type: 'number', min: 10, max: 800, description: 'Area height' },
80
+ color: { type: 'string' }, opacity: { type: 'number', min: 0.01, max: 0.6 },
81
+ direction: { type: 'string', description: 'Reserved' },
82
+ pressureStyle: { type: 'string' },
83
+ },
84
+ },
85
+ ];
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Primitives
89
+ // ---------------------------------------------------------------------------
90
+
91
+ export function hatchFill(cx, cy, width, height, angle, spacing, color, brushSize, opacity, colorEnd, pressureStyle) {
92
+ cx = Number(cx) || 0; cy = Number(cy) || 0;
93
+ width = clamp(Number(width) || 100, 10, 600);
94
+ height = clamp(Number(height) || 100, 10, 600);
95
+ angle = (Number(angle) || 45) * Math.PI / 180;
96
+ spacing = clamp(Number(spacing) || 8, 2, 50);
97
+ brushSize = clamp(Number(brushSize) || 2, 3, 100);
98
+ opacity = clamp(Number(opacity) || 0.5, 0.01, 1);
99
+
100
+ const result = [];
101
+ const diag = Math.hypot(width, height) / 2;
102
+ const dx = Math.cos(angle), dy = Math.sin(angle);
103
+ const nx = -dy, ny = dx;
104
+
105
+ const totalLines = Math.ceil((diag * 2) / spacing);
106
+ let lineIdx = 0;
107
+
108
+ for (let d = -diag; d <= diag; d += spacing) {
109
+ const lineStart = { x: cx + nx * d - dx * diag, y: cy + ny * d - dy * diag };
110
+ const lineEnd = { x: cx + nx * d + dx * diag, y: cy + ny * d + dy * diag };
111
+
112
+ const t = totalLines > 1 ? lineIdx / (totalLines - 1) : 0;
113
+ const lineColor = colorEnd ? lerpColor(color || '#ffffff', colorEnd, t) : color;
114
+ lineIdx++;
115
+
116
+ const pts = clipLineToRect(lineStart, lineEnd, cx - width/2, cy - height/2, cx + width/2, cy + height/2);
117
+ if (pts) result.push(makeStroke(pts, lineColor, brushSize, opacity, pressureStyle));
118
+ if (result.length >= 200) break;
119
+ }
120
+
121
+ return result;
122
+ }
123
+
124
+ export function crossHatch(cx, cy, width, height, spacing, color, brushSize, opacity, pressureStyle) {
125
+ const s1 = hatchFill(cx, cy, width, height, 45, spacing, color, brushSize, opacity, undefined, pressureStyle);
126
+ const s2 = hatchFill(cx, cy, width, height, -45, spacing, color, brushSize, opacity, undefined, pressureStyle);
127
+ return [...s1, ...s2].slice(0, 200);
128
+ }
129
+
130
+ export function stipple(cx, cy, width, height, density, color, brushSize, dotCount, pressureStyle) {
131
+ cx = Number(cx) || 0; cy = Number(cy) || 0;
132
+ width = clamp(Number(width) || 100, 10, 600);
133
+ height = clamp(Number(height) || 100, 10, 600);
134
+ density = clamp(Number(density) || 0.5, 0.1, 1);
135
+ brushSize = clamp(Number(brushSize) || 3, 3, 100);
136
+ dotCount = clamp(Math.round(Number(dotCount) || Math.round(50 * density)), 10, 500);
137
+
138
+ const numDots = dotCount;
139
+ const result = [];
140
+ for (let i = 0; i < numDots && result.length < 200; i++) {
141
+ const x = cx + (Math.random() - 0.5) * width;
142
+ const y = cy + (Math.random() - 0.5) * height;
143
+ result.push(makeStroke(
144
+ [{ x, y }, { x: x + 1, y: y + 1 }, { x: x - 1, y }],
145
+ color, brushSize, 0.8, pressureStyle,
146
+ ));
147
+ }
148
+ return result;
149
+ }
150
+
151
+ export function gradientFill(cx, cy, width, height, colorStart, colorEnd, angle, density, brushSize, pressureStyle) {
152
+ cx = Number(cx) || 0; cy = Number(cy) || 0;
153
+ width = clamp(Number(width) || 200, 10, 600);
154
+ height = clamp(Number(height) || 200, 10, 600);
155
+ angle = (Number(angle) || 0) * Math.PI / 180;
156
+ density = clamp(Number(density) || 0.5, 0.1, 1);
157
+ brushSize = clamp(Number(brushSize) || 10, 3, 100);
158
+
159
+ const numLines = Math.round(20 * density);
160
+ const dx = Math.cos(angle), dy = Math.sin(angle);
161
+ const nx = -dy, ny = dx;
162
+ const diag = Math.max(width, height) / 2;
163
+ const result = [];
164
+
165
+ for (let i = 0; i < numLines && result.length < 200; i++) {
166
+ const t = i / (numLines - 1 || 1);
167
+ const d = lerp(-diag, diag, t);
168
+ const c = lerpColor(colorStart || '#ffffff', colorEnd || '#000000', t);
169
+ const p0 = { x: cx + nx * d - dx * diag, y: cy + ny * d - dy * diag };
170
+ const p1 = { x: cx + nx * d + dx * diag, y: cy + ny * d + dy * diag };
171
+ const pts = clipLineToRect(p0, p1, cx - width/2, cy - height/2, cx + width/2, cy + height/2);
172
+ if (pts) result.push(makeStroke(pts, c, brushSize, 0.6, pressureStyle));
173
+ }
174
+
175
+ return result;
176
+ }
177
+
178
+ export function colorWash(cx, cy, width, height, color, opacity, pressureStyle) {
179
+ cx = Number(cx) || 0; cy = Number(cy) || 0;
180
+ width = clamp(Number(width) || 200, 10, 800);
181
+ height = clamp(Number(height) || 200, 10, 800);
182
+ opacity = clamp(Number(opacity) || 0.35, 0.01, 0.6);
183
+
184
+ const result = [];
185
+
186
+ const targetStrokes = 10;
187
+ const shorter = Math.min(width, height);
188
+ const brushSize = clamp(Math.round(shorter / targetStrokes), 8, 60);
189
+ const step = Math.round(brushSize * 0.75);
190
+
191
+ const passOpacity = opacity * 0.6;
192
+ const startY = cy - height / 2;
193
+ const endY = cy + height / 2;
194
+ for (let y = startY; y <= endY && result.length < 100; y += step) {
195
+ const jitterX = (Math.random() - 0.5) * step * 0.3;
196
+ result.push(makeStroke([
197
+ { x: cx - width / 2 + jitterX, y },
198
+ { x: cx + width / 2 + jitterX, y },
199
+ ], color, brushSize, passOpacity, pressureStyle));
200
+ }
201
+
202
+ const startX = cx - width / 2;
203
+ const endX = cx + width / 2;
204
+ for (let x = startX; x <= endX && result.length < 200; x += step) {
205
+ const jitterY = (Math.random() - 0.5) * step * 0.3;
206
+ result.push(makeStroke([
207
+ { x, y: cy - height / 2 + jitterY },
208
+ { x, y: cy + height / 2 + jitterY },
209
+ ], color, brushSize, passOpacity, pressureStyle));
210
+ }
211
+
212
+ return result;
213
+ }
214
+
215
+ export function solidFill(cx, cy, width, height, color, opacity, direction, pressureStyle) {
216
+ return colorWash(cx, cy, width, height, color, opacity, pressureStyle);
217
+ }
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Flow/abstract primitives: flowField, spiral, lissajous, strangeAttractor, spirograph.
3
+ */
4
+
5
+ import { clamp, lerp, noise2d, makeStroke, splitIntoStrokes, samplePalette } from './helpers.mjs';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Metadata
9
+ // ---------------------------------------------------------------------------
10
+
11
+ export const METADATA = [
12
+ {
13
+ name: 'flowField', description: 'Perlin noise flow field particle traces', category: 'flow-abstract',
14
+ parameters: {
15
+ cx: { type: 'number', required: true }, cy: { type: 'number', required: true },
16
+ width: { type: 'number', min: 20, max: 600, default: 200, description: 'Area width (larger = bigger field)' },
17
+ height: { type: 'number', min: 20, max: 600, default: 200, description: 'Area height (larger = bigger field)' },
18
+ noiseScale: { type: 'number', min: 0.001, max: 0.1, default: 0.01, description: 'Noise frequency (0.005=smooth waves, 0.05=chaotic static)' },
19
+ density: { type: 'number', min: 0.1, max: 1, default: 0.5, description: 'Particle density (0.2=sparse lines, 0.8=dense texture)' },
20
+ segmentLength: { type: 'number', min: 1, max: 30, default: 5, description: 'Step size (2=smooth curves, 15=jagged/angular)' },
21
+ color: { type: 'string' }, brushSize: { type: 'number', min: 3, max: 100 },
22
+ palette: { type: 'string', description: 'Color palette (overrides color)' },
23
+ traceLength: { type: 'number', min: 5, max: 200, default: 40, description: 'Steps per trace (longer = longer lines)' },
24
+ pressureStyle: { type: 'string' },
25
+ },
26
+ },
27
+ {
28
+ name: 'spiral', description: 'Archimedean spiral', category: 'flow-abstract',
29
+ parameters: {
30
+ cx: { type: 'number', required: true }, cy: { type: 'number', required: true },
31
+ turns: { type: 'number', min: 0.5, max: 20, default: 3, description: 'Number of turns (higher = tighter winding)' },
32
+ startRadius: { type: 'number', min: 0, max: 500, default: 5, description: 'Inner starting radius' },
33
+ endRadius: { type: 'number', min: 1, max: 500, default: 100, description: 'Outer ending radius' },
34
+ color: { type: 'string' }, brushSize: { type: 'number', min: 3, max: 100 },
35
+ opacity: { type: 'number', min: 0.01, max: 1 },
36
+ pressureStyle: { type: 'string' },
37
+ },
38
+ },
39
+ {
40
+ name: 'lissajous', description: 'Lissajous harmonic curves', category: 'flow-abstract',
41
+ parameters: {
42
+ cx: { type: 'number', required: true }, cy: { type: 'number', required: true },
43
+ freqX: { type: 'number', min: 1, max: 20, default: 3, description: 'X frequency (loops horizontally)' },
44
+ freqY: { type: 'number', min: 1, max: 20, default: 2, description: 'Y frequency (loops vertically)' },
45
+ phase: { type: 'number', description: 'Phase offset in degrees (shifts the wave)' },
46
+ amplitude: { type: 'number', min: 10, max: 500, default: 80, description: 'Curve size (radius)' },
47
+ color: { type: 'string' }, brushSize: { type: 'number', min: 3, max: 100 },
48
+ palette: { type: 'string', description: 'Color palette (overrides color)' },
49
+ pressureStyle: { type: 'string' },
50
+ },
51
+ },
52
+ {
53
+ name: 'strangeAttractor', description: 'Strange attractor chaotic orbits (lorenz, aizawa, thomas)', category: 'flow-abstract',
54
+ parameters: {
55
+ cx: { type: 'number', required: true }, cy: { type: 'number', required: true },
56
+ type: { type: 'string', options: ['lorenz', 'aizawa', 'thomas'], default: 'lorenz', description: 'Attractor type (lorenz=butterfly, aizawa=sphere-like)' },
57
+ iterations: { type: 'number', min: 100, max: 5000, default: 2000, description: 'Point count (higher = denser cloud)' },
58
+ scale: { type: 'number', min: 0.1, max: 50, default: 5, description: 'Display scale (zoom)' },
59
+ color: { type: 'string' }, brushSize: { type: 'number', min: 3, max: 100 },
60
+ palette: { type: 'string', description: 'Color palette (overrides color)' },
61
+ timeStep: { type: 'number', min: 0.001, max: 0.02, default: 0.005, description: 'Integration step (smaller = smoother/slower)' },
62
+ pressureStyle: { type: 'string' },
63
+ },
64
+ },
65
+ {
66
+ name: 'spirograph', description: 'Spirograph (epitrochoid/hypotrochoid) geometric curves', category: 'flow-abstract',
67
+ parameters: {
68
+ cx: { type: 'number', required: true }, cy: { type: 'number', required: true },
69
+ outerR: { type: 'number', min: 10, max: 500, default: 100, description: 'Outer ring radius (primary size)' },
70
+ innerR: { type: 'number', min: 5, max: 400, default: 40, description: 'Inner ring radius (loop frequency control)' },
71
+ traceR: { type: 'number', min: 1, max: 400, default: 30, description: 'Trace point distance (loop amplitude)' },
72
+ revolutions: { type: 'number', min: 1, max: 50, default: 10, description: 'Number of revolutions (higher = denser pattern)' },
73
+ color: { type: 'string' }, brushSize: { type: 'number', min: 3, max: 100 },
74
+ palette: { type: 'string', description: 'Color palette (overrides color)' },
75
+ startAngle: { type: 'number', description: 'Starting angle in degrees' },
76
+ pressureStyle: { type: 'string' },
77
+ },
78
+ },
79
+ ];
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Primitives
83
+ // ---------------------------------------------------------------------------
84
+
85
+ export function flowField(cx, cy, width, height, noiseScale, density, segmentLength, color, brushSize, palette, traceLength, pressureStyle) {
86
+ cx = Number(cx) || 0; cy = Number(cy) || 0;
87
+ width = clamp(Number(width) || 200, 20, 600);
88
+ height = clamp(Number(height) || 200, 20, 600);
89
+ noiseScale = clamp(Number(noiseScale) || 0.01, 0.001, 0.1);
90
+ density = clamp(Number(density) || 0.5, 0.1, 1);
91
+ segmentLength = clamp(Number(segmentLength) || 5, 1, 30);
92
+ brushSize = clamp(Number(brushSize) || 2, 3, 100);
93
+
94
+ traceLength = clamp(Math.round(Number(traceLength) || 40), 5, 200);
95
+ const numParticles = Math.round(20 * density);
96
+ const stepsPerParticle = traceLength;
97
+ const result = [];
98
+
99
+ for (let p = 0; p < numParticles; p++) {
100
+ let x = cx + (Math.random() - 0.5) * width;
101
+ let y = cy + (Math.random() - 0.5) * height;
102
+ const pts = [{ x, y }];
103
+
104
+ for (let s = 0; s < stepsPerParticle; s++) {
105
+ const angle = noise2d(x * noiseScale, y * noiseScale) * Math.PI * 2;
106
+ x += Math.cos(angle) * segmentLength;
107
+ y += Math.sin(angle) * segmentLength;
108
+ pts.push({ x, y });
109
+ if (Math.abs(x - cx) > width * 0.6 || Math.abs(y - cy) > height * 0.6) break;
110
+ }
111
+ const c = palette ? samplePalette(palette, Math.random()) : color;
112
+ if (pts.length > 2) result.push(makeStroke(pts, c, brushSize, 0.7, pressureStyle));
113
+ if (result.length >= 200) break;
114
+ }
115
+
116
+ return result;
117
+ }
118
+
119
+ export function spiral(cx, cy, turns, startRadius, endRadius, color, brushSize, opacity, pressureStyle) {
120
+ cx = Number(cx) || 0; cy = Number(cy) || 0;
121
+ turns = clamp(Number(turns) || 3, 0.5, 20);
122
+ startRadius = clamp(Number(startRadius) || 5, 0, 500);
123
+ endRadius = clamp(Number(endRadius) || 100, 1, 500);
124
+ const steps = clamp(Math.round(turns * 30), 20, 2000);
125
+ const pts = [];
126
+ for (let i = 0; i <= steps; i++) {
127
+ const t = i / steps;
128
+ const a = t * turns * Math.PI * 2;
129
+ const r = lerp(startRadius, endRadius, t);
130
+ pts.push({ x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r });
131
+ }
132
+ return splitIntoStrokes(pts, color, brushSize, opacity, pressureStyle);
133
+ }
134
+
135
+ export function lissajous(cx, cy, freqX, freqY, phase, amplitude, color, brushSize, palette, pressureStyle) {
136
+ cx = Number(cx) || 0; cy = Number(cy) || 0;
137
+ freqX = clamp(Number(freqX) || 3, 1, 20);
138
+ freqY = clamp(Number(freqY) || 2, 1, 20);
139
+ phase = (Number(phase) || 0) * Math.PI / 180;
140
+ amplitude = clamp(Number(amplitude) || 80, 10, 500);
141
+
142
+ const steps = 200;
143
+ const noiseSeed = Math.random() * 100;
144
+
145
+ if (!palette) {
146
+ const pts = [];
147
+ for (let i = 0; i <= steps; i++) {
148
+ const t = (i / steps) * Math.PI * 2;
149
+ const wobble = noise2d(i * 0.05 + noiseSeed, 0) * 0.1;
150
+ pts.push({
151
+ x: cx + Math.sin(freqX * t + phase + wobble) * amplitude,
152
+ y: cy + Math.sin(freqY * t + wobble * 0.7) * amplitude,
153
+ });
154
+ }
155
+ return splitIntoStrokes(pts, color, brushSize, 0.8, pressureStyle);
156
+ }
157
+
158
+ const segments = 12;
159
+ const perSegment = Math.ceil(steps / segments) + 1;
160
+ const strokes = [];
161
+ for (let seg = 0; seg < segments; seg++) {
162
+ const pts = [];
163
+ const t0 = seg / segments;
164
+ const c = samplePalette(palette, t0);
165
+ for (let j = 0; j <= perSegment; j++) {
166
+ const i = Math.min(seg * (steps / segments) + j, steps);
167
+ const t = (i / steps) * Math.PI * 2;
168
+ const wobble = noise2d(i * 0.05 + noiseSeed, 0) * 0.1;
169
+ pts.push({
170
+ x: cx + Math.sin(freqX * t + phase + wobble) * amplitude,
171
+ y: cy + Math.sin(freqY * t + wobble * 0.7) * amplitude,
172
+ });
173
+ }
174
+ if (pts.length > 1) strokes.push(makeStroke(pts, c, brushSize, 0.8, pressureStyle));
175
+ }
176
+ return strokes;
177
+ }
178
+
179
+ export function strangeAttractor(cx, cy, type, iterations, scale, color, brushSize, palette, timeStep, pressureStyle) {
180
+ cx = Number(cx) || 0; cy = Number(cy) || 0;
181
+ type = String(type || 'lorenz').toLowerCase();
182
+ iterations = clamp(Math.round(Number(iterations) || 2000), 100, 5000);
183
+ scale = clamp(Number(scale) || 5, 0.1, 50);
184
+ brushSize = clamp(Number(brushSize) || 2, 3, 100);
185
+ timeStep = clamp(Number(timeStep) || 0.005, 0.001, 0.02);
186
+
187
+ let x = 0.1 + Math.random() * 0.05;
188
+ let y = Math.random() * 0.05;
189
+ let z = Math.random() * 0.05;
190
+ const dt = timeStep;
191
+
192
+ function step() {
193
+ let dx, dy, dz;
194
+ if (type === 'aizawa') {
195
+ const a = 0.95, b = 0.7, c = 0.6, d = 3.5, e = 0.25, f = 0.1;
196
+ dx = (z - b) * x - d * y;
197
+ dy = d * x + (z - b) * y;
198
+ dz = c + a * z - z * z * z / 3 - (x * x + y * y) * (1 + e * z) + f * z * x * x * x;
199
+ } else if (type === 'thomas') {
200
+ const b = 0.208186;
201
+ dx = Math.sin(y) - b * x;
202
+ dy = Math.sin(z) - b * y;
203
+ dz = Math.sin(x) - b * z;
204
+ } else {
205
+ const sigma = 10, rho = 28, beta = 8/3;
206
+ dx = sigma * (y - x);
207
+ dy = x * (rho - z) - y;
208
+ dz = x * y - beta * z;
209
+ }
210
+ x += dx * dt; y += dy * dt; z += dz * dt;
211
+ }
212
+
213
+ if (!palette) {
214
+ const pts = [];
215
+ for (let i = 0; i < iterations; i++) {
216
+ step();
217
+ pts.push({ x: cx + x * scale, y: cy + y * scale });
218
+ }
219
+ return splitIntoStrokes(pts, color, brushSize, 0.7, pressureStyle);
220
+ }
221
+
222
+ const segments = 10;
223
+ const perSegment = Math.floor(iterations / segments);
224
+ const strokes = [];
225
+ for (let seg = 0; seg < segments; seg++) {
226
+ const pts = [];
227
+ const t = seg / (segments - 1);
228
+ for (let i = 0; i < perSegment; i++) {
229
+ step();
230
+ pts.push({ x: cx + x * scale, y: cy + y * scale });
231
+ }
232
+ if (pts.length > 1) strokes.push(makeStroke(pts, samplePalette(palette, t), brushSize, 0.7, pressureStyle));
233
+ }
234
+ return strokes;
235
+ }
236
+
237
+ export function spirograph(cx, cy, outerR, innerR, traceR, revolutions, color, brushSize, palette, startAngle, pressureStyle) {
238
+ cx = Number(cx) || 0; cy = Number(cy) || 0;
239
+ outerR = clamp(Number(outerR) || 100, 10, 500);
240
+ innerR = clamp(Number(innerR) || 40, 5, 400);
241
+ traceR = clamp(Number(traceR) || 30, 1, 400);
242
+ revolutions = clamp(Number(revolutions) || 10, 1, 50);
243
+ brushSize = clamp(Number(brushSize) || 2, 3, 100);
244
+ startAngle = (Number(startAngle) || 0) * Math.PI / 180;
245
+
246
+ const steps = revolutions * 100;
247
+ const diff = outerR - innerR;
248
+
249
+ if (!palette) {
250
+ const pts = [];
251
+ for (let i = 0; i <= steps; i++) {
252
+ const t = (i / steps) * revolutions * Math.PI * 2 + startAngle;
253
+ const x = cx + diff * Math.cos(t) + traceR * Math.cos(t * diff / innerR);
254
+ const y = cy + diff * Math.sin(t) - traceR * Math.sin(t * diff / innerR);
255
+ pts.push({ x, y });
256
+ }
257
+ return splitIntoStrokes(pts, color, brushSize, 0.8, pressureStyle);
258
+ }
259
+
260
+ const segments = 16;
261
+ const perSegment = Math.ceil(steps / segments) + 1;
262
+ const strokes = [];
263
+ for (let seg = 0; seg < segments; seg++) {
264
+ const pts = [];
265
+ const palT = seg / (segments - 1);
266
+ for (let j = 0; j <= perSegment; j++) {
267
+ const i = Math.min(seg * Math.floor(steps / segments) + j, steps);
268
+ const t = (i / steps) * revolutions * Math.PI * 2 + startAngle;
269
+ const x = cx + diff * Math.cos(t) + traceR * Math.cos(t * diff / innerR);
270
+ const y = cy + diff * Math.sin(t) - traceR * Math.sin(t * diff / innerR);
271
+ pts.push({ x, y });
272
+ }
273
+ if (pts.length > 1) strokes.push(makeStroke(pts, samplePalette(palette, palT), brushSize, 0.8, pressureStyle));
274
+ }
275
+ return strokes;
276
+ }