@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,342 @@
1
+ /**
2
+ * Utility primitives: bezierCurve, dashedLine, arrow, strokeText, alienGlyphs.
3
+ */
4
+
5
+ import { clamp, lerp, makeStroke, splitIntoStrokes } from './helpers.mjs';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Metadata
9
+ // ---------------------------------------------------------------------------
10
+
11
+ export const METADATA = [
12
+ {
13
+ name: 'bezierCurve', description: 'Smooth Bezier curve through control points', category: 'utility',
14
+ parameters: {
15
+ points: { type: 'array', required: true, description: 'Array of {x, y} control points (max 20)' },
16
+ color: { type: 'string' }, brushSize: { type: 'number', min: 3, max: 100 },
17
+ opacity: { type: 'number', min: 0.01, max: 1 },
18
+ pressureStyle: { type: 'string' },
19
+ },
20
+ },
21
+ {
22
+ name: 'dashedLine', description: 'Dashed line segment', category: 'utility',
23
+ parameters: {
24
+ startX: { type: 'number', required: true }, startY: { type: 'number', required: true },
25
+ endX: { type: 'number', required: true }, endY: { type: 'number', required: true },
26
+ dashLength: { type: 'number', min: 2, max: 50, default: 10, description: 'Dash length' },
27
+ gapLength: { type: 'number', min: 1, max: 50, default: 5, description: 'Gap length' },
28
+ color: { type: 'string' }, brushSize: { type: 'number', min: 3, max: 100 },
29
+ pressureStyle: { type: 'string' },
30
+ },
31
+ },
32
+ {
33
+ name: 'arrow', description: 'Line with arrowhead', category: 'utility',
34
+ parameters: {
35
+ startX: { type: 'number', required: true }, startY: { type: 'number', required: true },
36
+ endX: { type: 'number', required: true }, endY: { type: 'number', required: true },
37
+ headSize: { type: 'number', min: 3, max: 60, default: 15, description: 'Arrowhead size' },
38
+ color: { type: 'string' }, brushSize: { type: 'number', min: 3, max: 100 },
39
+ pressureStyle: { type: 'string' },
40
+ },
41
+ },
42
+ {
43
+ name: 'strokeText', description: 'Draw text as single-stroke letterforms', category: 'utility',
44
+ parameters: {
45
+ cx: { type: 'number', required: true }, cy: { type: 'number', required: true },
46
+ text: { type: 'string', required: true, description: 'Text to draw (max 40 chars)' },
47
+ charHeight: { type: 'number', min: 5, max: 200, default: 30, description: 'Character height' },
48
+ color: { type: 'string' }, brushSize: { type: 'number', min: 3, max: 100 },
49
+ opacity: { type: 'number', min: 0.01, max: 1 },
50
+ rotation: { type: 'number', description: 'Rotation in degrees' },
51
+ pressureStyle: { type: 'string' },
52
+ },
53
+ },
54
+ {
55
+ name: 'alienGlyphs', description: 'Procedural cryptic alien/AI glyphs', category: 'utility',
56
+ parameters: {
57
+ cx: { type: 'number', required: true }, cy: { type: 'number', required: true },
58
+ count: { type: 'number', min: 1, max: 20, default: 8, description: 'Number of glyphs' },
59
+ glyphSize: { type: 'number', min: 5, max: 100, default: 25, description: 'Glyph size' },
60
+ arrangement: { type: 'string', options: ['line', 'grid', 'scatter', 'circle'], default: 'line', description: 'Layout arrangement' },
61
+ color: { type: 'string' }, brushSize: { type: 'number', min: 3, max: 100 },
62
+ opacity: { type: 'number', min: 0.01, max: 1 },
63
+ pressureStyle: { type: 'string' },
64
+ },
65
+ },
66
+ ];
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Glyph map for strokeText
70
+ // ---------------------------------------------------------------------------
71
+
72
+ const GLYPH_MAP = {
73
+ 'A': [[[0,1],[0.5,0],[1,1]],[[0.2,0.6],[0.8,0.6]]],
74
+ 'B': [[[0,1],[0,0],[0.7,0],[0.9,0.15],[0.9,0.35],[0.7,0.5],[0,0.5]],[[0.7,0.5],[0.9,0.65],[0.9,0.85],[0.7,1],[0,1]]],
75
+ 'C': [[[1,0.1],[0.7,0],[0.3,0],[0,0.3],[0,0.7],[0.3,1],[0.7,1],[1,0.9]]],
76
+ 'D': [[[0,0],[0,1],[0.6,1],[0.9,0.8],[1,0.5],[0.9,0.2],[0.6,0],[0,0]]],
77
+ 'E': [[[1,0],[0,0],[0,0.5],[0.7,0.5]],[[0,0.5],[0,1],[1,1]]],
78
+ 'F': [[[1,0],[0,0],[0,0.5],[0.7,0.5]],[[0,0.5],[0,1]]],
79
+ 'G': [[[1,0.1],[0.7,0],[0.3,0],[0,0.3],[0,0.7],[0.3,1],[0.7,1],[1,0.7],[1,0.5],[0.5,0.5]]],
80
+ 'H': [[[0,0],[0,1]],[[1,0],[1,1]],[[0,0.5],[1,0.5]]],
81
+ 'I': [[[0.3,0],[0.7,0]],[[0.5,0],[0.5,1]],[[0.3,1],[0.7,1]]],
82
+ 'J': [[[0.3,0],[0.8,0]],[[0.6,0],[0.6,0.8],[0.4,1],[0.2,0.9]]],
83
+ 'K': [[[0,0],[0,1]],[[1,0],[0,0.5],[1,1]]],
84
+ 'L': [[[0,0],[0,1],[1,1]]],
85
+ 'M': [[[0,1],[0,0],[0.5,0.4],[1,0],[1,1]]],
86
+ 'N': [[[0,1],[0,0],[1,1],[1,0]]],
87
+ 'O': [[[0.3,0],[0.7,0],[1,0.3],[1,0.7],[0.7,1],[0.3,1],[0,0.7],[0,0.3],[0.3,0]]],
88
+ 'P': [[[0,1],[0,0],[0.7,0],[1,0.15],[1,0.35],[0.7,0.5],[0,0.5]]],
89
+ 'Q': [[[0.3,0],[0.7,0],[1,0.3],[1,0.7],[0.7,1],[0.3,1],[0,0.7],[0,0.3],[0.3,0]],[[0.6,0.8],[1,1.1]]],
90
+ 'R': [[[0,1],[0,0],[0.7,0],[1,0.15],[1,0.35],[0.7,0.5],[0,0.5]],[[0.5,0.5],[1,1]]],
91
+ 'S': [[[1,0.1],[0.7,0],[0.3,0],[0,0.15],[0,0.35],[0.3,0.5],[0.7,0.5],[1,0.65],[1,0.85],[0.7,1],[0.3,1],[0,0.9]]],
92
+ 'T': [[[0,0],[1,0]],[[0.5,0],[0.5,1]]],
93
+ 'U': [[[0,0],[0,0.7],[0.3,1],[0.7,1],[1,0.7],[1,0]]],
94
+ 'V': [[[0,0],[0.5,1],[1,0]]],
95
+ 'W': [[[0,0],[0.25,1],[0.5,0.5],[0.75,1],[1,0]]],
96
+ 'X': [[[0,0],[1,1]],[[1,0],[0,1]]],
97
+ 'Y': [[[0,0],[0.5,0.5],[1,0]],[[0.5,0.5],[0.5,1]]],
98
+ 'Z': [[[0,0],[1,0],[0,1],[1,1]]],
99
+ '0': [[[0.3,0],[0.7,0],[1,0.3],[1,0.7],[0.7,1],[0.3,1],[0,0.7],[0,0.3],[0.3,0]],[[0.1,0.8],[0.9,0.2]]],
100
+ '1': [[[0.3,0.2],[0.5,0]],[[0.5,0],[0.5,1]],[[0.3,1],[0.7,1]]],
101
+ '2': [[[0,0.15],[0.3,0],[0.7,0],[1,0.15],[1,0.35],[0,1],[1,1]]],
102
+ '3': [[[0,0.1],[0.3,0],[0.7,0],[1,0.15],[1,0.35],[0.7,0.5],[0.5,0.5]],[[0.7,0.5],[1,0.65],[1,0.85],[0.7,1],[0.3,1],[0,0.9]]],
103
+ '4': [[[0,0],[0,0.5],[1,0.5]],[[0.7,0],[0.7,1]]],
104
+ '5': [[[1,0],[0,0],[0,0.5],[0.7,0.5],[1,0.65],[1,0.85],[0.7,1],[0.3,1],[0,0.9]]],
105
+ '6': [[[0.7,0],[0.3,0],[0,0.3],[0,0.7],[0.3,1],[0.7,1],[1,0.7],[1,0.5],[0.7,0.4],[0,0.5]]],
106
+ '7': [[[0,0],[1,0],[0.3,1]]],
107
+ '8': [[[0.3,0],[0.7,0],[1,0.15],[1,0.35],[0.7,0.5],[0.3,0.5],[0,0.15],[0,0.35],[0.3,0.5]],[[0.3,0.5],[0,0.65],[0,0.85],[0.3,1],[0.7,1],[1,0.85],[1,0.65],[0.7,0.5]]],
108
+ '9': [[[1,0.5],[0.3,0.6],[0,0.3],[0,0.15],[0.3,0],[0.7,0],[1,0.3],[1,0.7],[0.7,1],[0.3,1]]],
109
+ ' ': [],
110
+ '.': [[[0.4,0.9],[0.6,0.9],[0.6,1],[0.4,1],[0.4,0.9]]],
111
+ ',': [[[0.5,0.85],[0.5,1],[0.3,1.15]]],
112
+ '!': [[[0.5,0],[0.5,0.7]],[[0.45,0.9],[0.55,0.9],[0.55,1],[0.45,1],[0.45,0.9]]],
113
+ '?': [[[0.1,0.15],[0.3,0],[0.7,0],[0.9,0.15],[0.9,0.35],[0.5,0.55],[0.5,0.7]],[[0.45,0.9],[0.55,0.9],[0.55,1],[0.45,1],[0.45,0.9]]],
114
+ '-': [[[0.2,0.5],[0.8,0.5]]],
115
+ ':': [[[0.45,0.25],[0.55,0.25],[0.55,0.35],[0.45,0.35],[0.45,0.25]],[[0.45,0.65],[0.55,0.65],[0.55,0.75],[0.45,0.75],[0.45,0.65]]],
116
+ '/': [[[0.1,1],[0.9,0]]],
117
+ '\'': [[[0.45,0],[0.5,0.2]]],
118
+ };
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Primitives
122
+ // ---------------------------------------------------------------------------
123
+
124
+ export function bezierCurve(points, color, brushSize, opacity, pressureStyle) {
125
+ if (!Array.isArray(points) || points.length < 2) return [];
126
+ const cps = points.slice(0, 20).map(p => ({ x: Number(p.x) || 0, y: Number(p.y) || 0 }));
127
+ brushSize = clamp(Number(brushSize) || 5, 3, 100);
128
+
129
+ const result = [];
130
+ const stepsPerSeg = 12;
131
+ for (let i = 0; i < cps.length - 1; i++) {
132
+ const p0 = cps[Math.max(0, i - 1)];
133
+ const p1 = cps[i];
134
+ const p2 = cps[Math.min(cps.length - 1, i + 1)];
135
+ const p3 = cps[Math.min(cps.length - 1, i + 2)];
136
+ for (let s = 0; s < stepsPerSeg; s++) {
137
+ const t = s / stepsPerSeg;
138
+ const t2 = t * t, t3 = t2 * t;
139
+ result.push({
140
+ x: 0.5 * (2*p1.x + (-p0.x+p2.x)*t + (2*p0.x-5*p1.x+4*p2.x-p3.x)*t2 + (-p0.x+3*p1.x-3*p2.x+p3.x)*t3),
141
+ y: 0.5 * (2*p1.y + (-p0.y+p2.y)*t + (2*p0.y-5*p1.y+4*p2.y-p3.y)*t2 + (-p0.y+3*p1.y-3*p2.y+p3.y)*t3),
142
+ });
143
+ }
144
+ }
145
+ result.push(cps[cps.length - 1]);
146
+
147
+ return splitIntoStrokes(result, color, brushSize, opacity, pressureStyle);
148
+ }
149
+
150
+ export function dashedLine(startX, startY, endX, endY, dashLength, gapLength, color, brushSize, pressureStyle) {
151
+ startX = Number(startX) || 0; startY = Number(startY) || 0;
152
+ endX = Number(endX) || 100; endY = Number(endY) || 0;
153
+ dashLength = clamp(Number(dashLength) || 10, 2, 50);
154
+ gapLength = clamp(Number(gapLength) || 5, 1, 50);
155
+ brushSize = clamp(Number(brushSize) || 3, 3, 100);
156
+
157
+ const total = Math.hypot(endX - startX, endY - startY);
158
+ if (total < 1) return [];
159
+ const dx = (endX - startX) / total, dy = (endY - startY) / total;
160
+
161
+ const result = [];
162
+ let d = 0;
163
+ while (d < total && result.length < 200) {
164
+ const end = Math.min(d + dashLength, total);
165
+ result.push(makeStroke([
166
+ { x: startX + dx * d, y: startY + dy * d },
167
+ { x: startX + dx * end, y: startY + dy * end },
168
+ ], color, brushSize, 0.9, pressureStyle));
169
+ d = end + gapLength;
170
+ }
171
+ return result;
172
+ }
173
+
174
+ export function arrow(startX, startY, endX, endY, headSize, color, brushSize, pressureStyle) {
175
+ startX = Number(startX) || 0; startY = Number(startY) || 0;
176
+ endX = Number(endX) || 100; endY = Number(endY) || 0;
177
+ headSize = clamp(Number(headSize) || 15, 3, 60);
178
+ brushSize = clamp(Number(brushSize) || 3, 3, 100);
179
+
180
+ const dx = endX - startX, dy = endY - startY;
181
+ const angle = Math.atan2(dy, dx);
182
+ const ha = Math.PI * 0.8;
183
+
184
+ const result = [];
185
+ result.push(makeStroke([{ x: startX, y: startY }, { x: endX, y: endY }], color, brushSize, 0.9, pressureStyle));
186
+ result.push(makeStroke([
187
+ { x: endX + Math.cos(angle + Math.PI - ha) * headSize, y: endY + Math.sin(angle + Math.PI - ha) * headSize },
188
+ { x: endX, y: endY },
189
+ { x: endX + Math.cos(angle + Math.PI + ha) * headSize, y: endY + Math.sin(angle + Math.PI + ha) * headSize },
190
+ ], color, brushSize, 0.9, pressureStyle));
191
+
192
+ return result;
193
+ }
194
+
195
+ export function strokeText(cx, cy, text, charHeight, color, brushSize, opacity, rotation, pressureStyle) {
196
+ cx = Number(cx) || 0; cy = Number(cy) || 0;
197
+ text = String(text || 'HELLO').toUpperCase().slice(0, 40);
198
+ charHeight = clamp(Number(charHeight) || 30, 5, 200);
199
+ brushSize = clamp(Number(brushSize) || 3, 3, 100);
200
+ opacity = clamp(Number(opacity) || 0.9, 0.01, 1);
201
+ rotation = (Number(rotation) || 0) * Math.PI / 180;
202
+
203
+ const charWidth = charHeight * 0.7;
204
+ const spacing = charHeight * 0.15;
205
+ const totalWidth = text.length * (charWidth + spacing) - spacing;
206
+
207
+ const result = [];
208
+ let cursorX = cx - totalWidth / 2;
209
+
210
+ for (const ch of text) {
211
+ const glyph = GLYPH_MAP[ch];
212
+ if (!glyph || glyph.length === 0) {
213
+ cursorX += charWidth + spacing;
214
+ continue;
215
+ }
216
+
217
+ for (const polyline of glyph) {
218
+ if (polyline.length < 2) continue;
219
+ const pts = polyline.map(([gx, gy]) => {
220
+ const lx = cursorX + gx * charWidth - cx;
221
+ const ly = cy + gy * charHeight - charHeight / 2 - cy;
222
+ return {
223
+ x: cx + lx * Math.cos(rotation) - ly * Math.sin(rotation),
224
+ y: cy + lx * Math.sin(rotation) + ly * Math.cos(rotation),
225
+ };
226
+ });
227
+ result.push(makeStroke(pts, color, brushSize, opacity, pressureStyle));
228
+ if (result.length >= 200) break;
229
+ }
230
+ cursorX += charWidth + spacing;
231
+ if (result.length >= 200) break;
232
+ }
233
+
234
+ return result.slice(0, 200);
235
+ }
236
+
237
+ export function alienGlyphs(cx, cy, count, glyphSize, arrangement, color, brushSize, opacity, pressureStyle) {
238
+ cx = Number(cx) || 0; cy = Number(cy) || 0;
239
+ count = clamp(Math.round(Number(count) || 8), 1, 20);
240
+ glyphSize = clamp(Number(glyphSize) || 25, 5, 100);
241
+ arrangement = String(arrangement || 'line').toLowerCase();
242
+ brushSize = clamp(Number(brushSize) || 2, 3, 100);
243
+ opacity = clamp(Number(opacity) || 0.85, 0.01, 1);
244
+
245
+ const result = [];
246
+ const spacing = glyphSize * 1.4;
247
+
248
+ const positions = [];
249
+ for (let i = 0; i < count; i++) {
250
+ let gx, gy;
251
+ if (arrangement === 'grid') {
252
+ const cols = Math.ceil(Math.sqrt(count));
253
+ const col = i % cols, row = Math.floor(i / cols);
254
+ const totalW = (cols - 1) * spacing, totalH = (Math.ceil(count / cols) - 1) * spacing;
255
+ gx = cx - totalW / 2 + col * spacing;
256
+ gy = cy - totalH / 2 + row * spacing;
257
+ } else if (arrangement === 'scatter') {
258
+ const r = glyphSize * 2 + Math.random() * glyphSize * 3;
259
+ const a = Math.random() * Math.PI * 2;
260
+ gx = cx + Math.cos(a) * r;
261
+ gy = cy + Math.sin(a) * r;
262
+ } else if (arrangement === 'circle') {
263
+ const a = (i / count) * Math.PI * 2 - Math.PI / 2;
264
+ const r = spacing * count / (Math.PI * 2) + glyphSize;
265
+ gx = cx + Math.cos(a) * r;
266
+ gy = cy + Math.sin(a) * r;
267
+ } else {
268
+ const totalW = (count - 1) * spacing;
269
+ gx = cx - totalW / 2 + i * spacing;
270
+ gy = cy;
271
+ }
272
+ positions.push({ x: gx, y: gy });
273
+ }
274
+
275
+ for (const pos of positions) {
276
+ if (result.length >= 200) break;
277
+ const half = glyphSize / 2;
278
+
279
+ const numElements = 2 + Math.floor(Math.random() * 3);
280
+ for (let e = 0; e < numElements && result.length < 200; e++) {
281
+ const type = Math.random();
282
+
283
+ if (type < 0.2) {
284
+ const x1 = pos.x + (Math.random() - 0.5) * glyphSize * 0.6;
285
+ const y1 = pos.y - half * (0.5 + Math.random() * 0.5);
286
+ const y2 = pos.y + half * (0.5 + Math.random() * 0.5);
287
+ const lean = (Math.random() - 0.5) * glyphSize * 0.3;
288
+ result.push(makeStroke([{ x: x1, y: y1 }, { x: x1 + lean, y: y2 }], color, brushSize, opacity, pressureStyle));
289
+ } else if (type < 0.4) {
290
+ const pts = [];
291
+ const arcSteps = 8 + Math.floor(Math.random() * 8);
292
+ const startA = Math.random() * Math.PI * 2;
293
+ const sweep = (0.5 + Math.random()) * Math.PI;
294
+ const r = half * (0.3 + Math.random() * 0.5);
295
+ const acx = pos.x + (Math.random() - 0.5) * half * 0.5;
296
+ const acy = pos.y + (Math.random() - 0.5) * half * 0.5;
297
+ for (let s = 0; s <= arcSteps; s++) {
298
+ const a = startA + (s / arcSteps) * sweep;
299
+ pts.push({ x: acx + Math.cos(a) * r, y: acy + Math.sin(a) * r });
300
+ }
301
+ result.push(makeStroke(pts, color, brushSize, opacity, pressureStyle));
302
+ } else if (type < 0.55) {
303
+ const dots = 1 + Math.floor(Math.random() * 3);
304
+ for (let d = 0; d < dots && result.length < 200; d++) {
305
+ const dx = pos.x + (Math.random() - 0.5) * glyphSize * 0.5;
306
+ const dy = pos.y + (Math.random() - 0.5) * glyphSize * 0.6;
307
+ result.push(makeStroke([{ x: dx, y: dy }, { x: dx + 1, y: dy + 1 }], color, brushSize * 1.5, opacity, pressureStyle));
308
+ }
309
+ } else if (type < 0.7) {
310
+ const y = pos.y + (Math.random() - 0.5) * glyphSize * 0.6;
311
+ const x1 = pos.x - half * (0.2 + Math.random() * 0.4);
312
+ const x2 = pos.x + half * (0.2 + Math.random() * 0.4);
313
+ result.push(makeStroke([{ x: x1, y }, { x: x2, y }], color, brushSize, opacity, pressureStyle));
314
+ } else if (type < 0.85) {
315
+ const pts = [];
316
+ const segs = 2 + Math.floor(Math.random() * 3);
317
+ let px = pos.x + (Math.random() - 0.5) * half;
318
+ let py = pos.y - half * 0.8;
319
+ pts.push({ x: px, y: py });
320
+ for (let s = 0; s < segs; s++) {
321
+ px += (Math.random() - 0.5) * glyphSize * 0.5;
322
+ py += glyphSize * (0.3 + Math.random() * 0.3) / segs;
323
+ pts.push({ x: px, y: py });
324
+ }
325
+ result.push(makeStroke(pts, color, brushSize, opacity, pressureStyle));
326
+ } else {
327
+ const r = half * (0.15 + Math.random() * 0.25);
328
+ const ringCx = pos.x + (Math.random() - 0.5) * half * 0.6;
329
+ const ringCy = pos.y + (Math.random() - 0.5) * half * 0.6;
330
+ const pts = [];
331
+ const steps = 12;
332
+ for (let s = 0; s <= steps; s++) {
333
+ const a = (s / steps) * Math.PI * 2;
334
+ pts.push({ x: ringCx + Math.cos(a) * r, y: ringCy + Math.sin(a) * r });
335
+ }
336
+ result.push(makeStroke(pts, color, brushSize, opacity, pressureStyle));
337
+ }
338
+ }
339
+ }
340
+
341
+ return result.slice(0, 200);
342
+ }
@@ -0,0 +1,211 @@
1
+ # Algorithm Guide
2
+
3
+ How to write custom drawing algorithms for the ClawDraw skill.
4
+
5
+ ## The Stroke Pipeline
6
+
7
+ Every drawing follows the same pipeline:
8
+
9
+ ```
10
+ Generate points --> Create strokes --> Apply symmetry --> Send to relay
11
+ (your code) (makeStroke) (applySymmetry) (WebSocket)
12
+ ```
13
+
14
+ 1. Your algorithm generates arrays of `{x, y}` points
15
+ 2. `makeStroke()` wraps them with pressure, timestamps, brush settings, and a unique ID
16
+ 3. Symmetry copies are generated if a symmetry mode is active
17
+ 4. Strokes are batched and sent over WebSocket to the relay
18
+
19
+ ## Available Helpers
20
+
21
+ Import from `primitives/helpers.mjs`:
22
+
23
+ ```js
24
+ import {
25
+ clamp, lerp, // Math
26
+ hexToRgb, rgbToHex, lerpColor, // Color interpolation
27
+ samplePalette, PALETTES, // Named color palettes
28
+ noise2d, // 2D Perlin noise
29
+ makeStroke, splitIntoStrokes, // Stroke creation
30
+ clipLineToRect, // Line-rect intersection
31
+ } from '../primitives/helpers.mjs';
32
+ ```
33
+
34
+ ## Pattern: Parametric Curves
35
+
36
+ Express shapes as x=f(t), y=g(t) where t goes from 0 to 1.
37
+
38
+ ```js
39
+ function customCurve(cx, cy, radius) {
40
+ const points = [];
41
+ const steps = 100;
42
+ for (let i = 0; i <= steps; i++) {
43
+ const t = i / steps;
44
+ const angle = t * Math.PI * 2;
45
+ // Rose curve: r = cos(3*theta)
46
+ const r = radius * Math.cos(3 * angle);
47
+ points.push({
48
+ x: cx + r * Math.cos(angle),
49
+ y: cy + r * Math.sin(angle),
50
+ });
51
+ }
52
+ return splitIntoStrokes(points, '#ff0000', 3, 0.9);
53
+ }
54
+ ```
55
+
56
+ This pattern works for circles, spirals, lissajous curves, rose curves, cardioids, and any curve expressible in polar or parametric form.
57
+
58
+ ## Pattern: Particle Systems
59
+
60
+ Simulate particles moving through space with forces.
61
+
62
+ ```js
63
+ function particleTrails(cx, cy, count, steps) {
64
+ const strokes = [];
65
+ for (let p = 0; p < count; p++) {
66
+ let x = cx + (Math.random() - 0.5) * 200;
67
+ let y = cy + (Math.random() - 0.5) * 200;
68
+ let vx = 0, vy = 0;
69
+ const pts = [{ x, y }];
70
+
71
+ for (let s = 0; s < steps; s++) {
72
+ // Attraction toward center
73
+ const dx = cx - x, dy = cy - y;
74
+ const dist = Math.hypot(dx, dy) || 1;
75
+ vx += (dx / dist) * 0.5;
76
+ vy += (dy / dist) * 0.5;
77
+ // Random perturbation
78
+ vx += (Math.random() - 0.5) * 2;
79
+ vy += (Math.random() - 0.5) * 2;
80
+ x += vx;
81
+ y += vy;
82
+ pts.push({ x, y });
83
+ }
84
+ strokes.push(makeStroke(pts, '#ffffff', 2, 0.7));
85
+ }
86
+ return strokes;
87
+ }
88
+ ```
89
+
90
+ Variations: random walks, flocking (boids), attraction/repulsion, gravity simulation.
91
+
92
+ ## Pattern: Fractal / Recursive
93
+
94
+ Use recursion to create self-similar branching structures.
95
+
96
+ ```js
97
+ function fractalBranch(x, y, angle, length, depth, strokes) {
98
+ if (depth <= 0 || strokes.length >= 200) return;
99
+
100
+ const endX = x + Math.cos(angle) * length;
101
+ const endY = y + Math.sin(angle) * length;
102
+ strokes.push(makeStroke(
103
+ [{ x, y }, { x: endX, y: endY }],
104
+ '#ffffff', Math.max(1, depth), 0.8
105
+ ));
106
+
107
+ const newLen = length * 0.7;
108
+ fractalBranch(endX, endY, angle - 0.4, newLen, depth - 1, strokes);
109
+ fractalBranch(endX, endY, angle + 0.4, newLen, depth - 1, strokes);
110
+ }
111
+
112
+ function tree(cx, cy) {
113
+ const strokes = [];
114
+ fractalBranch(cx, cy, -Math.PI / 2, 80, 7, strokes);
115
+ return strokes.slice(0, 200);
116
+ }
117
+ ```
118
+
119
+ Always include a depth limit and a stroke count check to prevent runaway recursion.
120
+
121
+ ## Pattern: Noise-Based (Perlin Noise, Flow Fields)
122
+
123
+ Use the built-in `noise2d(x, y)` function for smooth randomness.
124
+
125
+ ```js
126
+ function noiseField(cx, cy, width, height) {
127
+ const strokes = [];
128
+ const scale = 0.005; // Lower = smoother, larger features
129
+
130
+ for (let p = 0; p < 30; p++) {
131
+ let x = cx + (Math.random() - 0.5) * width;
132
+ let y = cy + (Math.random() - 0.5) * height;
133
+ const pts = [{ x, y }];
134
+
135
+ for (let s = 0; s < 50; s++) {
136
+ const angle = noise2d(x * scale, y * scale) * Math.PI * 2;
137
+ x += Math.cos(angle) * 5;
138
+ y += Math.sin(angle) * 5;
139
+ pts.push({ x, y });
140
+ }
141
+ strokes.push(makeStroke(pts, '#44aaff', 2, 0.6));
142
+ }
143
+ return strokes;
144
+ }
145
+ ```
146
+
147
+ Noise scale controls the feature size: 0.001 = very smooth/large, 0.1 = very noisy/small.
148
+
149
+ ## Tips
150
+
151
+ ### Color Gradients
152
+
153
+ Use `lerpColor()` to interpolate between two colors, or `samplePalette()` for scientific palettes:
154
+
155
+ ```js
156
+ // Linear interpolation between two colors
157
+ const midColor = lerpColor('#ff0000', '#0000ff', 0.5); // purple
158
+
159
+ // Sample a named palette at position t (0 to 1)
160
+ const warmColor = samplePalette('magma', 0.7); // orangey
161
+ ```
162
+
163
+ ### Pressure for Line Weight Variation
164
+
165
+ Use the `pressureStyle` parameter for automatic variation, or set pressure directly on points:
166
+
167
+ ```js
168
+ const points = myPoints.map((p, i) => ({
169
+ x: p.x,
170
+ y: p.y,
171
+ pressure: Math.sin((i / myPoints.length) * Math.PI), // Thick in middle
172
+ }));
173
+ ```
174
+
175
+ When a point has an explicit `pressure` property, `makeStroke()` preserves it instead of applying a pressure style.
176
+
177
+ ### Jitter for Organic Feel
178
+
179
+ Add small random offsets to make shapes look hand-drawn:
180
+
181
+ ```js
182
+ const wobble = radius * (1 + (Math.random() - 0.5) * 0.04);
183
+ ```
184
+
185
+ ## Performance Guidelines
186
+
187
+ | Constraint | Limit | Why |
188
+ |-----------|-------|-----|
189
+ | Points per stroke | 5,000 max | Server-enforced, `splitIntoStrokes` handles this automatically |
190
+ | Strokes per batch | 200 max | WebSocket message size limit |
191
+ | Total points per message | 10,000 max | Prevents relay overload |
192
+ | Iterations/recursion | Use explicit caps | Prevent infinite loops |
193
+
194
+ ### Batching Strategy
195
+
196
+ If your algorithm generates more than 200 strokes, batch them:
197
+
198
+ ```js
199
+ const allStrokes = generateManyStrokes();
200
+ const BATCH = 200;
201
+ for (let i = 0; i < allStrokes.length; i += BATCH) {
202
+ sendBatch(allStrokes.slice(i, i + BATCH));
203
+ await sleep(100); // Brief pause between batches
204
+ }
205
+ ```
206
+
207
+ ### Keeping Point Counts Low
208
+
209
+ - For circles, scale step count with radius: `steps = clamp(radius * 0.5, 24, 200)`
210
+ - For recursive structures, limit total depth and check stroke count at each level
211
+ - For particle systems, limit trace length: 50-200 steps is usually sufficient
@@ -0,0 +1,72 @@
1
+ # Community Algorithm Contribution Guide
2
+
3
+ Add your own drawing algorithms to the ClawDraw primitive library.
4
+
5
+ ## Quick Start
6
+
7
+ 1. Fork the repository
8
+ 2. Copy `community/_template.mjs` to `community/your-primitive.mjs`
9
+ 3. Implement your algorithm following the template pattern
10
+ 4. Test locally
11
+ 5. Submit a pull request
12
+
13
+ ## File Structure
14
+
15
+ Your file must be a single `.mjs` file in the `community/` directory:
16
+
17
+ ```
18
+ packages/skill/community/
19
+ _template.mjs # Reference template (do not modify)
20
+ your-primitive.mjs # Your contribution
21
+ ```
22
+
23
+ ## Requirements
24
+
25
+ ### Exports
26
+
27
+ Every community primitive must export:
28
+
29
+ 1. **`METADATA`** -- Object or array of objects with:
30
+ - `name` (string): Unique camelCase identifier
31
+ - `description` (string): One-line description
32
+ - `category`: Must be `'community'`
33
+ - `author` (string): Your GitHub username
34
+ - `parameters` (object): Parameter definitions with `type`, `required`, and `description`
35
+
36
+ 2. **Named function** matching `METADATA.name` -- The drawing function itself
37
+
38
+ ### Function Rules
39
+
40
+ - Accept parameters as positional arguments matching the order in `METADATA.parameters`
41
+ - Return an array of stroke objects (use `makeStroke` / `splitIntoStrokes` from helpers)
42
+ - No external dependencies -- only import from `../primitives/helpers.mjs`
43
+ - Maximum file size: 50KB
44
+ - Must not modify global state
45
+ - Must terminate in bounded time (no infinite loops)
46
+ - Respect limits: max 200 strokes, max 5000 points per stroke
47
+
48
+ ### Available Imports
49
+
50
+ ```js
51
+ import {
52
+ clamp, lerp,
53
+ hexToRgb, rgbToHex, lerpColor,
54
+ samplePalette, PALETTES,
55
+ noise2d,
56
+ makeStroke, splitIntoStrokes,
57
+ clipLineToRect,
58
+ } from '../primitives/helpers.mjs';
59
+ ```
60
+
61
+ ## Naming
62
+
63
+ - Use camelCase for the primitive name: `myAlgorithm`, not `my-algorithm`
64
+ - Choose a descriptive name that hints at the visual output
65
+ - Avoid names that conflict with built-in primitives (see PRIMITIVES.md)
66
+
67
+ ## Submission
68
+
69
+ Submit a PR with:
70
+ - Your single `.mjs` file in `community/`
71
+ - A brief description of the algorithm and what it draws
72
+ - At least one example invocation showing the parameters