@logomarks/core 0.1.11 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +7 -0
  3. package/package.json +47 -4
  4. package/src/font/glyphs.d.ts +31 -0
  5. package/src/font/glyphs.js +228 -0
  6. package/src/font/index.d.ts +10 -0
  7. package/src/font/index.js +10 -0
  8. package/src/font/measure.d.ts +16 -0
  9. package/src/font/measure.js +201 -0
  10. package/src/font/moves.d.ts +42 -0
  11. package/src/font/moves.js +263 -0
  12. package/src/forms/discs.d.ts +20 -0
  13. package/src/forms/discs.js +114 -0
  14. package/src/forms/emblems.d.ts +22 -0
  15. package/src/forms/emblems.js +109 -0
  16. package/src/forms/flow.d.ts +21 -0
  17. package/src/forms/flow.js +80 -0
  18. package/src/forms/folds.d.ts +18 -0
  19. package/src/forms/folds.js +147 -0
  20. package/src/forms/geo.d.ts +46 -0
  21. package/src/forms/geo.js +233 -0
  22. package/src/forms/hooks.d.ts +40 -0
  23. package/src/forms/hooks.js +204 -0
  24. package/src/forms/index.d.ts +22 -0
  25. package/src/forms/index.js +24 -0
  26. package/src/forms/jog.d.ts +33 -0
  27. package/src/forms/jog.js +162 -0
  28. package/src/forms/lines.d.ts +32 -0
  29. package/src/forms/lines.js +233 -0
  30. package/src/forms/ref.d.ts +41 -0
  31. package/src/forms/ref.js +246 -0
  32. package/src/forms/shatter.d.ts +40 -0
  33. package/src/forms/shatter.js +341 -0
  34. package/src/forms/slats.d.ts +35 -0
  35. package/src/forms/slats.js +78 -0
  36. package/src/forms/swash.d.ts +23 -0
  37. package/src/forms/swash.js +74 -0
  38. package/src/forms/tech.d.ts +40 -0
  39. package/src/forms/tech.js +293 -0
  40. package/src/forms/tracks.d.ts +36 -0
  41. package/src/forms/tracks.js +121 -0
  42. package/src/index.js +2 -1
  43. package/src/iso/index.d.ts +5 -0
  44. package/src/iso/index.js +13 -0
  45. package/src/iso/objects.d.ts +168 -0
  46. package/src/iso/objects.js +1042 -0
  47. package/src/iso/planes.d.ts +27 -0
  48. package/src/iso/planes.js +138 -0
  49. package/src/iso/voxels.d.ts +43 -0
  50. package/src/iso/voxels.js +255 -0
  51. package/src/letters/blackletter.d.ts +31 -0
  52. package/src/letters/blackletter.js +199 -0
  53. package/src/letters/dotstroke.d.ts +16 -0
  54. package/src/letters/dotstroke.js +77 -0
  55. package/src/letters/echo.d.ts +42 -0
  56. package/src/letters/echo.js +246 -0
  57. package/src/letters/frame.d.ts +6 -0
  58. package/src/letters/frame.js +14 -0
  59. package/src/letters/index.d.ts +16 -0
  60. package/src/letters/index.js +18 -0
  61. package/src/letters/inktrap.d.ts +66 -0
  62. package/src/letters/inktrap.js +244 -0
  63. package/src/letters/mono.d.ts +145 -0
  64. package/src/letters/mono.js +684 -0
  65. package/src/letters/pillar.d.ts +31 -0
  66. package/src/letters/pillar.js +257 -0
  67. package/src/letters/serif.d.ts +109 -0
  68. package/src/letters/serif.js +666 -0
  69. package/src/letters/stair.d.ts +8 -0
  70. package/src/letters/stair.js +43 -0
  71. package/src/letters/stem.d.ts +29 -0
  72. package/src/letters/stem.js +205 -0
  73. package/src/objects/animals.d.ts +105 -0
  74. package/src/objects/animals.js +469 -0
  75. package/src/objects/bio.d.ts +14 -0
  76. package/src/objects/bio.js +76 -0
  77. package/src/objects/devglyphs.d.ts +34 -0
  78. package/src/objects/devglyphs.js +226 -0
  79. package/src/objects/devices.d.ts +31 -0
  80. package/src/objects/devices.js +114 -0
  81. package/src/objects/faces.d.ts +94 -0
  82. package/src/objects/faces.js +344 -0
  83. package/src/objects/food.d.ts +12 -0
  84. package/src/objects/food.js +74 -0
  85. package/src/objects/index.d.ts +13 -0
  86. package/src/objects/index.js +18 -0
  87. package/src/objects/rosettes.d.ts +14 -0
  88. package/src/objects/rosettes.js +64 -0
  89. package/src/objects/sport.d.ts +8 -0
  90. package/src/objects/sport.js +37 -0
  91. package/src/objects/weapons.d.ts +31 -0
  92. package/src/objects/weapons.js +179 -0
  93. package/src/pixels/dither.d.ts +78 -0
  94. package/src/pixels/dither.js +399 -0
  95. package/src/pixels/emote.d.ts +6 -0
  96. package/src/pixels/emote.js +76 -0
  97. package/src/pixels/index.d.ts +7 -0
  98. package/src/pixels/index.js +8 -0
  99. package/src/sdf/index.d.ts +2 -0
  100. package/src/sdf/index.js +2 -0
  101. package/src/sdf/y2k.d.ts +12 -0
  102. package/src/sdf/y2k.js +80 -0
  103. package/src/util/vec.d.ts +14 -0
  104. package/src/util/vec.js +61 -0
