@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.
- package/LICENSE +21 -0
- package/README.md +63 -0
- package/SKILL.md +245 -0
- package/community/README.md +69 -0
- package/community/_template.mjs +39 -0
- package/community/helpers.mjs +1 -0
- package/package.json +44 -0
- package/primitives/basic-shapes.mjs +176 -0
- package/primitives/community-palettes.json +1 -0
- package/primitives/decorative.mjs +373 -0
- package/primitives/fills.mjs +217 -0
- package/primitives/flow-abstract.mjs +276 -0
- package/primitives/helpers.mjs +291 -0
- package/primitives/index.mjs +154 -0
- package/primitives/organic.mjs +514 -0
- package/primitives/utility.mjs +342 -0
- package/references/ALGORITHM_GUIDE.md +211 -0
- package/references/COMMUNITY.md +72 -0
- package/references/EXAMPLES.md +165 -0
- package/references/PALETTES.md +46 -0
- package/references/PRIMITIVES.md +301 -0
- package/references/PRO_TIPS.md +114 -0
- package/references/SECURITY.md +58 -0
- package/references/STROKE_FORMAT.md +78 -0
- package/references/SYMMETRY.md +59 -0
- package/references/WEBSOCKET.md +83 -0
- package/scripts/auth.mjs +145 -0
- package/scripts/clawdraw.mjs +882 -0
- package/scripts/connection.mjs +330 -0
- package/scripts/symmetry.mjs +217 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* WebSocket connection manager for sending strokes to the ClawDraw relay.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* import { connect, sendStrokes, addWaypoint, getWaypointUrl, disconnect } from './connection.mjs';
|
|
7
|
+
*
|
|
8
|
+
* const ws = await connect(token);
|
|
9
|
+
* await sendStrokes(ws, strokes, { waitForAcks: true });
|
|
10
|
+
* const wp = await addWaypoint(ws, { name: 'My Spot', x: 0, y: 0, zoom: 1 });
|
|
11
|
+
* console.log(getWaypointUrl(wp));
|
|
12
|
+
* disconnect(ws);
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import WebSocket from 'ws';
|
|
16
|
+
|
|
17
|
+
const WS_URL = 'wss://relay.clawdraw.ai/ws';
|
|
18
|
+
|
|
19
|
+
const MAX_RETRIES = 5;
|
|
20
|
+
const BASE_DELAY_MS = 1000;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Connect to the relay WebSocket with auth token.
|
|
24
|
+
* Sends an initial viewport.update on open.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} token - JWT from auth.mjs getToken()
|
|
27
|
+
* @param {object} [opts]
|
|
28
|
+
* @param {string} [opts.username] - Bot display name
|
|
29
|
+
* @param {{ x: number, y: number }} [opts.center] - Viewport center
|
|
30
|
+
* @param {number} [opts.zoom] - Viewport zoom
|
|
31
|
+
* @returns {Promise<WebSocket>}
|
|
32
|
+
*/
|
|
33
|
+
export function connect(token, opts = {}) {
|
|
34
|
+
const username = opts.username || 'openclaw-bot';
|
|
35
|
+
const center = opts.center || { x: 0, y: 0 };
|
|
36
|
+
const zoom = opts.zoom || 0.2;
|
|
37
|
+
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const ws = new WebSocket(WS_URL, {
|
|
40
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
ws.on('open', () => {
|
|
44
|
+
// Send initial viewport so the relay knows where we are
|
|
45
|
+
const viewportMsg = {
|
|
46
|
+
type: 'viewport.update',
|
|
47
|
+
viewport: {
|
|
48
|
+
center,
|
|
49
|
+
zoom,
|
|
50
|
+
size: { width: 6000, height: 6000 },
|
|
51
|
+
},
|
|
52
|
+
cursor: center,
|
|
53
|
+
username,
|
|
54
|
+
};
|
|
55
|
+
ws.send(JSON.stringify(viewportMsg));
|
|
56
|
+
|
|
57
|
+
// Re-send presence every 30s to prevent 60s eviction timeout
|
|
58
|
+
ws._presenceHeartbeat = setInterval(() => {
|
|
59
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
60
|
+
ws.send(JSON.stringify(viewportMsg));
|
|
61
|
+
}
|
|
62
|
+
}, 30000);
|
|
63
|
+
|
|
64
|
+
// Wait for chunks.initial before resolving — strokes sent before
|
|
65
|
+
// subscription completes get rejected with REGION_FULL / chunk.full.
|
|
66
|
+
const subTimeout = setTimeout(() => {
|
|
67
|
+
ws.removeListener('message', onChunksInitial);
|
|
68
|
+
console.warn('[connection] chunks.initial not received within 5s, resolving anyway');
|
|
69
|
+
resolve(ws);
|
|
70
|
+
}, 5000);
|
|
71
|
+
|
|
72
|
+
function onChunksInitial(data) {
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(data.toString());
|
|
75
|
+
const msgs = Array.isArray(parsed) ? parsed : [parsed];
|
|
76
|
+
for (const msg of msgs) {
|
|
77
|
+
if (msg.type === 'chunks.initial') {
|
|
78
|
+
clearTimeout(subTimeout);
|
|
79
|
+
ws.removeListener('message', onChunksInitial);
|
|
80
|
+
resolve(ws);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} catch { /* ignore non-JSON frames */ }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
ws.on('message', onChunksInitial);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
ws.on('error', (err) => {
|
|
91
|
+
reject(new Error(`WebSocket connection failed: ${err.message}`));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// If it closes before opening, reject
|
|
95
|
+
ws.on('close', (code) => {
|
|
96
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
97
|
+
reject(new Error(`WebSocket closed before open (code ${code})`));
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Maximum strokes per batch message. */
|
|
104
|
+
export const BATCH_SIZE = 100;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Send an array of strokes to the relay, batched for efficiency.
|
|
108
|
+
* Groups strokes into batches of up to BATCH_SIZE and sends each as
|
|
109
|
+
* a single `strokes.add` message.
|
|
110
|
+
*
|
|
111
|
+
* @param {WebSocket} ws - Connected WebSocket
|
|
112
|
+
* @param {Array} strokes - Array of stroke objects (from helpers.mjs makeStroke)
|
|
113
|
+
* @param {object|number} [optsOrDelay={}] - Options object or legacy delayMs number
|
|
114
|
+
* @param {number} [optsOrDelay.delayMs=50] - Milliseconds between batch sends
|
|
115
|
+
* @param {number} [optsOrDelay.batchSize=100] - Max strokes per batch
|
|
116
|
+
* @param {boolean} [optsOrDelay.legacy=false] - Use single stroke.add per stroke
|
|
117
|
+
* @param {boolean} [optsOrDelay.waitForAcks=false] - Wait for stroke.ack/strokes.ack before returning
|
|
118
|
+
* @returns {Promise<number|{sent: number, acked: number}>} Number of strokes sent (or {sent, acked} when waitForAcks)
|
|
119
|
+
*/
|
|
120
|
+
export async function sendStrokes(ws, strokes, optsOrDelay = {}) {
|
|
121
|
+
// Support legacy call signature: sendStrokes(ws, strokes, 50)
|
|
122
|
+
const opts = typeof optsOrDelay === 'number'
|
|
123
|
+
? { delayMs: optsOrDelay }
|
|
124
|
+
: optsOrDelay;
|
|
125
|
+
|
|
126
|
+
const delayMs = opts.delayMs ?? 50;
|
|
127
|
+
const batchSize = opts.batchSize ?? BATCH_SIZE;
|
|
128
|
+
const legacy = opts.legacy ?? false;
|
|
129
|
+
const waitForAcks = opts.waitForAcks ?? false;
|
|
130
|
+
|
|
131
|
+
let sent = 0;
|
|
132
|
+
let expectedAcks = 0;
|
|
133
|
+
|
|
134
|
+
if (legacy) {
|
|
135
|
+
// Legacy mode: one stroke.add per stroke
|
|
136
|
+
for (const stroke of strokes) {
|
|
137
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
138
|
+
console.warn(`[connection] WebSocket not open, sent ${sent}/${strokes.length}`);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
ws.send(JSON.stringify({ type: 'stroke.add', stroke }));
|
|
142
|
+
sent++;
|
|
143
|
+
expectedAcks++;
|
|
144
|
+
if (sent < strokes.length && delayMs > 0) {
|
|
145
|
+
await sleep(delayMs);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
// Batched mode: group into strokes.add messages
|
|
150
|
+
for (let i = 0; i < strokes.length; i += batchSize) {
|
|
151
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
152
|
+
console.warn(`[connection] WebSocket not open, sent ${sent}/${strokes.length}`);
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
const batch = strokes.slice(i, i + batchSize);
|
|
156
|
+
ws.send(JSON.stringify({ type: 'strokes.add', strokes: batch }));
|
|
157
|
+
sent += batch.length;
|
|
158
|
+
expectedAcks++;
|
|
159
|
+
if (i + batchSize < strokes.length && delayMs > 0) {
|
|
160
|
+
await sleep(delayMs);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!waitForAcks || expectedAcks === 0) {
|
|
166
|
+
return waitForAcks ? { sent, acked: 0 } : sent;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Wait for ack messages
|
|
170
|
+
const acked = await new Promise((resolve) => {
|
|
171
|
+
let ackCount = 0;
|
|
172
|
+
const timeout = setTimeout(() => {
|
|
173
|
+
ws.removeListener('message', handler);
|
|
174
|
+
resolve(ackCount);
|
|
175
|
+
}, 10000);
|
|
176
|
+
|
|
177
|
+
function handler(data) {
|
|
178
|
+
try {
|
|
179
|
+
const parsed = JSON.parse(data.toString());
|
|
180
|
+
const msgs = Array.isArray(parsed) ? parsed : [parsed];
|
|
181
|
+
for (const msg of msgs) {
|
|
182
|
+
if (msg.type === 'stroke.ack' || msg.type === 'strokes.ack') {
|
|
183
|
+
ackCount++;
|
|
184
|
+
if (ackCount >= expectedAcks) {
|
|
185
|
+
clearTimeout(timeout);
|
|
186
|
+
ws.removeListener('message', handler);
|
|
187
|
+
resolve(ackCount);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
} catch { /* ignore */ }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
ws.on('message', handler);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
return { sent, acked };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Drop a waypoint on the canvas and wait for server confirmation.
|
|
203
|
+
*
|
|
204
|
+
* @param {WebSocket} ws - Connected WebSocket
|
|
205
|
+
* @param {object} opts
|
|
206
|
+
* @param {string} opts.name - Waypoint display name (max 64 chars)
|
|
207
|
+
* @param {number} opts.x - X coordinate
|
|
208
|
+
* @param {number} opts.y - Y coordinate
|
|
209
|
+
* @param {number} opts.zoom - Zoom level
|
|
210
|
+
* @param {string} [opts.description] - Optional description (max 512 chars)
|
|
211
|
+
* @returns {Promise<object>} The created waypoint object (with id, name, x, y, zoom)
|
|
212
|
+
*/
|
|
213
|
+
export function addWaypoint(ws, { name, x, y, zoom, description }) {
|
|
214
|
+
return new Promise((resolve, reject) => {
|
|
215
|
+
const timeout = setTimeout(() => {
|
|
216
|
+
ws.removeListener('message', handler);
|
|
217
|
+
reject(new Error('Waypoint response timeout (5s)'));
|
|
218
|
+
}, 5000);
|
|
219
|
+
|
|
220
|
+
function handler(data) {
|
|
221
|
+
try {
|
|
222
|
+
const parsed = JSON.parse(data.toString());
|
|
223
|
+
const msgs = Array.isArray(parsed) ? parsed : [parsed];
|
|
224
|
+
for (const msg of msgs) {
|
|
225
|
+
if (msg.type === 'waypoint.added') {
|
|
226
|
+
clearTimeout(timeout);
|
|
227
|
+
ws.removeListener('message', handler);
|
|
228
|
+
resolve(msg.waypoint);
|
|
229
|
+
} else if (msg.type === 'sync.error') {
|
|
230
|
+
clearTimeout(timeout);
|
|
231
|
+
ws.removeListener('message', handler);
|
|
232
|
+
reject(new Error(msg.message || msg.code));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
} catch { /* ignore */ }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
ws.on('message', handler);
|
|
239
|
+
ws.send(JSON.stringify({
|
|
240
|
+
type: 'waypoint.add',
|
|
241
|
+
waypoint: { name, x, y, zoom, description: description || undefined },
|
|
242
|
+
}));
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Build a shareable URL for a waypoint.
|
|
248
|
+
*
|
|
249
|
+
* @param {object} waypoint - Waypoint object with id property
|
|
250
|
+
* @returns {string} Shareable URL
|
|
251
|
+
*/
|
|
252
|
+
export function getWaypointUrl(waypoint) {
|
|
253
|
+
return `https://clawdraw.ai/?wp=${waypoint.id}`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Disconnect gracefully.
|
|
258
|
+
*
|
|
259
|
+
* @param {WebSocket} ws
|
|
260
|
+
*/
|
|
261
|
+
export function disconnect(ws) {
|
|
262
|
+
if (ws && ws._presenceHeartbeat) {
|
|
263
|
+
clearInterval(ws._presenceHeartbeat);
|
|
264
|
+
ws._presenceHeartbeat = null;
|
|
265
|
+
}
|
|
266
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
267
|
+
ws.close(1000, 'done');
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Connect with automatic reconnection on disconnect.
|
|
273
|
+
* Returns a wrapper that transparently reconnects.
|
|
274
|
+
*
|
|
275
|
+
* @param {string} token - JWT
|
|
276
|
+
* @param {object} [opts] - Same as connect() opts
|
|
277
|
+
* @returns {Promise<{ ws: WebSocket, sendStrokes: Function, disconnect: Function }>}
|
|
278
|
+
*/
|
|
279
|
+
export async function connectWithRetry(token, opts = {}) {
|
|
280
|
+
let ws = null;
|
|
281
|
+
let retries = 0;
|
|
282
|
+
let closed = false;
|
|
283
|
+
|
|
284
|
+
async function doConnect() {
|
|
285
|
+
ws = await connect(token, opts);
|
|
286
|
+
retries = 0;
|
|
287
|
+
|
|
288
|
+
ws.on('close', async (code) => {
|
|
289
|
+
if (ws._presenceHeartbeat) {
|
|
290
|
+
clearInterval(ws._presenceHeartbeat);
|
|
291
|
+
ws._presenceHeartbeat = null;
|
|
292
|
+
}
|
|
293
|
+
if (closed) return;
|
|
294
|
+
if (retries >= MAX_RETRIES) {
|
|
295
|
+
console.error(`[connection] Max retries (${MAX_RETRIES}) exceeded, giving up`);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const delay = BASE_DELAY_MS * Math.pow(2, retries);
|
|
299
|
+
retries++;
|
|
300
|
+
console.warn(`[connection] Disconnected (code ${code}), reconnecting in ${delay}ms (attempt ${retries})`);
|
|
301
|
+
await sleep(delay);
|
|
302
|
+
if (!closed) {
|
|
303
|
+
try { await doConnect(); } catch (e) {
|
|
304
|
+
console.error(`[connection] Reconnect failed:`, e.message);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
return ws;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
await doConnect();
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
get ws() { return ws; },
|
|
316
|
+
sendStrokes: (strokes, delayMs) => sendStrokes(ws, strokes, delayMs),
|
|
317
|
+
disconnect() {
|
|
318
|
+
closed = true;
|
|
319
|
+
disconnect(ws);
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
// Internal
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
function sleep(ms) {
|
|
329
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
330
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Symmetry system for ClawDraw stroke generation.
|
|
3
|
+
*
|
|
4
|
+
* Modes:
|
|
5
|
+
* - none: no copies, no constraints
|
|
6
|
+
* - vertical: mirror across Y axis (flip X around center)
|
|
7
|
+
* - horizontal: mirror across X axis (flip Y around center)
|
|
8
|
+
* - both: 4-fold mirror (3 copies: flip X, flip Y, flip both)
|
|
9
|
+
* - radial:N N copies rotated evenly around center
|
|
10
|
+
*
|
|
11
|
+
* All functions are pure (no global state). Symmetry center is passed explicitly.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
let _symSeq = 0;
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Parsing
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse a symmetry mode string into a structured descriptor.
|
|
22
|
+
* @param {string} str - One of 'none', 'vertical', 'horizontal', 'both', 'radial:N'
|
|
23
|
+
* @returns {{ mode: string, folds: number }}
|
|
24
|
+
*/
|
|
25
|
+
export function parseSymmetryMode(str) {
|
|
26
|
+
if (!str || str === 'none') return { mode: 'none', folds: 1 };
|
|
27
|
+
|
|
28
|
+
const lower = String(str).trim().toLowerCase();
|
|
29
|
+
|
|
30
|
+
if (lower === 'vertical') return { mode: 'vertical', folds: 2 };
|
|
31
|
+
if (lower === 'horizontal') return { mode: 'horizontal', folds: 2 };
|
|
32
|
+
if (lower === 'both') return { mode: 'both', folds: 4 };
|
|
33
|
+
|
|
34
|
+
const radialMatch = /^radial[:\s]+(\d+)$/i.exec(lower);
|
|
35
|
+
if (radialMatch) {
|
|
36
|
+
const n = Math.max(2, parseInt(radialMatch[1], 10));
|
|
37
|
+
return { mode: 'radial', folds: n };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { mode: 'none', folds: 1 };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Internal helper
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Deep-clone a stroke with a new ID and transformed points.
|
|
49
|
+
* @param {object} stroke - Source stroke
|
|
50
|
+
* @param {(pt: {x:number, y:number}) => {x:number, y:number}} transformFn
|
|
51
|
+
* @returns {object} New stroke with transformed points
|
|
52
|
+
*/
|
|
53
|
+
function cloneStroke(stroke, transformFn) {
|
|
54
|
+
const id = `sym-${Date.now().toString(36)}-${(++_symSeq).toString(36)}`;
|
|
55
|
+
return {
|
|
56
|
+
...stroke,
|
|
57
|
+
id,
|
|
58
|
+
points: stroke.points.map(p => {
|
|
59
|
+
const np = transformFn(p);
|
|
60
|
+
return { ...p, x: np.x, y: np.y };
|
|
61
|
+
}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Constraint enforcement
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Mutate stroke points so the stroke's centroid lies in the canonical region
|
|
71
|
+
* for the given symmetry mode. This ensures the original stroke is in the
|
|
72
|
+
* "primary" zone before copies are generated.
|
|
73
|
+
*
|
|
74
|
+
* - vertical: centroid must be in right half (x >= centerX)
|
|
75
|
+
* - horizontal: centroid must be in top half (y <= centerY)
|
|
76
|
+
* - both: centroid must be in top-right quadrant
|
|
77
|
+
* - radial: centroid must be in wedge [0, 2pi/N)
|
|
78
|
+
*
|
|
79
|
+
* The entire stroke is transformed as a rigid body to preserve shape.
|
|
80
|
+
*
|
|
81
|
+
* @param {object} stroke - Stroke to mutate in place
|
|
82
|
+
* @param {string} mode - 'none' | 'vertical' | 'horizontal' | 'both' | 'radial'
|
|
83
|
+
* @param {number} folds - Number of folds (only used for radial)
|
|
84
|
+
* @param {number} centerX - Symmetry center X
|
|
85
|
+
* @param {number} centerY - Symmetry center Y
|
|
86
|
+
*/
|
|
87
|
+
export function enforceConstraints(stroke, mode, folds, centerX, centerY) {
|
|
88
|
+
if (mode === 'none') return;
|
|
89
|
+
if (!stroke.points || stroke.points.length === 0) return;
|
|
90
|
+
|
|
91
|
+
// Compute centroid
|
|
92
|
+
let sumX = 0, sumY = 0;
|
|
93
|
+
for (const pt of stroke.points) { sumX += pt.x; sumY += pt.y; }
|
|
94
|
+
const centX = sumX / stroke.points.length;
|
|
95
|
+
const centY = sumY / stroke.points.length;
|
|
96
|
+
|
|
97
|
+
if (mode === 'vertical') {
|
|
98
|
+
if (centX < centerX) {
|
|
99
|
+
for (const pt of stroke.points) pt.x = 2 * centerX - pt.x;
|
|
100
|
+
}
|
|
101
|
+
} else if (mode === 'horizontal') {
|
|
102
|
+
if (centY > centerY) {
|
|
103
|
+
for (const pt of stroke.points) pt.y = 2 * centerY - pt.y;
|
|
104
|
+
}
|
|
105
|
+
} else if (mode === 'both') {
|
|
106
|
+
if (centX < centerX) {
|
|
107
|
+
for (const pt of stroke.points) pt.x = 2 * centerX - pt.x;
|
|
108
|
+
}
|
|
109
|
+
if (centY > centerY) {
|
|
110
|
+
for (const pt of stroke.points) pt.y = 2 * centerY - pt.y;
|
|
111
|
+
}
|
|
112
|
+
} else if (mode === 'radial') {
|
|
113
|
+
const dx = centX - centerX, dy = centY - centerY;
|
|
114
|
+
const r = Math.sqrt(dx * dx + dy * dy);
|
|
115
|
+
if (r < 1) return; // Centered at origin — inherently symmetric
|
|
116
|
+
|
|
117
|
+
const maxAngle = (2 * Math.PI) / folds;
|
|
118
|
+
let angle = Math.atan2(dy, dx);
|
|
119
|
+
if (angle < 0) angle += 2 * Math.PI;
|
|
120
|
+
|
|
121
|
+
if (angle > maxAngle) {
|
|
122
|
+
const targetAngle = angle % maxAngle;
|
|
123
|
+
const rotationDelta = targetAngle - angle;
|
|
124
|
+
const cos = Math.cos(rotationDelta), sin = Math.sin(rotationDelta);
|
|
125
|
+
for (const pt of stroke.points) {
|
|
126
|
+
const pdx = pt.x - centerX, pdy = pt.y - centerY;
|
|
127
|
+
pt.x = centerX + pdx * cos - pdy * sin;
|
|
128
|
+
pt.y = centerY + pdx * sin + pdy * cos;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Copy generation
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Generate symmetry copies of a stroke (not including the original).
|
|
140
|
+
*
|
|
141
|
+
* @param {object} stroke - Original stroke
|
|
142
|
+
* @param {string} mode - 'none' | 'vertical' | 'horizontal' | 'both' | 'radial'
|
|
143
|
+
* @param {number} folds - Number of folds (only used for radial)
|
|
144
|
+
* @param {number} centerX - Symmetry center X
|
|
145
|
+
* @param {number} centerY - Symmetry center Y
|
|
146
|
+
* @returns {object[]} Array of cloned strokes with transformed points
|
|
147
|
+
*/
|
|
148
|
+
export function generateCopies(stroke, mode, folds, centerX, centerY) {
|
|
149
|
+
if (mode === 'none') return [];
|
|
150
|
+
|
|
151
|
+
const cx = centerX;
|
|
152
|
+
const cy = centerY;
|
|
153
|
+
|
|
154
|
+
if (mode === 'vertical') {
|
|
155
|
+
return [cloneStroke(stroke, p => ({ x: 2 * cx - p.x, y: p.y }))];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (mode === 'horizontal') {
|
|
159
|
+
return [cloneStroke(stroke, p => ({ x: p.x, y: 2 * cy - p.y }))];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (mode === 'both') {
|
|
163
|
+
return [
|
|
164
|
+
cloneStroke(stroke, p => ({ x: 2 * cx - p.x, y: p.y })),
|
|
165
|
+
cloneStroke(stroke, p => ({ x: p.x, y: 2 * cy - p.y })),
|
|
166
|
+
cloneStroke(stroke, p => ({ x: 2 * cx - p.x, y: 2 * cy - p.y })),
|
|
167
|
+
];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (mode === 'radial') {
|
|
171
|
+
const copies = [];
|
|
172
|
+
for (let i = 1; i < folds; i++) {
|
|
173
|
+
const angle = (i / folds) * Math.PI * 2;
|
|
174
|
+
const cos = Math.cos(angle), sin = Math.sin(angle);
|
|
175
|
+
copies.push(cloneStroke(stroke, p => {
|
|
176
|
+
const dx = p.x - cx, dy = p.y - cy;
|
|
177
|
+
return {
|
|
178
|
+
x: cx + dx * cos - dy * sin,
|
|
179
|
+
y: cy + dx * sin + dy * cos,
|
|
180
|
+
};
|
|
181
|
+
}));
|
|
182
|
+
}
|
|
183
|
+
return copies;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// High-level API
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Apply symmetry to an array of strokes. Returns a new array containing
|
|
195
|
+
* the originals plus all generated symmetry copies.
|
|
196
|
+
*
|
|
197
|
+
* Constraints are enforced on the originals before copies are generated,
|
|
198
|
+
* so originals may be mutated in place.
|
|
199
|
+
*
|
|
200
|
+
* @param {object[]} strokes - Original strokes
|
|
201
|
+
* @param {string} mode - 'none' | 'vertical' | 'horizontal' | 'both' | 'radial'
|
|
202
|
+
* @param {number} folds - Number of folds
|
|
203
|
+
* @param {number} centerX - Symmetry center X
|
|
204
|
+
* @param {number} centerY - Symmetry center Y
|
|
205
|
+
* @returns {object[]} Originals + symmetry copies
|
|
206
|
+
*/
|
|
207
|
+
export function applySymmetry(strokes, mode, folds, centerX, centerY) {
|
|
208
|
+
if (mode === 'none') return strokes;
|
|
209
|
+
|
|
210
|
+
const result = [];
|
|
211
|
+
for (const stroke of strokes) {
|
|
212
|
+
enforceConstraints(stroke, mode, folds, centerX, centerY);
|
|
213
|
+
result.push(stroke);
|
|
214
|
+
result.push(...generateCopies(stroke, mode, folds, centerX, centerY));
|
|
215
|
+
}
|
|
216
|
+
return result;
|
|
217
|
+
}
|