pi-roundtable-drawing 0.7.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/src/magic.ts ADDED
@@ -0,0 +1,525 @@
1
+ /**
2
+ * Magic circles, sigils, and sacred geometry, drawn with node-canvas: line art in the
3
+ * bundled font and palette on a dark, white, or transparent ground.
4
+ */
5
+ import { createHash } from "node:crypto";
6
+ import { type CanvasRenderingContext2D, createCanvas } from "canvas";
7
+ import { DrawingError } from "./errors.ts";
8
+ import { ensureFont, font, PALETTE } from "./style.ts";
9
+
10
+ export type Background = "dark" | "white" | "transparent";
11
+ type Point = readonly [number, number];
12
+
13
+ interface Surface {
14
+ ctx: CanvasRenderingContext2D;
15
+ size: number;
16
+ line: string;
17
+ png: () => Uint8Array;
18
+ }
19
+
20
+ function surface(size: number, background: Background): Surface {
21
+ ensureFont();
22
+ const canvas = createCanvas(size, size);
23
+ const ctx = canvas.getContext("2d");
24
+ if (background !== "transparent") {
25
+ ctx.fillStyle = background === "dark" ? PALETTE.background : "#ffffff";
26
+ ctx.fillRect(0, 0, size, size);
27
+ }
28
+ ctx.lineJoin = "round";
29
+ ctx.lineCap = "round";
30
+ return {
31
+ ctx,
32
+ size,
33
+ line: background === "dark" ? PALETTE.accentLight : PALETTE.background,
34
+ png: () => new Uint8Array(canvas.toBuffer("image/png")),
35
+ };
36
+ }
37
+
38
+ function circle(
39
+ ctx: CanvasRenderingContext2D,
40
+ x: number,
41
+ y: number,
42
+ r: number,
43
+ ): void {
44
+ ctx.beginPath();
45
+ ctx.arc(x, y, r, 0, Math.PI * 2);
46
+ ctx.stroke();
47
+ }
48
+
49
+ function path(
50
+ ctx: CanvasRenderingContext2D,
51
+ points: readonly Point[],
52
+ close = false,
53
+ ): void {
54
+ const [first, ...rest] = points;
55
+ if (!first) return;
56
+ if (rest.length === 0) {
57
+ // A path of one point, such as an intention of one letter, would draw nothing; mark the point.
58
+ ctx.beginPath();
59
+ ctx.arc(first[0], first[1], Math.max(4, ctx.lineWidth * 2), 0, Math.PI * 2);
60
+ ctx.fillStyle = ctx.strokeStyle;
61
+ ctx.fill();
62
+ return;
63
+ }
64
+ ctx.beginPath();
65
+ ctx.moveTo(first[0], first[1]);
66
+ for (const [x, y] of rest) ctx.lineTo(x, y);
67
+ if (close) ctx.closePath();
68
+ ctx.stroke();
69
+ }
70
+
71
+ /** Whether canvas accepts the text as a color: it leaves its fill alone for what it cannot parse. */
72
+ function isCssColor(value: string): boolean {
73
+ const ctx = createCanvas(1, 1).getContext("2d");
74
+ return ["#010203", "#040506"].some((sentinel) => {
75
+ ctx.fillStyle = sentinel;
76
+ ctx.fillStyle = value;
77
+ return ctx.fillStyle !== sentinel;
78
+ });
79
+ }
80
+
81
+ function polar(cx: number, cy: number, r: number, angle: number): Point {
82
+ return [cx + r * Math.cos(angle), cy + r * Math.sin(angle)];
83
+ }
84
+
85
+ // --- Magic circles ---
86
+
87
+ export type CircleType = "pentagram" | "hexagram" | "tree_of_life" | "custom";
88
+ export type CircleStyle = "traditional" | "modern" | "geometric";
89
+
90
+ export interface CircleOptions {
91
+ type: CircleType;
92
+ size: 512 | 1024 | 2048;
93
+ style: CircleStyle;
94
+ background: Background;
95
+ /** Up to four of fire, air, water, earth, placed north, east, south, west. */
96
+ elements?: readonly string[];
97
+ /** Written once around the perimeter. */
98
+ text?: string;
99
+ }
100
+
101
+ const ELEMENT_SYMBOLS: Readonly<Record<string, string>> = {
102
+ fire: "▲",
103
+ air: "△",
104
+ water: "▽",
105
+ earth: "▼",
106
+ };
107
+
108
+ const SEPHIROT_LAYOUT: readonly Point[] = [
109
+ [1 / 2, 1 / 6],
110
+ [1 / 3, 1 / 3],
111
+ [2 / 3, 1 / 3],
112
+ [1 / 3, 1 / 2],
113
+ [2 / 3, 1 / 2],
114
+ [1 / 2, 3 / 5],
115
+ [1 / 3, 3 / 4],
116
+ [2 / 3, 3 / 4],
117
+ [1 / 2, 5 / 6],
118
+ [1 / 2, 11 / 12],
119
+ ];
120
+
121
+ const SEPHIROT_PATHS: readonly (readonly [number, number])[] = [
122
+ [0, 1],
123
+ [0, 2],
124
+ [1, 2],
125
+ [1, 3],
126
+ [2, 4],
127
+ [3, 4],
128
+ [3, 5],
129
+ [4, 5],
130
+ [3, 6],
131
+ [4, 7],
132
+ [5, 6],
133
+ [5, 7],
134
+ [5, 8],
135
+ [6, 7],
136
+ [6, 8],
137
+ [7, 8],
138
+ [8, 9],
139
+ ];
140
+
141
+ export const CIRCLE_TYPES = [
142
+ "pentagram",
143
+ "hexagram",
144
+ "tree_of_life",
145
+ "custom",
146
+ ] as const;
147
+ export const MAX_CIRCLE_TEXT = 60;
148
+ const ELEMENTS = Object.keys(ELEMENT_SYMBOLS);
149
+
150
+ export function renderMagicCircle(options: CircleOptions): Uint8Array {
151
+ if (options.text && [...options.text].length > MAX_CIRCLE_TEXT)
152
+ throw new DrawingError(
153
+ `The rim text is over ${MAX_CIRCLE_TEXT} characters. Shorten it.`,
154
+ );
155
+ if (options.elements) {
156
+ if (options.elements.length > 4)
157
+ throw new DrawingError(
158
+ "At most four elements fit, one for each quarter. Give four or fewer.",
159
+ );
160
+ for (const element of options.elements)
161
+ if (!ELEMENTS.includes(element.toLowerCase()))
162
+ throw new DrawingError(
163
+ `Unknown element ${JSON.stringify(element)}. Use ${ELEMENTS.join(", ")}.`,
164
+ );
165
+ }
166
+ const s = surface(options.size, options.background);
167
+ const { ctx, size } = s;
168
+ const scale = size / 512;
169
+ const c = size / 2;
170
+ // Rim text sits between two rings, so the figure shrinks to make room for it.
171
+ const radius = c - (options.text ? 48 : 20) * scale;
172
+ ctx.strokeStyle = s.line;
173
+ ctx.lineWidth =
174
+ (options.style === "traditional" ? 3 : options.style === "modern" ? 2 : 1) *
175
+ scale;
176
+ if (options.text) circle(ctx, c, c, c - 12 * scale);
177
+
178
+ if (options.type === "tree_of_life") {
179
+ ctx.lineWidth = 2 * scale;
180
+ const points = SEPHIROT_LAYOUT.map(([x, y]): Point => [x * size, y * size]);
181
+ for (const [from, to] of SEPHIROT_PATHS) {
182
+ const a = points[from];
183
+ const b = points[to];
184
+ if (a && b) path(ctx, [a, b]);
185
+ }
186
+ for (const [x, y] of points) {
187
+ ctx.fillStyle =
188
+ options.background === "dark" ? PALETTE.background : "#ffffff";
189
+ ctx.beginPath();
190
+ ctx.arc(x, y, size / 20, 0, Math.PI * 2);
191
+ if (options.background !== "transparent") ctx.fill();
192
+ ctx.stroke();
193
+ }
194
+ } else if (options.type === "hexagram") {
195
+ circle(ctx, c, c, radius);
196
+ for (const offset of [-Math.PI / 2, Math.PI / 2]) {
197
+ const triangle = [0, 1, 2].map((i) =>
198
+ polar(c, c, radius * 0.8, (i * 2 * Math.PI) / 3 + offset),
199
+ );
200
+ path(ctx, triangle, true);
201
+ }
202
+ } else {
203
+ // A pentagram, also the fallback for custom.
204
+ circle(ctx, c, c, radius);
205
+ const points = [0, 1, 2, 3, 4].map((i) =>
206
+ polar(c, c, radius, (i * 2 * Math.PI) / 5 - Math.PI / 2),
207
+ );
208
+ for (let i = 0; i < 5; i++) {
209
+ const a = points[i];
210
+ const b = points[(i + 2) % 5];
211
+ if (a && b) path(ctx, [a, b]);
212
+ }
213
+ if (options.style === "traditional") circle(ctx, c, c, radius * 0.3);
214
+ }
215
+
216
+ ctx.fillStyle = s.line;
217
+ ctx.textAlign = "center";
218
+ ctx.textBaseline = "middle";
219
+ if (options.text) {
220
+ const chars = [...options.text];
221
+ ctx.font = font(Math.round(18 * scale));
222
+ const textRadius = c - 30 * scale;
223
+ chars.forEach((char, i) => {
224
+ const [x, y] = polar(
225
+ c,
226
+ c,
227
+ textRadius,
228
+ (i * 2 * Math.PI) / chars.length - Math.PI / 2,
229
+ );
230
+ ctx.fillText(char, x, y);
231
+ });
232
+ }
233
+ if (options.elements) {
234
+ ctx.font = font(Math.round(24 * scale));
235
+ const quarters: readonly Point[] = [
236
+ [c, c - size / 3],
237
+ [c + size / 3, c],
238
+ [c, c + size / 3],
239
+ [c - size / 3, c],
240
+ ];
241
+ options.elements.slice(0, 4).forEach((element, i) => {
242
+ const symbol = ELEMENT_SYMBOLS[element.toLowerCase()];
243
+ const at = quarters[i];
244
+ if (symbol && at) ctx.fillText(symbol, at[0], at[1]);
245
+ });
246
+ }
247
+ return s.png();
248
+ }
249
+
250
+ // --- Sigils ---
251
+
252
+ export type SigilMethod = "chaos" | "rose_cross" | "geometric" | "planetary";
253
+
254
+ export interface SigilOptions {
255
+ intention: string;
256
+ method: SigilMethod;
257
+ complexity: "simple" | "elaborate";
258
+ style: "traditional" | "modern";
259
+ background: Background;
260
+ }
261
+
262
+ const SIGIL_SIZE = 512;
263
+
264
+ function letters(text: string): string[] {
265
+ return [...text.toUpperCase()].filter((char) => /\p{L}/u.test(char));
266
+ }
267
+
268
+ function codePoint(char: string): number {
269
+ return char.codePointAt(0) ?? 0;
270
+ }
271
+
272
+ /** A–Z keep the Golden Dawn angles; other letters, such as those of other scripts, get one from their code point. */
273
+ function roseCrossAngle(char: string): number {
274
+ const index = codePoint(char) - 65;
275
+ if (index >= 0 && index < 26)
276
+ return index === 24 ? 60 : index === 25 ? 90 : (index % 12) * 30;
277
+ return (codePoint(char) % 12) * 30;
278
+ }
279
+
280
+ function planetaryRadius(char: string): number {
281
+ const index = codePoint(char) - 65;
282
+ return 0.2 + ((index >= 0 && index < 26 ? index : codePoint(char)) % 7) * 0.1;
283
+ }
284
+
285
+ export function renderSigil(options: SigilOptions): Uint8Array {
286
+ if (options.intention.trim() === "")
287
+ throw new DrawingError("The intention is empty. Give a short phrase.");
288
+ if (options.method !== "geometric" && letters(options.intention).length === 0)
289
+ throw new DrawingError(
290
+ `The ${options.method} method draws from letters, and the intention has none. Write it in words, or use the geometric method.`,
291
+ );
292
+ const s = surface(SIGIL_SIZE, options.background);
293
+ const { ctx, size } = s;
294
+ const c = size / 2;
295
+ const elaborate = options.complexity === "elaborate";
296
+ // A long intention spirals outward; the figure stays inside the frame.
297
+ const reach = c - 20;
298
+ ctx.strokeStyle = s.line;
299
+ const thin = () => {
300
+ ctx.lineWidth = 1;
301
+ };
302
+ const thick = () => {
303
+ ctx.lineWidth = elaborate ? 3 : 2;
304
+ };
305
+
306
+ if (options.method === "rose_cross") {
307
+ if (elaborate) {
308
+ thin();
309
+ path(ctx, [
310
+ [20, c],
311
+ [size - 20, c],
312
+ ]);
313
+ path(ctx, [
314
+ [c, 20],
315
+ [c, size - 20],
316
+ ]);
317
+ for (const r of [size / 6, size / 4, size / 3]) circle(ctx, c, c, r);
318
+ }
319
+ thick();
320
+ const points = letters(options.intention).map((char, i) =>
321
+ polar(
322
+ c,
323
+ c,
324
+ Math.min(size / 4 + i * 10, reach),
325
+ (roseCrossAngle(char) * Math.PI) / 180,
326
+ ),
327
+ );
328
+ path(ctx, points);
329
+ } else if (options.method === "geometric") {
330
+ const hash = createHash("md5").update(options.intention).digest("hex");
331
+ const margin = 40;
332
+ const points: Point[] = [];
333
+ for (let i = 0; i < 16; i += 2) {
334
+ const x = Number.parseInt(hash[i] ?? "8", 16) / 15;
335
+ const y = Number.parseInt(hash[i + 1] ?? "8", 16) / 15;
336
+ points.push([
337
+ margin + x * (size - 2 * margin),
338
+ margin + y * (size - 2 * margin),
339
+ ]);
340
+ }
341
+ thick();
342
+ path(ctx, points, true);
343
+ if (elaborate) {
344
+ thin();
345
+ const cx = points.reduce((sum, p) => sum + p[0], 0) / points.length;
346
+ const cy = points.reduce((sum, p) => sum + p[1], 0) / points.length;
347
+ for (const point of points) path(ctx, [point, [cx, cy]]);
348
+ }
349
+ } else if (options.method === "planetary") {
350
+ if (elaborate) {
351
+ thin();
352
+ for (let i = 1; i <= 7; i++) circle(ctx, c, c, (reach * i) / 7);
353
+ }
354
+ thick();
355
+ const radii = letters(options.intention).map(planetaryRadius);
356
+ const points = radii.map((factor, i) =>
357
+ polar(
358
+ c,
359
+ c,
360
+ factor * (size / 3),
361
+ (i * 2 * Math.PI) / Math.max(radii.length, 1),
362
+ ),
363
+ );
364
+ path(ctx, points, points.length > 2);
365
+ } else {
366
+ // Chaos method: drop repeated letters, then join what remains.
367
+ const unique = [...new Set(letters(options.intention))];
368
+ const points = unique.map((char, i) =>
369
+ polar(
370
+ c,
371
+ c,
372
+ Math.min((size / 3) * (0.5 + i * 0.1), reach),
373
+ ((codePoint(char) - 65) * Math.PI) / 13,
374
+ ),
375
+ );
376
+ thick();
377
+ path(ctx, points, elaborate && points.length >= 3);
378
+ if (elaborate) {
379
+ thin();
380
+ for (const [x, y] of points) circle(ctx, x, y, 3);
381
+ }
382
+ }
383
+
384
+ if (options.style === "modern") {
385
+ ctx.lineWidth = 2;
386
+ ctx.strokeRect(10, 10, size - 20, size - 20);
387
+ }
388
+ return s.png();
389
+ }
390
+
391
+ // --- Sacred geometry ---
392
+
393
+ export type GeometryPattern =
394
+ | "flower_of_life"
395
+ | "metatron"
396
+ | "sri_yantra"
397
+ | "vesica_pisces";
398
+
399
+ export interface GeometryOptions {
400
+ pattern: GeometryPattern;
401
+ layers: number;
402
+ rotation: number;
403
+ /** CSS colors cycled per layer; the palette's line color when empty. */
404
+ colors: readonly string[];
405
+ background: Background;
406
+ }
407
+
408
+ const GEOMETRY_SIZE = 1024;
409
+
410
+ function rotate(points: readonly Point[], c: number, degrees: number): Point[] {
411
+ const r = (degrees * Math.PI) / 180;
412
+ return points.map(([x, y]) => [
413
+ c + (x - c) * Math.cos(r) - (y - c) * Math.sin(r),
414
+ c + (x - c) * Math.sin(r) + (y - c) * Math.cos(r),
415
+ ]);
416
+ }
417
+
418
+ export const MAX_LAYERS = 9;
419
+
420
+ export function renderSacredGeometry(options: GeometryOptions): Uint8Array {
421
+ if (
422
+ !Number.isInteger(options.layers) ||
423
+ options.layers < 1 ||
424
+ options.layers > MAX_LAYERS
425
+ )
426
+ throw new DrawingError(
427
+ `layers must be a whole number from 1 to ${MAX_LAYERS}; got ${options.layers}.`,
428
+ );
429
+ if (!Number.isFinite(options.rotation))
430
+ throw new DrawingError("rotation must be a number of degrees.");
431
+ if (options.colors.length > MAX_LAYERS)
432
+ throw new DrawingError(
433
+ `At most ${MAX_LAYERS} colors are used, one for each layer. Give fewer.`,
434
+ );
435
+ for (const color of options.colors)
436
+ if (!isCssColor(color))
437
+ throw new DrawingError(
438
+ `${JSON.stringify(color)} is not a CSS color. Use a name such as gold or a code such as #c0a060.`,
439
+ );
440
+ const s = surface(GEOMETRY_SIZE, options.background);
441
+ const { ctx, size } = s;
442
+ const c = size / 2;
443
+ const colors = options.colors.length > 0 ? options.colors : [s.line];
444
+ const color = (i: number) => colors[i % colors.length] ?? s.line;
445
+ const turn = (options.rotation * Math.PI) / 180;
446
+ ctx.lineWidth = 2;
447
+
448
+ // Every figure is scaled to fit inside the frame with a margin, so each layer shows.
449
+ const reach = c - 20;
450
+
451
+ if (options.pattern === "metatron") {
452
+ // Thirteen equal circles: the centre, a touching inner ring, and an outer ring twice as far,
453
+ // with a line between every pair of centres. One layer is the inner seven circles; two or more, all thirteen.
454
+ const r = reach / 2.5;
455
+ const centers: Point[] = [[c, c]];
456
+ for (const distance of [r, r * 2]) {
457
+ for (let i = 0; i < 6; i++)
458
+ centers.push(polar(c, c, distance, (2 * Math.PI * i) / 6 + turn));
459
+ }
460
+ const shown = centers.slice(0, options.layers >= 2 ? 13 : 7);
461
+ shown.forEach(([x, y], i) => {
462
+ ctx.strokeStyle = color(i);
463
+ circle(ctx, x, y, r / 2);
464
+ });
465
+ ctx.strokeStyle = colors[0] ?? s.line;
466
+ ctx.lineWidth = 1;
467
+ for (const [i, from] of shown.entries())
468
+ for (const to of shown.slice(i + 1)) path(ctx, [from, to]);
469
+ } else if (options.pattern === "sri_yantra") {
470
+ // A simplified yantra: upward and downward triangles in nested pairs around a point, inside a circle.
471
+ const base = size / 3;
472
+ for (let layer = 0; layer < options.layers; layer++) {
473
+ const t = base - (layer * base) / (options.layers + 1);
474
+ ctx.strokeStyle = color(layer);
475
+ const up: Point[] = [
476
+ [c, c - t],
477
+ [c - t * 0.866, c + t * 0.5],
478
+ [c + t * 0.866, c + t * 0.5],
479
+ ];
480
+ const down: Point[] = [
481
+ [c, c + t],
482
+ [c - t * 0.866, c - t * 0.5],
483
+ [c + t * 0.866, c - t * 0.5],
484
+ ];
485
+ path(ctx, rotate(up, c, options.rotation), true);
486
+ path(ctx, rotate(down, c, options.rotation), true);
487
+ }
488
+ ctx.strokeStyle = colors[0] ?? s.line;
489
+ circle(ctx, c, c, base + 20);
490
+ path(ctx, [[c, c]]);
491
+ } else if (options.pattern === "vesica_pisces") {
492
+ // Pairs of overlapping circles, each centred half a radius from the middle; the largest pair fills the frame.
493
+ const outer = reach / 1.5;
494
+ for (let layer = 0; layer < options.layers; layer++) {
495
+ const r = (outer * (layer + 1)) / options.layers;
496
+ const dx = r * 0.5 * Math.cos(turn);
497
+ const dy = r * 0.5 * Math.sin(turn);
498
+ ctx.strokeStyle = color(layer);
499
+ circle(ctx, c - dx, c - dy, r);
500
+ circle(ctx, c + dx, c + dy, r);
501
+ }
502
+ } else {
503
+ // Flower of life: circles of one radius on a six-fold lattice whose spacing is that radius, ring after ring around the centre.
504
+ const r = reach / (options.layers + 1);
505
+ ctx.strokeStyle = colors[0] ?? s.line;
506
+ circle(ctx, c, c, r);
507
+ for (let ring = 1; ring <= options.layers; ring++) {
508
+ ctx.strokeStyle = color(ring);
509
+ const corners = Array.from({ length: 6 }, (_, j) =>
510
+ polar(c, c, ring * r, (Math.PI * j) / 3 + turn),
511
+ );
512
+ corners.forEach((from, j) => {
513
+ const to = corners[(j + 1) % 6] ?? from;
514
+ for (let step = 0; step < ring; step++)
515
+ circle(
516
+ ctx,
517
+ from[0] + ((to[0] - from[0]) * step) / ring,
518
+ from[1] + ((to[1] - from[1]) * step) / ring,
519
+ r,
520
+ );
521
+ });
522
+ }
523
+ }
524
+ return s.png();
525
+ }
package/src/plugin.ts ADDED
@@ -0,0 +1,61 @@
1
+ import { definePlugin, PluginError, type Tier } from "pi-roundtable";
2
+ import { type Deck, DeckError, loadDecks } from "./cards/deck.ts";
3
+ import type { Random } from "./random.ts";
4
+ import { drawCardsTool } from "./tools/draw-cards-tool.ts";
5
+ import type { ImageToolEnv } from "./tools/image-tool.ts";
6
+ import {
7
+ magicCircleTool,
8
+ sacredGeometryTool,
9
+ sigilTool,
10
+ } from "./tools/magic-tools.ts";
11
+ import { relationshipMapTool } from "./tools/relationship-map-tool.ts";
12
+
13
+ export interface DrawingOptions {
14
+ /**
15
+ * A directory with one subdirectory for each deck, each holding a `deck.json` and the card
16
+ * faces it names. Without it the plugin has no `draw_cards` tool. The package ships no card
17
+ * faces; see the README for the layout.
18
+ */
19
+ deckDir?: string;
20
+ /** The random source of the map layouts and the card draws; `Math.random` by default. A seeded source draws the same picture every time. */
21
+ random?: Random;
22
+ /** The lowest tier that may use the tools; default `member`. */
23
+ minTier?: Tier;
24
+ }
25
+
26
+ function readDecks(deckDir: string): Deck[] {
27
+ try {
28
+ return loadDecks(deckDir);
29
+ } catch (error) {
30
+ if (error instanceof DeckError)
31
+ throw new PluginError(`plugin drawing: ${error.message}`);
32
+ throw error;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Tools that draw images locally with node-canvas and attach them to the agent's reply: relationship
38
+ * maps, magic circles, sigils, sacred geometry, and, with a `deckDir`, card draws.
39
+ */
40
+ export function drawing(options: DrawingOptions = {}) {
41
+ const random = options.random ?? Math.random;
42
+ return definePlugin({
43
+ name: "drawing",
44
+ setup: () => {
45
+ // Read at setup, so a wrong deck directory stops the start with a message that names this plugin.
46
+ const decks = options.deckDir ? readDecks(options.deckDir) : [];
47
+ const env: ImageToolEnv = {
48
+ minTier: options.minTier ?? "member",
49
+ };
50
+ return {
51
+ tools: [
52
+ relationshipMapTool(env, random),
53
+ magicCircleTool(env),
54
+ sigilTool(env),
55
+ sacredGeometryTool(env),
56
+ ...(decks.length > 0 ? [drawCardsTool(decks, env, random)] : []),
57
+ ],
58
+ };
59
+ },
60
+ });
61
+ }
package/src/random.ts ADDED
@@ -0,0 +1,19 @@
1
+ /** Returns a number from 0 up to, not including, 1, like `Math.random`. */
2
+ export type Random = () => number;
3
+
4
+ /** A seed for Rough.js, which treats 0 as "pick one yourself"; always in 1 to 2^31 - 2. */
5
+ export function roughSeed(random: Random): number {
6
+ return 1 + Math.floor(random() * 0x7ffffffe);
7
+ }
8
+
9
+ /** A small deterministic generator for tests and reproducible drawings (mulberry32). */
10
+ export function seededRandom(seed: number): Random {
11
+ let state = seed >>> 0;
12
+ return () => {
13
+ state = (state + 0x6d2b79f5) >>> 0;
14
+ let t = state;
15
+ t = Math.imul(t ^ (t >>> 15), t | 1);
16
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
17
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
18
+ };
19
+ }