@@ -0,0 +1,399 @@
1
+ /**
2
+ * Dither tapers: a solid pixel core that breaks up along a ramp, its cells shrinking and dropping out the further they
3
+ * sit from the core, so a mark dissolves (or assembles) in one direction, or all round; and squares off the grid (a
4
+ * taper that steps down in size, a block splitting into 2, 3, 4 … squares a column, a file of squares that shrink and
5
+ * turn).
6
+ *
7
+ * Everything is drawn on a cell grid: cell (i, j) spans [i, i + 1] × [j, j + 1], its centre (i + 0.5, j + 0.5). A mark is
8
+ * a list of cells, each with a ramp value t: t ≤ 0 is solid (a full, touching pixel), 0 < t < 1 is the dither zone, t ≥ 1
9
+ * is gone. taper() turns t into a cell size (a few quantised levels) and a keep or drop (an ordered-dither threshold
10
+ * against t), and sprite() draws the result on the 512 canvas.
11
+ *
12
+ * The rule this enforces: the taper stops at a cell and a spacing that survive 32 px. After fitting, the smallest cell
13
+ * is MIN_CELL (16: one whole pixel at 32 px) and two dither cells sit at least MIN_GAP (22, the house gap) apart; a
14
+ * dither cell beside the solid core is pushed away from it so that gap holds there too, or it's centred. The largest
15
+ * dither cell is the pitch less the gap, so the pitch is at least 38 and a grid at most 11 cells along its longest side;
16
+ * levels() throws below that. A batch can raise the floor (`min`), turn the squares as they fade (`turn`) or draw the
17
+ * cells by hand (mapCells()). Free squares are [x, y, s, deg]: top-left, side and the turn about the centre; every gap
18
+ * is measured box to box, so a turned square only ever sits further off than the gap says. Lay those out at the final
19
+ * scale (448 across), so fitting changes nothing.
20
+ * From the Logomarks tools (ditherparts.mjs, batch 167; free squares batch 186).
21
+ */
22
+ import { U, rect, rr, circle, rot } from '../geometry/index.js';
23
+ import { insideShape as inside } from '../parts/index.js';
24
+
25
+ /** The smallest dither cell once fitted (16: one whole pixel at 32 px). */
26
+ export const MIN_CELL = 16;
27
+ /** The smallest gap between dither cells once fitted (the house gap). */
28
+ export const MIN_GAP = 22;
29
+ /** The fitted span: the longest side of a mark. */
30
+ export const SPAN = 448;
31
+
32
+ /* ---------------------------------------------------------------- threshold masks ---------------------------------------------------------------- */
33
+
34
+ /**
35
+ * The n × n Bayer (ordered-dither) matrix as thresholds in (0, 1).
36
+ *
37
+ * @param {number} n Its side, a power of two.
38
+ * @returns {number[][]} The thresholds, row by row.
39
+ */
40
+ export const bayer = (n) => { let m = [[0]];
41
+ while (m.length < n) { const k = m.length; m = [...Array(2 * k)].map((_, r) => [...Array(2 * k)].map((_, c) => 4 * m[r % k][c % k] + [0, 2, 3, 1][2 * Math.floor(r / k) + Math.floor(c / k)])); }
42
+ return m.map((row) => row.map((v) => (v + 0.5) / (n * n))); };
43
+ const B4 = bayer(4), B8 = bayer(8);
44
+
45
+ /**
46
+ * Threshold masks, (i, j) → a value in (0, 1): a cell in the dither zone is kept while its threshold beats the ramp.
47
+ * `bayer4` and `bayer8` are ordered dither (regular, so a half-dissolved band is a lattice: at 50% a checkerboard, so
48
+ * keep the density low there or use r2); `r2` is the R2 low-discrepancy sequence (Roberts 2018), a deterministic
49
+ * blue-noise-like mask with no lattice.
50
+ */
51
+ export const MASKS = {
52
+ bayer4: (i, j) => B4[((j % 4) + 4) % 4][((i % 4) + 4) % 4],
53
+ bayer8: (i, j) => B8[((j % 8) + 8) % 8][((i % 8) + 8) % 8],
54
+ r2: (i, j) => { const v = 0.5 + i * 0.7548776662466927 + j * 0.5698402909980532; return v - Math.floor(v); },
55
+ };
56
+
57
+ /* ---------------------------------------------------------------- cells ---------------------------------------------------------------- */
58
+
59
+ /** A shape as a polygon set (a primitive returns one polygon, a boolean a set). */
60
+ const polys = (shape) => (typeof shape[0][0][0] === 'number' ? [shape] : shape);
61
+
62
+ /**
63
+ * The cells a shape (drawn in grid units) covers by at least `min` of a k × k sample: its pixel rendering.
64
+ *
65
+ * @param {import('../geometry/types.js').Geom} shape The shape, in grid units.
66
+ * @param {{ min?: number, k?: number }} [o] The share of samples a cell needs (0.5) and the samples a side (4).
67
+ * @returns {[number, number][]} The cells.
68
+ */
69
+ export const raster = (shape, { min = 0.5, k = 4 } = {}) => { shape = polys(shape); const xs = shape.flat(2).map((p) => p[0]), ys = shape.flat(2).map((p) => p[1]), out = [];
70
+ for (let j = Math.floor(Math.min(...ys)); j < Math.ceil(Math.max(...ys)); j++) for (let i = Math.floor(Math.min(...xs)); i < Math.ceil(Math.max(...xs)); i++) {
71
+ let n = 0; for (let a = 0; a < k; a++) for (let b = 0; b < k; b++) n += inside(shape, i + (a + 0.5) / k, j + (b + 0.5) / k);
72
+ if (n / (k * k) >= min) out.push([i, j]); }
73
+ return out; };
74
+
75
+ /**
76
+ * Cells from rows of text, '#' or 'X' a cell (the format of PIX7 and the pixel animals), top-left at (i0, j0).
77
+ *
78
+ * @param {readonly string[]} rows The rows.
79
+ * @param {number} [i0=0] The first column.
80
+ * @param {number} [j0=0] The first row.
81
+ * @returns {[number, number][]} The cells.
82
+ */
83
+ export const fromRows = (rows, i0 = 0, j0 = 0) => rows.flatMap((row, j) => [...row].flatMap((ch, i) => (ch === '#' || ch === 'X' ? [[i0 + i, j0 + j]] : [])));
84
+
85
+ /**
86
+ * A w × h block of cells, its top-left at (i, j).
87
+ *
88
+ * @param {number} i Its first column.
89
+ * @param {number} j Its first row.
90
+ * @param {number} w Its width, in cells.
91
+ * @param {number} h Its height, in cells.
92
+ * @returns {[number, number][]} The cells.
93
+ */
94
+ export const block = (i, j, w, h) => [...Array(w * h)].map((_, k) => [i + (k % w), j + Math.floor(k / w)]);
95
+
96
+ /**
97
+ * The cells of a not in b.
98
+ *
99
+ * @param {readonly (readonly number[])[]} a The cells.
100
+ * @param {readonly (readonly number[])[]} b The cells taken away.
101
+ * @returns {number[][]} What's left.
102
+ */
103
+ export const minus = (a, b) => { const k = new Set(b.map((c) => c.join())); return a.filter((c) => !k.has(c.join())); };
104
+
105
+ /**
106
+ * A square frame n cells across with walls w thick, its top-left at (i, j).
107
+ *
108
+ * @param {number} i Its first column.
109
+ * @param {number} j Its first row.
110
+ * @param {number} n Its side, in cells.
111
+ * @param {number} w Its walls, in cells.
112
+ * @returns {number[][]} The cells.
113
+ */
114
+ export const squareFrame = (i, j, n, w) => minus(block(i, j, n, n), block(i + w, j + w, n - 2 * w, n - 2 * w));
115
+
116
+ /** Cells less their repeats. */
117
+ const uniq = (cells) => [...new Map(cells.map((c) => [c.join(), c])).values()];
118
+
119
+ /**
120
+ * A comet: the head's cells plus a trail behind them, `len` cells long against the direction `deg` (the way the head is
121
+ * moving: 0° right, 90° down), narrowing by `narrow` of its width by the end (0 a straight smear, 1 a point, negative a
122
+ * fan that spreads as it disperses). The ramp is 0 on the head's back edge (the head stays solid) and 1 just past the
123
+ * end of the trail, so a taper() over it fades the trail out. With `lanes` (for 0°, 90°, 180°, −90°) each row or column
124
+ * streams out behind its own last cell, so the trail is the shape smeared, not a block, and the ramp counts from that
125
+ * cell. `edge` (cells): only the rows whose last cell is within `edge` of the shape's back stream, from the back edge, so
126
+ * the trail echoes the back profile (a letter's stems and bars) without running into its counters.
127
+ *
128
+ * @param {readonly (readonly number[])[]} head The head's cells.
129
+ * @param {number} deg The direction it moves (degrees).
130
+ * @param {number} len The trail's length, in cells.
131
+ * @param {{ narrow?: number, lanes?: boolean, edge?: number | null }} [o] The narrowing (0.5), lanes (false), edge (null).
132
+ * @returns {{ cells: number[][], ramp: (x: number, y: number) => number }} The cells and the ramp over them.
133
+ */
134
+ export const comet = (head, deg, len, { narrow = 0.5, lanes = false, edge = null } = {}) => {
135
+ const u = [Math.cos((deg * Math.PI) / 180), Math.sin((deg * Math.PI) / 180)], v = [-u[1], u[0]];
136
+ const ctr = head.map(([i, j]) => [i + 0.5, j + 0.5]), cx = ctr.reduce((a, c) => a + c[0], 0) / ctr.length, cy = ctr.reduce((a, c) => a + c[1], 0) / ctr.length;
137
+ const along = ([x, y]) => (x - cx) * u[0] + (y - cy) * u[1], across = ([x, y]) => (x - cx) * v[0] + (y - cy) * v[1], key = (c) => Math.round(across(c) * 4);
138
+ const lane = new Map(); for (const c of ctr) lane.set(key(c), Math.min(lane.get(key(c)) ?? 1e9, along(c)));
139
+ const back0 = Math.min(...ctr.map(along)), backOf = (c) => (lanes ? lane.get(key(c)) : back0);
140
+ const W = Math.max(...ctr.map((c) => Math.abs(across(c)))) + 0.5, out = [...head];
141
+ const is = head.map((c) => c[0]), js = head.map((c) => c[1]), R = Math.ceil(len + 2 + Math.abs(Math.min(0, narrow)) * W);
142
+ for (let j = Math.min(...js) - R; j <= Math.max(...js) + R; j++) for (let i = Math.min(...is) - R; i <= Math.max(...is) + R; i++) {
143
+ const c = [i + 0.5, j + 0.5], b = backOf(c); if (b === undefined) continue; const d = b - along(c); if (d <= 0 || d > len + 0.01) continue;
144
+ if (edge != null && !(lane.get(key(c)) - back0 <= edge + 1e-9)) continue;
145
+ if (Math.abs(across(c)) <= W * (1 - (narrow * d) / len) + 1e-9) out.push([i, j]); }
146
+ return { cells: uniq(out), ramp: (x, y) => { const b = backOf([x, y]) ?? back0; return (b - along([x, y])) / (len + 0.5); } };
147
+ };
148
+
149
+ /**
150
+ * The cells within `depth` steps (Chebyshev) of a shape's cells, the shape included: room for a halo to break into.
151
+ *
152
+ * @param {readonly (readonly number[])[]} cells The cells.
153
+ * @param {number} [depth=1] How far.
154
+ * @returns {number[][]} The grown cells.
155
+ */
156
+ export const dilate = (cells, depth = 1) => uniq(cells.flatMap(([i, j]) => [...Array((2 * depth + 1) ** 2)].map((_, k) => [i + (k % (2 * depth + 1)) - depth, j + Math.floor(k / (2 * depth + 1)) - depth])));
157
+
158
+ /**
159
+ * A shape's cells less every cell within `depth` steps of its outside: its solid heart.
160
+ *
161
+ * @param {readonly (readonly number[])[]} cells The cells.
162
+ * @param {number} [depth=1] How far.
163
+ * @returns {number[][]} The heart.
164
+ */
165
+ export const erode = (cells, depth = 1) => { const set = new Set(cells.map((c) => c.join()));
166
+ return cells.filter(([i, j]) => [...Array((2 * depth + 1) ** 2)].every((_, k) => set.has(`${i + (k % (2 * depth + 1)) - depth},${j + Math.floor(k / (2 * depth + 1)) - depth}`))); };
167
+
168
+ /* ---------------------------------------------------------------- ramps ---------------------------------------------------------------- */
169
+ // A ramp maps a cell centre (x, y) in grid units to t: ≤ 0 solid, 1 gone (comet() returns its own).
170
+
171
+ /** A value held to 0…1. */
172
+ const clamp = (v) => Math.max(0, Math.min(1, v));
173
+
174
+ /**
175
+ * A linear ramp: 0 at distance a along the direction `deg` (0° right, 90° down) from (cx, cy), 1 at distance b.
176
+ *
177
+ * @param {number} deg The direction (degrees).
178
+ * @param {number} a Where it starts (t = 0).
179
+ * @param {number} b Where it ends (t = 1).
180
+ * @param {number} [cx=0] The origin.
181
+ * @param {number} [cy=0] The origin.
182
+ * @returns {(x: number, y: number) => number} The ramp.
183
+ */
184
+ export const linear = (deg, a, b, cx = 0, cy = 0) => { const u = [Math.cos((deg * Math.PI) / 180), Math.sin((deg * Math.PI) / 180)];
185
+ return (x, y) => ((x - cx) * u[0] + (y - cy) * u[1] - a) / (b - a); };
186
+
187
+ /**
188
+ * A ramp that is solid (−1) on the `solid` cells and `ramp` everywhere else: a shape kept whole beside its own dust.
189
+ *
190
+ * @param {readonly (readonly number[])[]} solid The cells kept solid.
191
+ * @param {(x: number, y: number) => number} ramp The ramp elsewhere.
192
+ * @returns {(x: number, y: number) => number} The ramp.
193
+ */
194
+ export const solidOn = (solid, ramp) => { const k = new Set(solid.map((c) => c.join())); return (x, y) => (k.has(`${Math.floor(x)},${Math.floor(y)}`) ? -1 : ramp(x, y)); };
195
+
196
+ /**
197
+ * A ramp by steps from a set of core cells (Chebyshev distance on the grid): 0 on the core, 1 at `depth` steps out.
198
+ *
199
+ * @param {readonly (readonly number[])[]} core The core's cells.
200
+ * @param {number} depth The steps to t = 1.
201
+ * @returns {(x: number, y: number) => number} The ramp.
202
+ */
203
+ export const steps = (core, depth) => { const set = core.map(([i, j]) => [i + 0.5, j + 0.5]);
204
+ return (x, y) => Math.min(...set.map(([a, b]) => Math.max(Math.abs(x - a), Math.abs(y - b)))) / depth; };
205
+
206
+ /* ---------------------------------------------------------------- taper ---------------------------------------------------------------- */
207
+
208
+ /**
209
+ * The dither zone's cell sizes (fractions of the pitch p), largest first: from a cell that leaves the house gap to its
210
+ * neighbours (1 − MIN_GAP/p) down to min/p, in n steps (n = 1: the largest only).
211
+ *
212
+ * @param {number} p The pitch.
213
+ * @param {number} n The steps.
214
+ * @param {number} [min=MIN_CELL] The smallest cell once fitted.
215
+ * @returns {number[]} The sizes.
216
+ * @throws {Error} When the pitch is under min + 22 (no room for one dither cell).
217
+ */
218
+ export const levels = (p, n, min = MIN_CELL) => { const a = 1 - MIN_GAP / p, b = min / p; if (a < b - 1e-9) throw new Error(`pitch ${p.toFixed(1)} < ${min + MIN_GAP}: no room for a dither cell`);
219
+ return n <= 1 || a - b < 1e-9 ? [a] : [...Array(n)].map((_, k) => a + ((b - a) * k) / (n - 1)); };
220
+
221
+ /**
222
+ * The pitch a set of cells gets once its longest side is fitted to 448 (an upper bound on the cells it spans).
223
+ *
224
+ * @param {readonly (readonly number[])[]} cells The cells.
225
+ * @returns {number} The pitch.
226
+ */
227
+ export const pitchOf = (cells) => { const is = cells.map((c) => c[0]), js = cells.map((c) => c[1]);
228
+ return SPAN / Math.max(Math.max(...is) - Math.min(...is) + 1, Math.max(...js) - Math.min(...js) + 1); };
229
+
230
+ /**
231
+ * Tapers a list of cells by a ramp. `n` is how many shrinking sizes the dither zone steps through; `size: false` keeps
232
+ * every dither cell at the largest size (a pure density taper); `density` is how much of the zone the mask drops (0
233
+ * none, a pure size taper; 1 everything by t = 1), `mask` a MASKS name or (i, j) → threshold, `gamma` bends the drop-out
234
+ * (above 1 keeps more near the core); `min` is the smallest dither cell once fitted; `turn` the angle the last of the n
235
+ * steps is turned to, each step its share, so the squares tip over as they fade (sprite() keeps a turned square inside
236
+ * its upright box, so check its side, size / (|cos| + |sin|), against the floor).
237
+ *
238
+ * @param {readonly (readonly number[])[]} cells The candidate cells (the core and its trail or halo).
239
+ * @param {{ ramp: (x: number, y: number) => number, n?: number, size?: boolean, density?: number, mask?: string | ((i: number, j: number) => number), gamma?: number, min?: number, turn?: number }} o
240
+ * The ramp and the taper's settings (n 3, size true, density 0.75, mask 'bayer4', gamma 1, min MIN_CELL, turn 0).
241
+ * @returns {number[][]} [i, j, f] per kept cell (with `turn`, [i, j, f, deg]), f its size as a share of the pitch (1 a
242
+ * full, touching pixel).
243
+ */
244
+ export const taper = (cells, { ramp, n = 3, size = true, density = 0.75, mask = 'bayer4', gamma = 1, min = MIN_CELL, turn = 0 } = {}) => {
245
+ const lv = levels(pitchOf(cells), n, min), m = typeof mask === 'function' ? mask : MASKS[mask], out = [];
246
+ for (const [i, j] of uniq(cells)) { const t = ramp(i + 0.5, j + 0.5);
247
+ if (t <= 0) { out.push([i, j, 1]); continue; } if (t >= 1) continue;
248
+ if (density && m(i, j) < density * clamp(t) ** gamma) continue;
249
+ const k = Math.min(n - 1, Math.floor(t * n)), f = size ? lv[Math.min(lv.length - 1, k)] : lv[0];
250
+ out.push(turn ? [i, j, f, (turn * (k + 1)) / n] : [i, j, f]); }
251
+ return out; };
252
+
253
+ /**
254
+ * Cells drawn by hand as rows of text: '#' a full cell, '1' … '9' a dither cell at that size (1 the largest, the pitch
255
+ * less 22; n the smallest, min once fitted), anything else empty unless `legend` names it ({ ch: [size, deg] }: a
256
+ * dither cell at that size turned by deg; keep a turned square's side at min or more). For a small grid whose dither is
257
+ * designed cell by cell rather than thresholded.
258
+ *
259
+ * @param {readonly string[]} rows The rows.
260
+ * @param {{ n?: number, min?: number, legend?: Record<string, number[]>, i0?: number, j0?: number }} [o] The sizes (3),
261
+ * the floor (MIN_CELL), the legend and the top-left cell.
262
+ * @returns {number[][]} Cells for sprite(): [i, j, f] or [i, j, f, deg].
263
+ * @throws {Error} For a size past n.
264
+ */
265
+ export const mapCells = (rows, { n = 3, min = MIN_CELL, legend = {}, i0 = 0, j0 = 0 } = {}) => {
266
+ const all = rows.flatMap((row, j) => [...row].flatMap((ch, i) => (ch === '#' || /[1-9]/.test(ch) || legend[ch] ? [[i0 + i, j0 + j, ch]] : [])));
267
+ const lv = levels(pitchOf(all), n, min);
268
+ return all.map(([i, j, ch]) => { if (ch === '#') return [i, j, 1];
269
+ const [k, deg = 0] = legend[ch] || [+ch]; if (k > lv.length) throw new Error(`size ${ch} past n = ${n}`);
270
+ return deg ? [i, j, lv[k - 1], deg] : [i, j, lv[k - 1]]; }); };
271
+
272
+ /**
273
+ * Full cells touching only at a corner (a hairline join at any size): each 2 × 2 checker's top-left.
274
+ *
275
+ * @param {readonly (readonly number[])[]} cells Tapered cells ([i, j, f…]).
276
+ * @returns {number[][]} The checkers' top-left cells.
277
+ */
278
+ export const corners = (cells) => { const full = new Set(cells.filter((c) => c[2] === 1).map((c) => `${c[0]},${c[1]}`)), has = (i, j) => full.has(`${i},${j}`), hits = [];
279
+ for (const k of full) { const [i, j] = k.split(',').map(Number);
280
+ if (has(i + 1, j + 1) && !has(i + 1, j) && !has(i, j + 1)) hits.push([i, j]); if (has(i - 1, j + 1) && !has(i - 1, j) && !has(i, j + 1)) hits.push([i - 1, j]); }
281
+ return hits; };
282
+
283
+ /**
284
+ * Draws tapered cells on the 512 canvas, the grid scaled so the longest side of all the cells' boxes is 448, centred.
285
+ * Full cells fuse into one solid (overlapping by 0.01, as the kit's pixels() does). A dither cell sits against the far
286
+ * side of its grid cell, away from any full cell beside it (or, with none, towards `push`), so the gap behind it is what
287
+ * it lost: the house 22 behind the largest dither size. Then every gap is checked: a dither cell nearer than MIN_GAP to
288
+ * the solid or to another dither cell is centred on that axis, and dropped if that isn't enough. `round` draws the
289
+ * dither cells as dots and rounds the core's exposed outer corners (radius r of the pitch). A cell's fourth value turns
290
+ * its square by that many degrees, kept inside the same box, so every gap still holds.
291
+ *
292
+ * @param {readonly (readonly number[])[]} cells Tapered cells ([i, j, f] or [i, j, f, deg]).
293
+ * @param {{ round?: boolean, r?: number, push?: number[], allowCorners?: boolean }} [o] Round dots (false), the corner
294
+ * radius (0.5 of the pitch), the push with no solid beside ([0, 0]), and whether corner-only contacts are allowed.
295
+ * @returns {import('../geometry/types.js').Shape} The mark.
296
+ * @throws {Error} On full cells that touch only at a corner (unless allowed).
297
+ */
298
+ export const sprite = (cells, { round = false, r = 0.5, push = [0, 0], allowCorners = false } = {}) => {
299
+ const hits = corners(cells); if (hits.length && !allowCorners) throw new Error(`full cells touch only at a corner at ${hits.map((h) => h.join(',')).join(' ')}`);
300
+ const p = pitchOf(cells), is = cells.map((c) => c[0]), js = cells.map((c) => c[1]);
301
+ const x0 = 256 - (p * (Math.max(...is) + 1 + Math.min(...is))) / 2, y0 = 256 - (p * (Math.max(...js) + 1 + Math.min(...js))) / 2;
302
+ const full = new Set(cells.filter((c) => c[2] === 1).map((c) => `${c[0]},${c[1]}`)), has = (i, j) => full.has(`${i},${j}`), e = 0.01, sgn = (v) => Math.sign(v);
303
+ // dither cells as boxes [x0, y0, x1, y1], pushed away from the solid beside them
304
+ const N8 = [[-1, -1], [0, -1], [1, -1], [-1, 0], [1, 0], [-1, 1], [0, 1], [1, 1]];
305
+ const dots = cells.filter((c) => c[2] < 1).map(([i, j, f, deg = 0]) => { const v = N8.reduce((a, [di, dj]) => (has(i + di, j + dj) ? [a[0] + di, a[1] + dj] : a), [0, 0]);
306
+ const d = v[0] || v[1] ? [-sgn(v[0]), -sgn(v[1])] : push; return { i, j, s: f * p, d: [...d], deg }; });
307
+ const boxOf = (q) => { const cx = x0 + (q.i + 0.5) * p + (q.d[0] * (p - q.s)) / 2, cy = y0 + (q.j + 0.5) * p + (q.d[1] * (p - q.s)) / 2; return [cx - q.s / 2, cy - q.s / 2, cx + q.s / 2, cy + q.s / 2]; };
308
+ const gap = (a, b) => Math.hypot(Math.max(0, a[0] - b[2], b[0] - a[2]), Math.max(0, a[1] - b[3], b[1] - a[3]));
309
+ const cellBox = (i, j) => [x0 + i * p, y0 + j * p, x0 + (i + 1) * p, y0 + (j + 1) * p], tol = 1e-6;
310
+ const solidGap = (q) => Math.min(Infinity, ...N8.filter(([di, dj]) => has(q.i + di, q.j + dj)).map(([di, dj]) => gap(boxOf(q), cellBox(q.i + di, q.j + dj))));
311
+ let live = dots.filter((q) => { if (solidGap(q) >= MIN_GAP - tol) return true; q.d = [0, 0]; return solidGap(q) >= MIN_GAP - tol; });
312
+ for (let pass = 0; pass < 3; pass++) for (const a of live) for (const b of live) { if (a === b || Math.abs(a.i - b.i) > 1 || Math.abs(a.j - b.j) > 1 || gap(boxOf(a), boxOf(b)) >= MIN_GAP - tol) continue;
313
+ for (const q of [a, b]) for (const k of [0, 1]) if (q.d[k] && (k ? q.j !== (q === a ? b.j : a.j) : q.i !== (q === a ? b.i : a.i))) { const was = q.d[k]; q.d[k] = 0; if (solidGap(q) < MIN_GAP - tol) q.d[k] = was; } }
314
+ live = live.filter((a, n) => !live.some((b, m) => m < n && Math.abs(a.i - b.i) <= 1 && Math.abs(a.j - b.j) <= 1 && gap(boxOf(a), boxOf(b)) < MIN_GAP - tol));
315
+ return U(...cells.filter((c) => c[2] === 1).map(([i, j]) => { const x = x0 + i * p, y = y0 + j * p;
316
+ if (!round) return rect(x - e, y - e, p + 2 * e, p + 2 * e);
317
+ const k = (a, b) => (!has(i + a, j) && !has(i, j + b) && !has(i + a, j + b) ? r * p : 0); // round a corner only where it's exposed
318
+ return rr(x - e, y - e, p + 2 * e, p + 2 * e, [k(-1, -1), k(1, -1), k(1, 1), k(-1, 1)]); }),
319
+ ...live.map((q) => { const [ax, ay, bx, by] = boxOf(q), cx = (ax + bx) / 2, cy = (ay + by) / 2;
320
+ if (round) return circle(cx, cy, q.s / 2, 64); if (!q.deg) return rect(ax, ay, q.s, q.s);
321
+ const a = (q.deg * Math.PI) / 180, w = q.s / (Math.abs(Math.cos(a)) + Math.abs(Math.sin(a))); // a turned square inside the upright box
322
+ return rot(rect(cx - w / 2, cy - w / 2, w, w), q.deg, cx, cy); })); };
323
+
324
+ /* ---------------------------------------------------------------- free squares ---------------------------------------------------------------- */
325
+
326
+ /**
327
+ * The upright box of a square of side s turned by deg.
328
+ *
329
+ * @param {number} s The side.
330
+ * @param {number} [deg=0] The turn (degrees).
331
+ * @returns {number} The box's side.
332
+ */
333
+ export const turnBox = (s, deg = 0) => s * (Math.abs(Math.cos((deg * Math.PI) / 180)) + Math.abs(Math.sin((deg * Math.PI) / 180)));
334
+
335
+ /**
336
+ * The side of a square turned by deg whose upright box is b across (turnBox's inverse).
337
+ *
338
+ * @param {number} b The box's side.
339
+ * @param {number} [deg=0] The turn (degrees).
340
+ * @returns {number} The square's side.
341
+ */
342
+ export const turnedSide = (b, deg = 0) => b / turnBox(1, deg);
343
+
344
+ /**
345
+ * Squares as one shape, each turned about its own centre.
346
+ *
347
+ * @param {readonly (readonly number[])[]} list The squares, [x, y, s, deg].
348
+ * @returns {import('../geometry/types.js').Shape} The squares.
349
+ */
350
+ export const squares = (list) => U(...list.map(([x, y, s, deg = 0]) => (deg ? rot(rect(x, y, s, s), deg, x + s / 2, y + s / 2) : rect(x, y, s, s))));
351
+
352
+ /**
353
+ * The bounding box of a square list (turned squares by their upright boxes).
354
+ *
355
+ * @param {readonly (readonly number[])[]} list The squares, [x, y, s, deg].
356
+ * @returns {import('../geometry/types.js').Box} [x0, y0, x1, y1].
357
+ */
358
+ export const extent = (list) => { const bs = list.map(([x, y, s, deg = 0]) => { const b = turnBox(s, deg) / 2, cx = x + s / 2, cy = y + s / 2; return [cx - b, cy - b, cx + b, cy + b]; });
359
+ return [Math.min(...bs.map((b) => b[0])), Math.min(...bs.map((b) => b[1])), Math.max(...bs.map((b) => b[2])), Math.max(...bs.map((b) => b[3]))]; };
360
+
361
+ /**
362
+ * A square list moved so its box is centred on the 512 canvas.
363
+ *
364
+ * @param {readonly (readonly number[])[]} list The squares, [x, y, s, deg].
365
+ * @returns {number[][]} The squares, moved.
366
+ */
367
+ export const centred = (list) => { const [x0, y0, x1, y1] = extent(list), dx = 256 - (x0 + x1) / 2, dy = 256 - (y0 + y1) / 2; return list.map(([x, y, s, deg = 0]) => [x + dx, y + dy, s, deg]); };
368
+
369
+ /**
370
+ * A resolution run: columns left to right, column k a stack of counts[k] squares the same height H (1 one solid block,
371
+ * 2 two squares, 3 three…), the gap between columns and between the squares of a column; H is solved so the run is
372
+ * `span` wide ([1, 2, 3, 4] at 448: H 203.6, the smallest side 34.4).
373
+ *
374
+ * @param {readonly number[]} counts Each column's squares.
375
+ * @param {{ span?: number, gap?: number }} [o] The run's width (SPAN) and the gap (MIN_GAP).
376
+ * @returns {{ list: number[][], H: number, side: number }} The squares (top-left at 0, 0), the height and the smallest
377
+ * side.
378
+ */
379
+ export const resRun = (counts, { span = SPAN, gap = MIN_GAP } = {}) => {
380
+ // width = Σ (H − (n − 1) gap) / n + gap (columns − 1) = span, linear in H
381
+ const a = counts.reduce((t, n) => t + 1 / n, 0), b = counts.reduce((t, n) => t - ((n - 1) * gap) / n, 0) + gap * (counts.length - 1), H = (span - b) / a;
382
+ let x = 0; const list = [];
383
+ for (const n of counts) { const s = (H - (n - 1) * gap) / n; for (let k = 0; k < n; k++) list.push([x, k * (s + gap), s]); x += s + gap; }
384
+ return { list, H, side: Math.min(...list.map((q) => q[2])) }; };
385
+
386
+ /**
387
+ * A single file of squares along x, each [side, deg], MIN_GAP between their upright boxes, each set on the file's
388
+ * 'top', 'bottom' or 'centre' line. With `span` the sides are relative, scaled so the file is that long (the gaps stay).
389
+ * The first box's top-left is at (0, 0).
390
+ *
391
+ * @param {readonly (readonly number[])[]} items The squares, [side, deg].
392
+ * @param {{ gap?: number, align?: 'top' | 'bottom' | 'centre', span?: number }} [o] The gap (MIN_GAP), the line
393
+ * (centre) and the length (0: as drawn).
394
+ * @returns {number[][]} The squares, [x, y, s, deg].
395
+ */
396
+ export const file = (items, { gap = MIN_GAP, align = 'centre', span = 0 } = {}) => {
397
+ if (span) { const k = (span - gap * (items.length - 1)) / items.reduce((t, [s, deg = 0]) => t + turnBox(s, deg), 0); items = items.map(([s, deg = 0]) => [s * k, deg]); }
398
+ const Hb = Math.max(...items.map(([s, deg = 0]) => turnBox(s, deg)));
399
+ let x = 0; return items.map(([s, deg = 0]) => { const b = turnBox(s, deg), by = align === 'top' ? 0 : align === 'bottom' ? Hb - b : (Hb - b) / 2, q = [x + (b - s) / 2, by + (b - s) / 2, s, deg]; x += b + gap; return q; }); };
@@ -0,0 +1,6 @@
1
+ import type { Shape } from '../geometry/types.js';
2
+
3
+ /** The emote glyphs on a 9-row grid, '#' a cell. */
4
+ export const EMOTE_GLYPHS: Record<string, string[]>;
5
+ /** An emoticon typed left to right, each glyph at pitch 2g, the glyphs parted by exactly g. */
6
+ export function emote(text: string | readonly string[], g: number, G?: Readonly<Record<string, readonly string[]>>): Shape;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Club emotes: a typed emoticon read left to right, each glyph a chunky pixel sprite (the kit's pixels()) at pitch 2g,
3
+ * the glyphs parted by exactly the gap g.
4
+ * From the Logomarks tools (emoteparts.mjs, batches 259 and 315; their glyph tables merged, the keys both drew the same).
5
+ */
6
+ import { U, move, bbox, pixels } from '../geometry/index.js';
7
+
8
+ /**
9
+ * The emote glyphs on a 9-row grid, '#' a cell: eyes in rows 1–6, mouths the full height, strokes mostly two cells
10
+ * wide. Keys of more than one character name a variant (m3 the big mouth 3; BO the big O; gt the mouth >; >e and <e
11
+ * squinting eyes; nose, ball, stare and head parts of particular faces).
12
+ */
13
+ export const EMOTE_GLYPHS = {
14
+ // ---- batch 259
15
+ ';': ['..', '..', '##', '##', '..', '##', '##', '#.', '..'],
16
+ ':': ['..', '..', '##', '##', '..', '##', '##', '..', '..'],
17
+ 8: ['...', '...', '###', '#.#', '###', '#.#', '###', '...', '...'],
18
+ ')': ['##..', '.##.', '..##', '..##', '..##', '..##', '..##', '.##.', '##..'],
19
+ D: ['####..', '######', '##..##', '##..##', '##..##', '##..##', '##..##', '######', '####..'],
20
+ P: ['######', '######', '##..##', '##..##', '######', '######', '##....', '##....', '##....'],
21
+ '<': ['.....', '.....', '...##', '..##.', '.##..', '##...', '.##..', '..##.', '...##'],
22
+ 3: ['.....', '.....', '####.', '...##', '...##', '.###.', '...##', '...##', '####.'],
23
+ '=': ['....', '....', '....', '####', '....', '####', '....', '....', '....'],
24
+ ']': ['####', '####', '..##', '..##', '..##', '..##', '..##', '####', '####'],
25
+ m3: ['#####', '#####', '...##', '...##', '.####', '...##', '...##', '#####', '#####'],
26
+ x: ['......', '......', '##..##', '.####.', '..##..', '.####.', '##..##', '......', '......'],
27
+ '^': ['......', '......', '..##..', '.####.', '##..##', '......', '......', '......', '......'],
28
+ _: ['....', '....', '....', '....', '....', '....', '....', '####', '####'],
29
+ '-': ['....', '....', '....', '####', '####', '....', '....', '....', '....'],
30
+ o: ['....', '....', '....', '####', '#..#', '#..#', '####', '....', '....'],
31
+ BO: ['......', '.####.', '######', '##..##', '##..##', '##..##', '######', '.####.', '......'],
32
+ '>': ['...', '##.', '.##', '##.', '...', '...', '...', '...', '...'],
33
+ '|': ['..', '##', '##', '##', '##', '##', '##', '##', '..'],
34
+ S: ['......', '.####.', '##..##', '##....', '.####.', '....##', '##..##', '.####.', '......'],
35
+ v: ['......', '......', '##..##', '##..##', '.####.', '.####.', '..##..', '..##..', '......'],
36
+ T: ['......', '######', '######', '..##..', '..##..', '..##..', '..##..', '......', '......'],
37
+ c: ['.....', '.....', '.####', '#####', '##...', '##...', '#####', '.####', '.....'],
38
+ gt: ['##...', '.##..', '..##.', '...##', '...##', '...##', '..##.', '.##..', '##...'],
39
+ u: ['......', '......', '......', '##..##', '##..##', '##..##', '##..##', '######', '.####.'],
40
+ w: ['........', '........', '........', '##....##', '##....##', '##.##.##', '##.##.##', '########', '.##..##.'],
41
+ '>e': ['.....', '.....', '##...', '.##..', '..##.', '.##..', '##...', '.....', '.....'],
42
+ '<e': ['.....', '.....', '...##', '..##.', '.##..', '..##.', '...##', '.....', '.....'],
43
+ // ---- batch 315
44
+ /** The nose caret of :^), at the eyes' middle. */
45
+ nose: ['......', '......', '......', '..##..', '.####.', '##..##', '......', '......', '......'],
46
+ /** The clown's round nose, a solid disc six cells across (four cells across read as a plus). */
47
+ ball: ['......', '......', '.####.', '######', '######', '######', '######', '.####.', '......'],
48
+ /** A square spiral, the dizzy eye of @_@ (one-cell strokes and spaces). */
49
+ '@': ['.......', '#######', '......#', '#####.#', '#...#.#', '#.###.#', '#.....#', '#######', '.......'],
50
+ /** The dollar: the S of batch 259 (one-cell bars, or it reads 5) with the stem's ticks above and below. */
51
+ $: ['..##..', '.####.', '##..##', '##....', '.####.', '....##', '##..##', '.####.', '..##..'],
52
+ /** The look of disapproval's eye (ಠ): a ring round a dot, with the tick on its crown. */
53
+ stare: ['##....', '######', '#....#', '#.##.#', '#.##.#', '#....#', '######', '......', '......'],
54
+ 9: ['.####.', '##..##', '##..##', '.#####', '....##', '....##', '.####.', '......', '......'],
55
+ /** The arms of \o/, stepped two rows a cell. */
56
+ '\\': ['##...', '##...', '.##..', '.##..', '..##.', '..##.', '...##', '.....', '.....'],
57
+ '/': ['...##', '...##', '..##.', '..##.', '.##..', '.##..', '##...', '.....', '.....'],
58
+ /** The head of \o/, a ring between the raised hands. */
59
+ head: ['....', '....', '....', '####', '#..#', '#..#', '####', '....', '....'],
60
+ /** The dot mouth of >.>. */
61
+ '.': ['..', '..', '..', '..', '..', '##', '##', '..', '..'],
62
+ '~': ['........', '........', '........', '.###....', '#####..#', '#..#####', '....###.', '........', '........'],
63
+ '[': ['####', '####', '##..', '##..', '##..', '##..', '##..', '####', '####'],
64
+ };
65
+
66
+ /**
67
+ * An emoticon typed left to right: each glyph at pitch 2g, the glyphs parted by exactly the gap g.
68
+ *
69
+ * @param {string | readonly string[]} text The glyphs: a string, or a list of EMOTE_GLYPHS keys for the multi-letter ones.
70
+ * @param {number} g The gap.
71
+ * @param {Readonly<Record<string, readonly string[]>>} [G=EMOTE_GLYPHS] The glyph table.
72
+ * @returns {import('../geometry/types.js').Shape} The emote as one shape.
73
+ */
74
+ export const emote = (text, g, G = EMOTE_GLYPHS) => { const p = 2 * g; let x = 0; const out = [];
75
+ for (const ch of (Array.isArray(text) ? text : [...text])) { const s = pixels(G[ch], p, 0, 0), [x0, , x1] = bbox(U(s)); out.push(move(s, x - x0, 0)); x += x1 - x0 + g; }
76
+ return U(...out); };
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Pixel marks: dither tapers (a solid pixel core breaking up along a ramp, every cell and gap held to what survives
3
+ * 32 px), free squares laid out by size, and typed emotes of chunky pixel glyphs.
4
+ */
5
+ export type { Cells, Ramp, Mask, TaperOptions } from './dither.js';
6
+ export * from './dither.js';
7
+ export * from './emote.js';
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @module @logomarks/core/pixels
3
+ *
4
+ * Pixel marks: dither tapers (a solid pixel core breaking up along a ramp, every cell and gap held to what survives
5
+ * 32 px), free squares laid out by size, and typed emotes of chunky pixel glyphs.
6
+ */
7
+ export * from './dither.js';
8
+ export * from './emote.js';
@@ -54,3 +54,5 @@ export function flink(a: Field, b: Field, g: number, aOver: (x: number, y: numbe
54
54
 
55
55
  /** A field's zero contour traced into a kit shape (marching squares, step h = 1, over −64…576). */
56
56
  export function shapeOf(f: Field, area?: { h?: number; x0?: number; y0?: number; x1?: number; y1?: number }): Shape;
57
+
58
+ export { pinch, ballArm, node, blob, stadium } from './y2k.js';
package/src/sdf/index.js CHANGED
@@ -5,8 +5,10 @@
5
5
  * (a smooth minimum) instead of meeting at a crease. A field is (x, y) → distance, negative inside, on the 512 canvas.
6
6
  * Build fields, combine them (smin, smax, ssub, fmin, fmax, fsub), cut the house gap (gapCut, fover, flink), then
7
7
  * shapeOf() traces the result into a kit shape for the usual booleans and fit.
8
+ * The Y2K parts (pinch, ballArm, node, blob, stadium) are built from them.
8
9
  */
9
10
  export { sdCircle, segT, sdCapsule, sdPath, arcPts, sdBox, sdEllipse, sdRing, sdPoly } from './fields.js';
10
11
  export { shell, turn, offset, smin2, smin, smax, ssub, fmin, fmax, fsub } from './ops.js';
11
12
  export { gapCut, fover, flink } from './gap.js';
12
13
  export { shapeOf } from './trace.js';
14
+ export { pinch, ballArm, node, blob, stadium } from './y2k.js';
@@ -0,0 +1,12 @@
1
+ import type { Field, Pt } from './index.js';
2
+
3
+ /** A bar from a to b, w wide at its ends, waisted n times to `waist` wide (a cosine profile). */
4
+ export function pinch(a: Pt, b: Pt, w: number, waist: number, n?: number): Field;
5
+ /** An arm from the hub c at deg: a stem ending in a ball of radius r, melted with blend k. */
6
+ export function ballArm(c: Pt, deg: number, len: number, stem: number, r: number, k?: number): Field;
7
+ /** n ball-ended arms from (cx, cy) melted into a hub. */
8
+ export function node(cx: number, cy: number, n: number, len: number, stem: number, r: number, o?: { deg0?: number; hub?: number; k?: number; kArm?: number }): Field;
9
+ /** A smooth blob: discs [cx, cy, r] melted with blend k. */
10
+ export function blob(discs: readonly (readonly number[])[], k?: number): Field;
11
+ /** A stadium (a pill) centred on (cx, cy), w × h. */
12
+ export function stadium(cx: number, cy: number, w: number, h: number): Field;
package/src/sdf/y2k.js ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The Y2K family's parts as fields: a bar waisted along its length, an arm ending in a ball, a node of ball-ended arms
3
+ * melted into a hub, a smooth blob of discs and a stadium frame. Combine and trace them like any other field.
4
+ * From the Logomarks tools (y2kparts.mjs, batch 232, "Bold · Y2K").
5
+ */
6
+ import { sdCircle, segT, sdCapsule } from './fields.js';
7
+ import { smin } from './ops.js';
8
+
9
+ /**
10
+ * A bar from a to b, w wide at its ends, waisted n times along its length to `waist` wide (n = 1 an hourglass bar, 2
11
+ * two beads' necks…; a waist wider than w swells instead). The waist follows a cosine, so the outline has no corners;
12
+ * the ends are round.
13
+ *
14
+ * @param {readonly number[]} a One end.
15
+ * @param {readonly number[]} b The other.
16
+ * @param {number} w The width at the ends.
17
+ * @param {number} waist The width at each waist.
18
+ * @param {number} [n=1] Waists along it.
19
+ * @returns {import('./index.js').Field} The bar.
20
+ */
21
+ export const pinch = (a, b, w, waist, n = 1) => (x, y) => {
22
+ const [d, t] = segT(a, b, x, y);
23
+ return d - (w - ((w - waist) * (1 - Math.cos(2 * Math.PI * n * t))) / 2) / 2;
24
+ };
25
+
26
+ /**
27
+ * An arm from the hub c at `deg`, `len` to the ball's centre: a stem `stem` wide ending in a ball of radius r, melted
28
+ * with blend k.
29
+ *
30
+ * @param {readonly number[]} c The hub.
31
+ * @param {number} deg The arm's direction (degrees, y down).
32
+ * @param {number} len Hub to ball centre.
33
+ * @param {number} stem The stem's width.
34
+ * @param {number} r The ball's radius.
35
+ * @param {number} [k=30] The blend.
36
+ * @returns {import('./index.js').Field} The arm.
37
+ */
38
+ export const ballArm = (c, deg, len, stem, r, k = 30) => {
39
+ const a = (deg * Math.PI) / 180, e = [c[0] + len * Math.cos(a), c[1] + len * Math.sin(a)];
40
+ return smin(k, sdCapsule(c, e, stem), sdCircle(e[0], e[1], r));
41
+ };
42
+
43
+ /**
44
+ * A node: n ball-ended arms from (cx, cy), the first at deg0, melted into a hub of radius `hub` (0: none).
45
+ *
46
+ * @param {number} cx The hub's centre.
47
+ * @param {number} cy The hub's centre.
48
+ * @param {number} n Arms.
49
+ * @param {number} len Hub to ball centre.
50
+ * @param {number} stem The stems' width.
51
+ * @param {number} r The balls' radius.
52
+ * @param {{ deg0?: number, hub?: number, k?: number, kArm?: number }} [o] The first arm's direction (−90), the hub's
53
+ * radius (0), the hub's blend (40) and each arm's (30).
54
+ * @returns {import('./index.js').Field} The node.
55
+ */
56
+ export const node = (cx, cy, n, len, stem, r, { deg0 = -90, hub = 0, k = 40, kArm = 30 } = {}) =>
57
+ smin(k, ...(hub ? [sdCircle(cx, cy, hub)] : []), ...[...Array(n)].map((_, i) => ballArm([cx, cy], deg0 + (i * 360) / n, len, stem, r, kArm)));
58
+
59
+ /**
60
+ * A smooth blob: discs [cx, cy, r] melted with blend k.
61
+ *
62
+ * @param {readonly (readonly number[])[]} discs The discs.
63
+ * @param {number} [k=90] The blend.
64
+ * @returns {import('./index.js').Field} The blob.
65
+ */
66
+ export const blob = (discs, k = 90) => smin(k, ...discs.map(([cx, cy, r]) => sdCircle(cx, cy, r)));
67
+
68
+ /**
69
+ * A stadium (a pill) centred on (cx, cy), w × h, as a field.
70
+ *
71
+ * @param {number} cx The centre.
72
+ * @param {number} cy The centre.
73
+ * @param {number} w The width.
74
+ * @param {number} h The height.
75
+ * @returns {import('./index.js').Field} The stadium.
76
+ */
77
+ export const stadium = (cx, cy, w, h) => {
78
+ const r = Math.min(w, h) / 2;
79
+ return w >= h ? sdCapsule([cx - w / 2 + r, cy], [cx + w / 2 - r, cy], h) : sdCapsule([cx, cy - h / 2 + r], [cx, cy + h / 2 - r], w);
80
+ };
@@ -0,0 +1,14 @@
1
+ import type { Point, Point3, Polygon } from '../geometry/types.js';
2
+
3
+ /** a − b, in 2D. */
4
+ export function sub(a: readonly number[], b: readonly number[]): Point;
5
+ /** a + b, in 2D. */
6
+ export function add(a: readonly number[], b: readonly number[]): Point;
7
+ /** a scaled by k, in 2D. */
8
+ export function mul(a: readonly number[], k: number): Point;
9
+ /** The cross product of two 3D vectors. */
10
+ export function cross3(a: readonly number[], b: readonly number[]): Point3;
11
+ /** A ring's signed area by the shoelace sum, added point by point. */
12
+ export function shoelace(r: readonly (readonly number[])[]): number;
13
+ /** A rectangle from its corners (x0, y0) and (x1, y1). */
14
+ export function box(x0: number, y0: number, x1: number, y1: number): Polygon;