@johnmorrisdotca/dougu-sports 0.7.2 → 0.9.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 (46) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/NOTICE.md +1 -1
  3. package/README.md +58 -28
  4. package/dist/emblems/ball/ball.baseball.js +2 -1
  5. package/dist/emblems/ball/ball.contrast.d.ts +4 -0
  6. package/dist/emblems/ball/ball.contrast.js +22 -0
  7. package/dist/emblems/ball/ball.core.d.ts +42 -0
  8. package/dist/emblems/ball/ball.core.js +34 -1
  9. package/dist/emblems/ball/ball.engine.d.ts +70 -4
  10. package/dist/emblems/ball/ball.engine.js +70 -20
  11. package/dist/emblems/ball/ball.family.seamed.d.ts +4 -0
  12. package/dist/emblems/ball/ball.family.seamed.js +42 -7
  13. package/dist/emblems/ball/ball.image.d.ts +8 -0
  14. package/dist/emblems/ball/ball.image.js +14 -5
  15. package/dist/emblems/ball/ball.index.js +2 -4
  16. package/dist/emblems/ball/ball.letters.d.ts +3 -0
  17. package/dist/emblems/ball/ball.letters.js +51 -0
  18. package/dist/emblems/ball/ball.print.d.ts +81 -0
  19. package/dist/emblems/ball/ball.print.js +268 -0
  20. package/dist/emblems/ball/ball.printable.d.ts +6 -0
  21. package/dist/emblems/ball/ball.printable.js +13 -0
  22. package/dist/emblems/ball/ball.region.d.ts +12 -0
  23. package/dist/emblems/ball/ball.region.js +94 -0
  24. package/dist/emblems/ball/ball.script.d.ts +15 -0
  25. package/dist/emblems/ball/ball.script.js +107 -0
  26. package/dist/emblems/ball/ball.soccer.js +7 -3
  27. package/dist/emblems/ball/ball.soccer.panels.d.ts +2 -0
  28. package/dist/emblems/ball/ball.soccer.panels.js +6 -3
  29. package/dist/emblems/ball/ball.soccer.size.d.ts +2 -0
  30. package/dist/emblems/ball/ball.soccer.size.js +8 -0
  31. package/dist/emblems/ball/ball.softball.js +2 -1
  32. package/dist/emblems/ball/ball.warp.d.ts +48 -0
  33. package/dist/emblems/ball/ball.warp.js +441 -0
  34. package/dist/emblems/emblem.core.d.ts +13 -0
  35. package/dist/emblems-entry.d.ts +1 -0
  36. package/dist/index.d.ts +5 -5
  37. package/dist/index.js +4 -5
  38. package/dist/turn-control.d.ts +24 -5
  39. package/dist/turn-control.js +67 -23
  40. package/dist/turn-full.d.ts +15 -0
  41. package/dist/turn-full.js +19 -0
  42. package/dist/turn-registry.d.ts +1 -0
  43. package/dist/turn-registry.js +3 -0
  44. package/dist/version.d.ts +1 -1
  45. package/dist/version.js +1 -1
  46. package/package.json +10 -10
@@ -0,0 +1,268 @@
1
+ // What a ball is printed with: a logo wrapped onto the side of the ball, a stamp of text, a cursive maker's word and a small mark, laid out as a label on a flat plane that touches the ball at one point and
2
+ // carried onto the sphere with an equal-distance map, so every line of it curves with the ball and turns with it. Everything is our own drawing: our own cursive script (ball.script.ts), our own block
3
+ // lettering (ball.letters.ts), our own stars and circled letter, and no maker's, league's or team's name or mark; a logo and a mark of the caller's own are drawn from the SVG they give, made safe first.
4
+ // Imports only this folder's helpers.
5
+ import { escape, hashOf, hexOf, readColour } from "./ball.core.js";
6
+ import { contrast } from "./ball.contrast.js";
7
+ import { hasPrint } from "./ball.core.js";
8
+ import { safeImage } from "./ball.image.js";
9
+ import { lineStrokes, textWidth } from "./ball.letters.js";
10
+ import { add, mul, norm } from "./ball.math.js";
11
+ import { scriptRuns, scriptWidth } from "./ball.script.js";
12
+ import { signaturePath } from "./ball.signature.js";
13
+ import { drawPicture, drawShapes, flattenPath, shapesOf } from "./ball.warp.js";
14
+ const NAVY = "#1b2a5c";
15
+ const MAX_SVG = 60000;
16
+ /** The ink for a print on the given grounds: the wanted colour if it shows on all of them (3 to 1), else the first of navy, white and near-black that does. */
17
+ export function inkFor(grounds, wanted) {
18
+ const w = wanted === undefined ? undefined : readColour(wanted);
19
+ const list = [...(w === undefined ? [] : [hexOf(w)]), NAVY, "#ffffff", "#101418"];
20
+ return list.find((c) => grounds.every((g) => contrast(c, g) >= 3)) ?? list[list.length - 1];
21
+ }
22
+ const clean = (v, max) => {
23
+ if (typeof v !== "string" && typeof v !== "number")
24
+ return undefined;
25
+ const t = [...String(v).replace(/\p{Cc}/gu, " ").replace(/\s+/g, " ").trim()].slice(0, max).join("");
26
+ return t === "" ? undefined : t;
27
+ };
28
+ const degrees = (v, d, lo, hi) => (typeof v === "number" && Number.isFinite(v) ? Math.max(lo, Math.min(hi, v)) : d);
29
+ /** An SVG from a data URI, if the URI is one of an SVG: base64 or percent-encoded. */
30
+ function svgOfUri(uri) {
31
+ const m = /^data:image\/svg\+xml(?:;charset=[\w-]+)?(;base64)?,([\s\S]*)$/i.exec(uri.trim());
32
+ if (m === null || m[2].length > MAX_SVG * 1.4)
33
+ return undefined;
34
+ try {
35
+ const decode = globalThis.atob;
36
+ return m[1] === undefined ? decodeURIComponent(m[2]) : decode === undefined ? undefined : decode(m[2]);
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ }
42
+ /** A raster data URI that is safe to write into an attribute: PNG, JPEG, GIF or WebP, base64 only, of a size that fits in a document. */
43
+ const RASTER = /^data:image\/(?:png|jpe?g|gif|webp);base64,[A-Za-z0-9+/]+={0,2}$/;
44
+ const MAX_RASTER = 400000;
45
+ /** A logo or mark from a string and a URI, made safe; `undefined` when nothing drawable is left. A picture is taken only when `pictures` is true. */
46
+ function artOf(svg, href, prefix, pictures) {
47
+ let source = typeof svg === "string" ? svg : undefined;
48
+ let uri = typeof href === "string" ? href.trim() : undefined;
49
+ if (source !== undefined && /^\s*data:/i.test(source)) {
50
+ uri = source.trim();
51
+ source = undefined;
52
+ }
53
+ if (source === undefined && uri !== undefined) {
54
+ source = svgOfUri(uri);
55
+ if (source === undefined && pictures && uri.length <= MAX_RASTER && RASTER.test(uri))
56
+ return { kind: "picture", href: uri };
57
+ }
58
+ const img = safeImage(source, prefix);
59
+ return img === undefined ? undefined : { kind: "vector", img };
60
+ }
61
+ /** The frame a plane is laid on the ball in: at `spot` degrees from the label's middle, turned `turn` degrees. The map is equal-distance, so a length from the middle is the same on the ball in radians. */
62
+ function planeOf(home, spot, turn = 0) {
63
+ const lon = (degrees(spot?.lon, 0, -360, 360) * Math.PI) / 180, lat = (degrees(spot?.lat, 0, -90, 90) * Math.PI) / 180;
64
+ const c1 = add(mul(home.c, Math.cos(lon)), mul(home.ex, Math.sin(lon)));
65
+ const ex1 = add(mul(home.ex, Math.cos(lon)), mul(home.c, -Math.sin(lon)));
66
+ const c = add(mul(c1, Math.cos(lat)), mul(home.up, Math.sin(lat)));
67
+ const up = add(mul(home.up, Math.cos(lat)), mul(c1, -Math.sin(lat)));
68
+ const t = (turn * Math.PI) / 180, ct = Math.cos(t), st = Math.sin(t);
69
+ return (x, y) => {
70
+ const px = x * ct - y * st, py = x * st + y * ct;
71
+ const r = Math.min(Math.hypot(px, py), Math.PI);
72
+ if (r < 1e-9)
73
+ return c;
74
+ return norm(add(mul(c, Math.cos(r)), mul(add(mul(ex1, px), mul(up, -py)), Math.sin(r) / Math.hypot(px, py))));
75
+ };
76
+ }
77
+ const ringOf = (cx, cy, r, n = 28) => Array.from({ length: n }, (_, i) => [cx + r * Math.cos((i * 2 * Math.PI) / n), cy + r * Math.sin((i * 2 * Math.PI) / n)]);
78
+ const starOf = (cx, cy, r) => Array.from({ length: 10 }, (_, i) => [cx + (i % 2 === 0 ? r : r * 0.42) * Math.cos(-Math.PI / 2 + (i * Math.PI) / 5), cy + (i % 2 === 0 ? r : r * 0.42) * Math.sin(-Math.PI / 2 + (i * Math.PI) / 5)]);
79
+ /** The print asked for, made safe. `word` is the ball's own word, the default of the script and of the stamp's second line. */
80
+ function partsOf(p, word) {
81
+ const out = {};
82
+ const d = typeof p.decal === "string" ? { svg: p.decal } : typeof p.decal === "object" && p.decal !== null ? p.decal : undefined;
83
+ if (d !== undefined) {
84
+ const art = artOf(d.svg, d.href, "d", true);
85
+ if (art !== undefined)
86
+ out.decal = { art, size: (degrees(d.size, 0, 5, 170) * Math.PI) / 180, turn: degrees(d.turn, 0, -360, 360) };
87
+ }
88
+ const sc = p.script === true ? {} : typeof p.script === "string" ? { text: p.script } : typeof p.script === "object" && p.script !== null ? p.script : undefined;
89
+ if (sc !== undefined) {
90
+ const t = clean(sc.text, 16) ?? `${word[0]?.toUpperCase()}${word.slice(1)}`;
91
+ if (scriptWidth(t) > 0)
92
+ out.script = { text: t, colour: typeof sc.colour === "string" ? sc.colour : undefined };
93
+ }
94
+ const mk = p.mark === true ? {} : typeof p.mark === "string" ? (/^\s*(<|data:)/i.test(p.mark) ? { svg: p.mark } : { letter: p.mark }) : typeof p.mark === "object" && p.mark !== null ? p.mark : undefined;
95
+ // The script brings its circled letter with it, unless the mark is switched off.
96
+ const mark = mk ?? (out.script !== undefined && p.mark !== false ? {} : undefined);
97
+ if (mark !== undefined) {
98
+ const art = artOf(mark.svg, mark.href, "m", false);
99
+ const letter = [...(clean(mark.letter, 1) ?? out.script?.text.trim()[0] ?? word[0] ?? "B")][0];
100
+ out.mark = { letter: letter.toUpperCase(), colour: typeof mark.colour === "string" ? mark.colour : undefined, art };
101
+ }
102
+ const st = p.stamp === true ? {} : typeof p.stamp === "string" ? { lines: [p.stamp] } : Array.isArray(p.stamp) ? { lines: p.stamp } : typeof p.stamp === "object" && p.stamp !== null ? p.stamp : undefined;
103
+ if (st !== undefined) {
104
+ const given = Array.isArray(st.lines) ? st.lines.slice(0, 3).flatMap((l) => clean(l, 24) ?? []) : [];
105
+ const lines = given.length > 0 ? given : ["UNOFFICIAL", `DIGITAL ${word.toUpperCase()}`];
106
+ const sg = st.sign;
107
+ const sign = sg === true ? { seed: "dougu" } : typeof sg === "string" || typeof sg === "number" ? { seed: sg } : typeof sg === "object" && sg !== null ? { seed: sg.seed ?? "dougu", caption: clean(sg.caption, 24) } : undefined;
108
+ out.stamp = { lines, stars: st.stars !== false, colour: typeof st.colour === "string" ? st.colour : undefined, sign };
109
+ }
110
+ return out;
111
+ }
112
+ /** The ink of one part: its own colour if it is one, else the label's. */
113
+ const pen = (own, label) => {
114
+ const c = own === undefined ? undefined : readColour(own);
115
+ return c === undefined ? label : hexOf(c);
116
+ };
117
+ /** Points of a flat curve onto the ball, each given as `[x, y]` in the plane's units. */
118
+ const lay = (map, pts) => pts.map((p) => map(p[0], p[1]));
119
+ /** The size of the label's full width, in radians, for a ball that reaches `reach` from the middle of its label area. */
120
+ const labelWidth = (reach) => Math.min(1.7, reach * 1.4);
121
+ /** The id of a drawing's definitions: from its own text, so that two logos in a page do not share. */
122
+ const idOf = (text) => `p${hashOf(text)}`;
123
+ /**
124
+ * The print of a ball as layers for its surface: the logo, then the label (the script, the circled letter, the stamp), each at its own place. `grounds` are the colours the print lies on, for the ink (the label's far side is the same colour as the front on every ball that has one). A ball
125
+ * in the line style has none. Returns an empty string when nothing was asked for.
126
+ */
127
+ export function printLayers(s, p, word, grounds) {
128
+ const home = s.home;
129
+ if (p === undefined || home === undefined || s.line)
130
+ return "";
131
+ const parts = partsOf(p, word);
132
+ let out = "";
133
+ const labelled = parts.script !== undefined || parts.mark !== undefined || parts.stamp !== undefined;
134
+ if (parts.decal !== undefined) {
135
+ const d = parts.decal;
136
+ const ink = inkFor(grounds);
137
+ const map = planeOf(home, p.spots?.decal, d.turn);
138
+ const size = d.size > 0 ? d.size : Math.min(1.3, home.reach * 1.25);
139
+ if (d.art.kind === "picture")
140
+ out += drawPicture(s, d.art.href, map, size, idOf(d.art.href.slice(0, 64) + d.art.href.length));
141
+ else
142
+ out += drawShapes(s, shapesOf(d.art.img, ink), d.art.img.box, map, size);
143
+ }
144
+ if (labelled) {
145
+ const spot = p.spots?.label ?? (parts.decal !== undefined ? { lon: 180 } : undefined);
146
+ out += labelLayers(s, parts, planeOf(home, spot), labelWidth(home.reach), inkFor(grounds, parts.stamp?.colour));
147
+ }
148
+ return out;
149
+ }
150
+ /** The label: its items from the top down, centred on the plane's origin. */
151
+ function labelLayers(s, parts, map, W, ink) {
152
+ const items = [];
153
+ const gap = W * 0.085;
154
+ const digits = 2;
155
+ const stroke = (curves, colour, width, extra) => s.strokes(curves.map((c) => lay(map, c)), colour, width * s.radius, { digits, ...extra });
156
+ if (parts.script !== undefined) {
157
+ const sc = parts.script;
158
+ const runs = scriptRuns(sc.text);
159
+ const wide = scriptWidth(sc.text);
160
+ const k = Math.min((W * 0.82) / wide, W * 0.03);
161
+ const high = 22 * k;
162
+ const colour = pen(sc.colour, ink);
163
+ items.push({
164
+ h: high,
165
+ draw: (cy) => {
166
+ const ox = (-wide * k) / 2 + wide * k * 0.02, oy = cy + high / 2 - k * 0.5;
167
+ const tiers = [[], [], []];
168
+ for (const run of runs) {
169
+ let cur = [];
170
+ let tier = -1;
171
+ run.pts.forEach((pt, i) => {
172
+ const q = [ox + pt[0] * k, oy - pt[1] * k];
173
+ if (i === 0) {
174
+ cur = [q];
175
+ return;
176
+ }
177
+ const t = run.tier[i - 1];
178
+ if (t !== tier && cur.length > 1) {
179
+ tiers[tier]?.push(cur);
180
+ cur = [cur[cur.length - 1]];
181
+ }
182
+ tier = t;
183
+ cur.push(q);
184
+ });
185
+ if (cur.length > 1)
186
+ tiers[tier]?.push(cur);
187
+ }
188
+ const w = [0.34, 0.7, 1.2];
189
+ return stroke(tiers[0], colour, w[0] * k) + stroke(tiers[1], colour, w[1] * k) + stroke(tiers[2], colour, w[2] * k);
190
+ },
191
+ });
192
+ }
193
+ if (parts.mark !== undefined) {
194
+ const mk = parts.mark;
195
+ const r = W * 0.062;
196
+ const colour = pen(mk.colour, ink);
197
+ items.push({
198
+ h: r * 2,
199
+ draw: (cy) => {
200
+ if (mk.art !== undefined && mk.art.kind === "vector")
201
+ return drawShapes(s, shapesOf(mk.art.img, colour), mk.art.img.box, (x, y) => map(x, y + cy), r * 2.6);
202
+ const L = { size: r * 1.15, weight: 0.2, wide: 0.9 };
203
+ return stroke([ringOf(0, cy, r)], colour, r * 0.2, { closed: true }) + stroke(lineStrokes(mk.letter, L, 0, cy, 5), colour, L.size * L.weight);
204
+ },
205
+ });
206
+ }
207
+ if (parts.stamp !== undefined) {
208
+ const st = parts.stamp;
209
+ const colour = pen(st.colour, ink);
210
+ st.lines.forEach((text, i) => {
211
+ const want = i === 0 ? { weight: 0.22, wide: 0.78 } : { weight: 0.22, wide: 0.78 };
212
+ const room = W * (i === 0 ? 0.62 : 0.98);
213
+ const base = i === 0 ? W * 0.075 : W * 0.105;
214
+ const w1 = textWidth(text, { ...want, size: 1 });
215
+ const size = Math.min(base, room / w1);
216
+ const L = { ...want, size };
217
+ items.push({
218
+ h: size * 1.1,
219
+ draw: (cy) => {
220
+ let o = stroke(lineStrokes(text, L, 0, cy, 5), colour, size * L.weight);
221
+ if (i === 0 && st.stars) {
222
+ const half = textWidth(text, L) / 2;
223
+ const r = size * 0.62;
224
+ for (const sx of [-1, 1])
225
+ o += s.panel(lay(map, starOf(sx * (half + r * 1.5), cy, r)), colour, { n: 1, ink: true });
226
+ }
227
+ return o;
228
+ },
229
+ });
230
+ });
231
+ if (st.sign !== undefined) {
232
+ const sg = st.sign;
233
+ const seed = sg.seed;
234
+ const width = W * 0.52;
235
+ items.push({
236
+ h: width * 0.34,
237
+ draw: (cy) => {
238
+ const subs = flattenPath(signaturePath(seed), 8);
239
+ return stroke(subs.map((q) => q.pts.map((pt) => [pt[0] * width, pt[1] * width + cy])), colour, width * 0.014);
240
+ },
241
+ });
242
+ if (sg.caption !== undefined) {
243
+ const size = W * 0.036;
244
+ const L = { size, weight: 0.16, wide: 0.8, track: 0.12 };
245
+ items.push({ h: size * 1.4, draw: (cy) => stroke(lineStrokes(sg.caption, L, 0, cy, 4), colour, size * L.weight) });
246
+ }
247
+ }
248
+ }
249
+ const total = items.reduce((a, it) => a + it.h, 0) + gap * Math.max(0, items.length - 1);
250
+ let y = -total / 2;
251
+ let out = "";
252
+ for (const it of items) {
253
+ out += it.draw(y + it.h / 2);
254
+ y += it.h + gap;
255
+ }
256
+ return out;
257
+ }
258
+ /** What `printLayers` was given, for the id of a drawing (so two prints of one ball have their own definitions). */
259
+ export const printKey = (p) => (p === undefined ? "" : escape(hashOf(JSON.stringify(p))));
260
+ /**
261
+ * A ball that can be printed. The balls themselves carry no printing code (a ball bundled alone stays small): `renderEmblem` and the registry use this, and so can you for one ball taken from its own module:
262
+ * `const ball = printed(BALL_BASEBALL); ball.svg({ stamp: true })`. The same ball with the printer in its options; options that ask for no print draw exactly what the plain ball draws.
263
+ */
264
+ export const printed = (piece) => ({
265
+ ...piece,
266
+ svg: (o) => piece.svg(hasPrint(o) ? { ...o, printer: printLayers } : o),
267
+ frame: (o) => piece.frame(hasPrint(o) ? { ...o, printer: printLayers } : o),
268
+ });
@@ -0,0 +1,6 @@
1
+ export declare const BALL_BASEBALL: import("./ball.core.ts").Piece<import("./ball.engine.ts").BallSpec>;
2
+ export declare const BALL_SOFTBALL: import("./ball.core.ts").Piece<import("./ball.engine.ts").BallSpec>;
3
+ export declare const BALL_SOCCER: import("./ball.core.ts").Piece<import("./ball.engine.ts").BallSpec>;
4
+ export declare const BALL_SOCCER_COLOUR: import("./ball.core.ts").Piece<import("./ball.engine.ts").BallSpec>;
5
+ export declare const BALL_SOCCER_ORANGE: import("./ball.core.ts").Piece<import("./ball.engine.ts").BallSpec>;
6
+ export declare const BALL_SOCCER_YELLOW: import("./ball.core.ts").Piece<import("./ball.engine.ts").BallSpec>;
@@ -0,0 +1,13 @@
1
+ // The balls that take a logo, lettering and a mark, with the printer in hand: the same balls as `ball.baseball.ts`, `ball.softball.ts` and `ball.soccer.ts` and the classic soccer balls of `ball.soccer.panels.ts`, made
2
+ // with `printed()`. The balls' own modules carry no printing code (so one bundled alone stays small); the registry and the main entry take these.
3
+ import { BALL_BASEBALL as PLAIN_BASEBALL } from "./ball.baseball.js";
4
+ import { printed } from "./ball.print.js";
5
+ import { BALL_SOCCER_ORANGE as PLAIN_SOCCER_ORANGE, BALL_SOCCER_YELLOW as PLAIN_SOCCER_YELLOW } from "./ball.soccer.panels.js";
6
+ import { BALL_SOCCER as PLAIN_SOCCER, BALL_SOCCER_COLOUR as PLAIN_SOCCER_COLOUR } from "./ball.soccer.js";
7
+ import { BALL_SOFTBALL as PLAIN_SOFTBALL } from "./ball.softball.js";
8
+ export const BALL_BASEBALL = /* @__PURE__ */ printed(PLAIN_BASEBALL);
9
+ export const BALL_SOFTBALL = /* @__PURE__ */ printed(PLAIN_SOFTBALL);
10
+ export const BALL_SOCCER = /* @__PURE__ */ printed(PLAIN_SOCCER);
11
+ export const BALL_SOCCER_COLOUR = /* @__PURE__ */ printed(PLAIN_SOCCER_COLOUR);
12
+ export const BALL_SOCCER_ORANGE = /* @__PURE__ */ printed(PLAIN_SOCCER_ORANGE);
13
+ export const BALL_SOCCER_YELLOW = /* @__PURE__ */ printed(PLAIN_SOCCER_YELLOW);
@@ -0,0 +1,12 @@
1
+ import { type Geometry, type Surface } from "./ball.engine.ts";
2
+ import { type V3 } from "./ball.math.ts";
3
+ /**
4
+ * The visible part of the surface on the left of a closed curve on a sphere, as the path of its outline: the curve's visible runs, joined along the limb. Walking a run with the region on your left, the limb is
5
+ * followed counterclockwise (as the eye sees it) from where the run leaves the disc to the next place a run comes in, so the outline closes on the correct side even for a region that is not convex.
6
+ */
7
+ export declare function regionPath(g: Geometry, ring: readonly V3[]): string;
8
+ /**
9
+ * The part of the surface on the left of a closed curve (looking at the sphere from outside, walking along the curve in the order given), filled with a colour: what you see of it, bounded by the curve and the
10
+ * limb. For spheres only. In the line style nothing is drawn.
11
+ */
12
+ export declare function region(s: Surface, ring: readonly V3[], fill: string): string;
@@ -0,0 +1,94 @@
1
+ // A region of a sphere on the left of a closed curve, drawn as the outline it has on the screen. Kept out of the engine so that a ball with no two-tone colouring does not carry it: the seamed balls import it.
2
+ import { num } from "./ball.core.js";
3
+ import { EPS, pathOf, project, visOf, visibleRuns } from "./ball.engine.js";
4
+ import { cross, dot, norm, sub } from "./ball.math.js";
5
+ /** The signed area of a polygon of screen points, y up (positive when it runs counterclockwise as you see it). */
6
+ function areaOf(pts) {
7
+ let a = 0;
8
+ for (let i = 0; i < pts.length; i++) {
9
+ const p = pts[i], q = pts[(i + 1) % pts.length];
10
+ a += p[0] * -q[1] - q[0] * -p[1];
11
+ }
12
+ return a / 2;
13
+ }
14
+ /** Whether a point of the sphere is on the left of a closed curve, by the nearest sample's side. */
15
+ function leftOf(ring, p) {
16
+ let best = Infinity, left = false;
17
+ const n = ring.length;
18
+ for (let i = 0; i < n; i++) {
19
+ const q = ring[i];
20
+ const d = 1 - dot(p, q);
21
+ if (d < best) {
22
+ best = d;
23
+ left = dot(p, cross(q, norm(sub(ring[(i + 1) % n], ring[(i + n - 1) % n])))) > 0;
24
+ }
25
+ }
26
+ return left;
27
+ }
28
+ /**
29
+ * The visible part of the surface on the left of a closed curve on a sphere, as the path of its outline: the curve's visible runs, joined along the limb. Walking a run with the region on your left, the limb is
30
+ * followed counterclockwise (as the eye sees it) from where the run leaves the disc to the next place a run comes in, so the outline closes on the correct side even for a region that is not convex.
31
+ */
32
+ export function regionPath(g, ring) {
33
+ const R = g.k;
34
+ const vis = ring.map((p) => visOf(g, p));
35
+ const disc = () => `M${num(g.cx - R, 2)} ${num(g.cy, 2)}A${num(R, 2)} ${num(R, 2)} 0 1 0 ${num(g.cx + R, 2)} ${num(g.cy, 2)}A${num(R, 2)} ${num(R, 2)} 0 1 0 ${num(g.cx - R, 2)} ${num(g.cy, 2)}Z`;
36
+ if (!vis.some((v) => v > EPS))
37
+ return leftOf(ring, norm(g.w)) ? disc() : "";
38
+ if (vis.every((v) => v > EPS)) {
39
+ const pts = ring.map((p) => {
40
+ const [x, y] = project(g, p);
41
+ return [x, y, 1];
42
+ });
43
+ const poly = `${pathOf(pts, 2)}Z`;
44
+ return areaOf(pts) > 0 ? poly : disc() + poly;
45
+ }
46
+ const runs = visibleRuns(g, ring, true).filter((r) => r.pts.length > 1);
47
+ if (runs.length === 0)
48
+ return "";
49
+ const angle = (p) => Math.atan2(-(p[1] - g.cy), p[0] - g.cx);
50
+ const first = (r) => r.pts[0];
51
+ const last = (r) => r.pts[r.pts.length - 1];
52
+ const next = runs.map((r) => {
53
+ const out = angle(last(r));
54
+ let best = 0, gap = Infinity;
55
+ runs.forEach((q, j) => {
56
+ let d = angle(first(q)) - out;
57
+ d -= Math.floor(d / (2 * Math.PI)) * 2 * Math.PI;
58
+ if (d < gap) {
59
+ gap = d;
60
+ best = j;
61
+ }
62
+ });
63
+ return { to: best, gap };
64
+ });
65
+ const seen = new Set();
66
+ let d = "";
67
+ runs.forEach((_, start) => {
68
+ if (seen.has(start))
69
+ return;
70
+ let at = start;
71
+ d += `M${num(first(runs[at])[0], 2)} ${num(first(runs[at])[1], 2)}`;
72
+ while (!seen.has(at)) {
73
+ seen.add(at);
74
+ const r = runs[at];
75
+ d += pathOf(r.pts.slice(1), 2).replace(/^M/, "L");
76
+ const n = next[at];
77
+ const e = first(runs[n.to]);
78
+ d += n.gap < 1e-4 ? `L${num(e[0], 2)} ${num(e[1], 2)}` : `A${num(R, 2)} ${num(R, 2)} 0 ${n.gap > Math.PI ? 1 : 0} 0 ${num(e[0], 2)} ${num(e[1], 2)}`;
79
+ at = n.to;
80
+ }
81
+ d += "Z";
82
+ });
83
+ return d;
84
+ }
85
+ /**
86
+ * The part of the surface on the left of a closed curve (looking at the sphere from outside, walking along the curve in the order given), filled with a colour: what you see of it, bounded by the curve and the
87
+ * limb. For spheres only. In the line style nothing is drawn.
88
+ */
89
+ export function region(s, ring, fill) {
90
+ if (s.line)
91
+ return "";
92
+ const d = regionPath(s.view, ring);
93
+ return d === "" ? "" : `<path d="${d}" fill="${fill}" fill-rule="evenodd"/>`;
94
+ }
@@ -0,0 +1,15 @@
1
+ type Pt = readonly [number, number];
2
+ /** One piece of a word's centre line: its points in the grid, and `tier` 0 (hairline), 1 (medium) or 2 (down stroke), for each segment. */
3
+ export interface ScriptRun {
4
+ pts: Pt[];
5
+ /** One entry per segment (`pts.length - 1`). */
6
+ tier: number[];
7
+ }
8
+ /** The width of a word in the grid. */
9
+ export declare function scriptWidth(text: string): number;
10
+ /**
11
+ * A word as pen runs in the grid (y up, the baseline at 0, the left end of the first letter at 0), leaning right, each run with the thickness tier of its segments. Characters with no letter are left out;
12
+ * a space is a gap.
13
+ */
14
+ export declare function scriptRuns(text: string): ScriptRun[];
15
+ export {};
@@ -0,0 +1,107 @@
1
+ // An original cursive script, drawn for this package: a pen's centre line through hand-placed waypoints, joined letter to letter at the baseline, with the pen's thick down strokes and hairline up
2
+ // strokes made by the caller from each segment's direction (`tier`). No font file, no licensed outline and no maker's lettering: every waypoint below was placed by hand, in a grid where the x-height is 10,
3
+ // the ascender 20 and the descender -10, with y up. A glyph enters at (0, 0) and leaves at (adv, 0), so the next one starts where it stops. Imports nothing.
4
+ const LOWER = {
5
+ a: { adv: 8.4, d: "0,0 1.6,2.6 3.6,6.2 5.2,8.6 6.4,9.6 4.6,10.4 2.4,9.2 1.2,6 1.5,2.4 3.4,0.5 5.2,1.4 6.3,4.4 6.7,9.4 6.7,5 6.8,1.4 7.6,0.2 8.4,0" },
6
+ b: { adv: 8.6, d: "0,0 1.6,3.5 3.4,9 4.6,15.5 4.6,19.5 3.8,20.8 2.8,19.4 2.9,15 3.6,9 4,3.5 4.2,0.8 4.8,3.2 6.5,6.6 7.8,5.6 7.6,2.2 5.8,0.4 3.8,1.4 3.3,3.4 4.3,0.3 7.2,0 8.6,0" },
7
+ c: { adv: 7.4, d: "0,0 1.8,2.8 3.8,7 5.8,9.4 4.8,10.2 2.6,9 1.2,5.8 1.6,2.2 3.4,0.3 5.4,0.8 6.6,1.8 7.4,0" },
8
+ d: { adv: 9, d: "0,0 1.6,2.6 3.6,6.2 5.2,8.6 6.4,9.6 4.6,10.4 2.4,9.2 1.2,6 1.5,2.4 3.4,0.5 5.2,1.4 6.4,4.4 7.2,11 8,17.5 8,20.2 7.1,20.8 6.5,19.3 6.7,13 7,6 7.1,1.4 8,0.2 9,0" },
9
+ e: { adv: 7.6, d: "0,0 0.9,2.4 1.7,5 3.2,5.8 4.9,5.7 5.4,8.2 4.2,10.1 2.6,9.8 1.2,7.6 1.1,3.8 2.1,1.2 3.8,0.2 5.6,0.9 7,0.2 7.6,0" },
10
+ f: { adv: 6.4, d: "0,0 1.4,3.5 3.2,9.5 4.8,16.5 5.4,19.6 4.4,20.8 3.2,19 3.5,14 3.6,6 3.4,-2.4 2.2,-8.4 1,-9.6 0,-8 1,-5.2 3.6,-3 5.4,0.4 6.4,0 ; 0.6,10.2 4.4,10.8 7.2,10.2" },
11
+ g: { adv: 8.6, d: "0,0 1.6,2.6 3.6,6.2 5.2,8.6 6.4,9.6 4.6,10.4 2.4,9.2 1.2,6 1.5,2.4 3.4,0.5 5.2,1.4 6.4,4.4 6.8,9.6 6.4,3 6.2,-4.6 4.8,-9 2.4,-9.8 1,-8.2 2,-6.8 5,-6 7,-3.6 8,-0.5 8.6,0" },
12
+ h: { adv: 8.6, d: "0,0 1.6,3.5 3.4,9 4.6,15.5 4.6,19.5 3.8,20.8 2.8,19.4 2.9,15 3.6,9 4,3.5 4.1,0.4 4.6,3.6 6,7.8 7.2,8 7.5,5 7.1,1.4 7.6,0.2 8.6,0" },
13
+ i: { adv: 4.8, d: "0,0 1.4,3.4 2.4,8.2 2.8,9.6 2.8,4.4 2.6,1 3.2,0.2 4.8,0 ; 3.2,12.8 3.2,13.4" },
14
+ j: { adv: 4.8, d: "0,0 1.4,3.4 2.4,8.2 3,9.6 3.2,3 3,-4.6 1.6,-9 0.2,-9.8 -0.8,-8.4 0.6,-6.8 2.6,-4 3.8,-0.5 4.8,0 ; 3.6,12.8 3.6,13.4" },
15
+ k: { adv: 8.2, d: "0,0 1.6,3.5 3.4,9 4.6,15.5 4.6,19.5 3.8,20.8 2.8,19.4 2.9,15 3.6,9 4,3.5 4.1,0.4 4.4,3.2 5,6.2 6.6,8 7,6.4 5.8,4.2 4.4,3.6 5.6,2.6 6.8,0.4 8.2,0" },
16
+ l: { adv: 4.8, d: "0,0 1.6,3.5 3.2,9.5 4.2,15.5 4.4,19.4 3.6,20.8 2.6,19.4 2.7,15 3.2,9 3.5,3.5 3.6,0.6 4.1,0.2 4.8,0" },
17
+ m: { adv: 12.8, d: "0,0 1.4,3.4 2.4,8.2 2.8,9.6 2.9,5.5 2.8,1 3.2,3.6 4.4,8 5.6,8.4 6.1,5.2 6,1 6.4,3.6 7.6,8 8.8,8.4 9.4,5.2 9.2,1.2 9.9,0.2 10.8,0.6 12.8,0" },
18
+ n: { adv: 8.4, d: "0,0 1.4,3.4 2.4,8.2 2.8,9.6 2.9,5.5 2.8,1 3.2,3.6 4.4,8 5.6,8.4 6.2,5.2 6,1.2 6.8,0.2 8.4,0" },
19
+ o: { adv: 7.6, d: "0,0 1.8,2.8 3.4,6.4 4.4,8.8 3.4,10.2 1.8,8.4 1.2,5.2 1.9,1.8 3.8,0.3 5.6,1.8 5.7,5.4 4.8,8.6 5.2,10 6.4,9.4 7.6,8.5" },
20
+ p: { adv: 8.6, d: "0,0 1.4,3.4 2.4,8.2 2.8,9.6 2.9,2 2.4,-4.6 1.8,-9.6 ; 2.8,5 3.4,8.6 5.2,10.2 7.2,9 7.6,5.4 6.4,2 4.2,0.4 2.8,1.4 2.5,3.4 4.4,0.4 6.8,0 8.6,0" },
21
+ q: { adv: 8.4, d: "0,0 1.6,2.6 3.6,6.2 5.2,8.6 6.4,9.6 4.6,10.4 2.4,9.2 1.2,6 1.5,2.4 3.4,0.5 5.2,1.4 6.4,4.4 6.8,9.6 6.6,2 6.2,-5 6.4,-9.4 7.2,-9.8 8.4,-8.4" },
22
+ r: { adv: 7, d: "0,0 1.4,3.4 2.4,8.2 2.8,9.6 2.9,5.5 2.8,1.6 3.4,4.6 4.6,8.4 5.8,9.6 6.4,8.4 5.4,7.6 6.2,8.4 7,8" },
23
+ s: { adv: 6.6, d: "0,0 1.2,2.4 2.8,6 4,8.6 4.6,9.8 3.6,9.8 3.5,7.6 4.8,5.8 5,3.6 4.2,1.4 2.8,0.4 1.5,0.8 1.6,1.8 3.2,1.4 5,0.5 6.6,0" },
24
+ t: { adv: 6.4, d: "0,0 1.4,3.4 2.6,8.5 3.2,13 3.4,16.8 3,16.4 3,10 2.8,3.2 3.4,0.6 4.6,0.4 6.4,0 ; 0.6,9.4 3,9.8 5.8,9.2" },
25
+ u: { adv: 8.4, d: "0,0 1.4,3.4 2.4,8.2 2.8,9.6 2.7,5.5 2.6,2.2 3.4,0.4 5,0.6 6.4,3.6 7,9.6 6.8,4.6 6.8,1.2 7.6,0.2 8.4,0" },
26
+ v: { adv: 7.6, d: "0,0 1.4,3.4 2.4,8.2 2.8,9.6 2.7,5.5 3,2.2 4,0.4 5.2,1.6 6.4,6 7,9 6.4,10 7.6,8.4" },
27
+ w: { adv: 10.4, d: "0,0 1.4,3.4 2.4,8.2 2.8,9.6 2.7,5.5 3,2.2 4,0.4 5,2.4 5.4,6 5.5,2.2 6.6,0.4 7.8,2.4 8.6,6 9.2,9 8.8,10 10.4,8.4" },
28
+ x: { adv: 7.4, d: "0,0 1.6,3 3.4,7 5,9.6 4.2,10.4 3,9 3.6,5.6 4.4,2.4 4,0.4 2.6,0.2 2,1.4 3.4,2.2 5.2,4.6 6.4,8.6 6.8,10 7.4,9" },
29
+ y: { adv: 8.2, d: "0,0 1.4,3.4 2.4,8.2 2.8,9.6 2.7,5.5 3,2.2 4,0.4 5.4,2.4 6.4,6 6.8,9.6 6.4,2.6 5.6,-4.6 4,-9 2,-9.8 0.8,-8.4 2,-7 4.6,-5.6 7,-3.4 8.2,0" },
30
+ z: { adv: 7, d: "0,0 1.4,2.6 2.6,6.6 3.4,9 4.4,10 5.8,9.4 3.8,6.4 2.2,3.4 2,0.4 3,-1.6 4.8,-3.6 4.2,-6 2.8,-6.6 2,-5.4 3.4,-4 5.6,-2.2 6.4,0 7,0" },
31
+ };
32
+ /** Capitals drawn for their own: the rest of the capitals are the small letter set tall (see `scriptStrokes`). */
33
+ const UPPER = {
34
+ B: { adv: 14, d: "0,0 1.6,6 3.2,12 5,18 6.2,22.4 7,20 6.2,12 5.4,5 4.6,0.4 6,6.6 9.6,12 13,13.6 13.6,10.6 11,7.6 7,6.4 6,6.2 9,5.6 12.2,3.4 12.8,0.6 11,-1.4 7.8,-1.6 4,0.2 1.4,1 0,0" },
35
+ S: { adv: 11, d: "0,0 2.4,5 5,10 7.6,14.4 9.4,17 8.2,18.8 6,17.2 6.2,14 8.4,11 9.6,7.2 8.4,3 5.8,0.8 3,0.4 1.8,2 3.4,3 6.6,1.6 9,0.4 11,0" },
36
+ };
37
+ const SLANT = 0.3;
38
+ /** A smooth curve through waypoints (a Catmull-Rom spline), as points about 0.8 apart. */
39
+ function through(wp) {
40
+ const out = [];
41
+ for (let i = 0; i + 1 < wp.length; i++) {
42
+ const p0 = wp[Math.max(0, i - 1)], p1 = wp[i], p2 = wp[i + 1], p3 = wp[Math.min(wp.length - 1, i + 2)];
43
+ const n = Math.max(2, Math.ceil(Math.hypot(p2[0] - p1[0], p2[1] - p1[1]) / 0.8));
44
+ for (let j = 0; j < n; j++) {
45
+ const t = j / n, t2 = t * t, t3 = t2 * t;
46
+ out.push([
47
+ 0.5 * (2 * p1[0] + (-p0[0] + p2[0]) * t + (2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 + (-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3),
48
+ 0.5 * (2 * p1[1] + (-p0[1] + p2[1]) * t + (2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 + (-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3),
49
+ ]);
50
+ }
51
+ }
52
+ out.push(wp[wp.length - 1]);
53
+ return out;
54
+ }
55
+ /** The letter for a character: a drawn capital, a small letter (a capital with no drawing of its own is that letter set 1.7 times as tall), or `undefined`. */
56
+ function letterOf(ch) {
57
+ if (Object.hasOwn(UPPER, ch))
58
+ return { letter: UPPER[ch], k: 1 };
59
+ const low = ch.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
60
+ if (low.length !== 1 || !Object.hasOwn(LOWER, low))
61
+ return undefined;
62
+ return { letter: LOWER[low], k: ch === ch.toUpperCase() && ch !== ch.toLowerCase() ? 1.7 : 1 };
63
+ }
64
+ /** The width of a word in the grid. */
65
+ export function scriptWidth(text) {
66
+ let x = 0;
67
+ for (const ch of [...text]) {
68
+ if (ch === " ")
69
+ x += 5;
70
+ else {
71
+ const l = letterOf(ch);
72
+ if (l !== undefined)
73
+ x += l.letter.adv * l.k;
74
+ }
75
+ }
76
+ return x;
77
+ }
78
+ /**
79
+ * A word as pen runs in the grid (y up, the baseline at 0, the left end of the first letter at 0), leaning right, each run with the thickness tier of its segments. Characters with no letter are left out;
80
+ * a space is a gap.
81
+ */
82
+ export function scriptRuns(text) {
83
+ const runs = [];
84
+ let x = 0;
85
+ for (const ch of [...text]) {
86
+ if (ch === " ") {
87
+ x += 5;
88
+ continue;
89
+ }
90
+ const l = letterOf(ch);
91
+ if (l === undefined)
92
+ continue;
93
+ for (const stroke of l.letter.d.split(";")) {
94
+ const wp = stroke.trim().split(/\s+/).map((p) => p.split(",").map(Number));
95
+ const pts = through(wp).map(([px, py]) => [x + px * l.k + SLANT * py * l.k, py * l.k]);
96
+ const tier = pts.slice(1).map((p, i) => {
97
+ const a = pts[i];
98
+ const dx = p[0] - a[0], dy = p[1] - a[1];
99
+ const down = Math.max(0, -dy / (Math.hypot(dx, dy) || 1));
100
+ return down > 0.62 ? 2 : down > 0.2 ? 1 : 0;
101
+ });
102
+ runs.push({ pts, tier });
103
+ }
104
+ x += l.letter.adv * l.k;
105
+ }
106
+ return runs;
107
+ }
@@ -4,7 +4,7 @@ import { defineBall } from "./ball.engine.js";
4
4
  import { truncatedIcosahedron } from "./ball.polyhedra.js";
5
5
  import { add, mul, norm } from "./ball.math.js";
6
6
  import { blend, mix } from "./ball.core.js";
7
- import { SOCCER_SIZE } from "./ball.soccer.size.js";
7
+ import { CLASSIC_HOME, SOCCER_SIZE } from "./ball.soccer.size.js";
8
8
  const SEAM = "#6b7180";
9
9
  export const BALL_SOCCER = /* @__PURE__ */ defineBall({
10
10
  id: "ball:soccer",
@@ -16,11 +16,13 @@ export const BALL_SOCCER = /* @__PURE__ */ defineBall({
16
16
  base: "#1e2025",
17
17
  ground: "#f8f8f6",
18
18
  mat: { gloss: 0.8, shade: "#2a3450", grain: { f: 1.1, a: 0.16 } },
19
- draw(s, base) {
19
+ home: CLASSIC_HOME,
20
+ draw(s, base, _accent, _colors, custom) {
20
21
  const t = truncatedIcosahedron();
21
22
  let out = "";
22
23
  for (const p of t.pentagons)
23
24
  out += s.panel(p, base, { ink: true });
25
+ out += custom?.printer?.(s, custom.print, "soccer", [custom.paint?.body ?? "#f8f8f6"]) ?? "";
24
26
  out += s.strokes(t.edges, SEAM, 1.15);
25
27
  out += s.strokes(t.edges, "#fff", 0.45, { deco: true, opacity: 0.5, dx: 0.3, dy: 0.35 });
26
28
  return out;
@@ -37,7 +39,8 @@ export const BALL_SOCCER_COLOUR = /* @__PURE__ */ defineBall({
37
39
  base: "#d4283c",
38
40
  ground: "#f8f8f6",
39
41
  mat: { gloss: 0.8, shade: "#3a2030", grain: { f: 1.1, a: 0.16 } },
40
- draw(s, base) {
42
+ home: CLASSIC_HOME,
43
+ draw(s, base, _accent, _colors, custom) {
41
44
  const t = truncatedIcosahedron();
42
45
  const light = blend(base, "#ffffff", 0.45);
43
46
  let out = "";
@@ -47,6 +50,7 @@ export const BALL_SOCCER_COLOUR = /* @__PURE__ */ defineBall({
47
50
  });
48
51
  for (const p of t.pentagons)
49
52
  out += s.panel(p, base, { ink: true });
53
+ out += custom?.printer?.(s, custom.print, "soccer", [custom.paint?.body ?? "#f8f8f6", light]) ?? "";
50
54
  out += s.strokes(t.edges, mix(base, -0.35), 1.1);
51
55
  out += s.strokes(t.edges, "#fff", 0.45, { deco: true, opacity: 0.45, dx: 0.3, dy: 0.35 });
52
56
  return out;
@@ -7,6 +7,8 @@ export interface Tones {
7
7
  /** A third tone between the light and the mid one, for a third kind of panel. */
8
8
  deep: string;
9
9
  seam: string;
10
+ /** What a layout lays between its panels and its seams (the print of a ball that has one), if it has any. */
11
+ under?: (s: Surface) => string;
10
12
  }
11
13
  export type Layout = (s: Surface, t: Tones) => string;
12
14
  export declare const classic: Layout;
@@ -5,7 +5,7 @@ import { blend, mix } from "./ball.core.js";
5
5
  import { strips18 } from "./ball.family.panelled.js";
6
6
  import { truncatedIcosahedron } from "./ball.polyhedra.js";
7
7
  import { dodecahedron, gores, propellers14, rhombicuboctahedron } from "./ball.tilings.js";
8
- import { SOCCER_SIZE } from "./ball.soccer.size.js";
8
+ import { CLASSIC_HOME, SOCCER_SIZE } from "./ball.soccer.size.js";
9
9
  const INK = "#1e2025";
10
10
  const WHITE = "#f8f8f6";
11
11
  const SEAM = "#6b7180";
@@ -16,6 +16,7 @@ export const classic = (s, t) => {
16
16
  let out = "";
17
17
  for (const p of g.pentagons)
18
18
  out += s.panel(p, t.dark, { ink: true });
19
+ out += t.under?.(s) ?? "";
19
20
  out += s.strokes(g.edges, t.seam, 1.15);
20
21
  return out + lit(s, g.edges);
21
22
  };
@@ -94,7 +95,8 @@ function specOf(construction, way, view) {
94
95
  base: INK,
95
96
  ground: WHITE,
96
97
  mat: construction === 6 ? { ...gloss, gloss: 0.9, grain: { f: 0.7, a: 0.07 } } : gloss,
97
- draw: (s, base, accent) => layout(s, { light: WHITE, dark: base, mid: accent ?? "#dfe2ea", deep: blend(accent ?? "#dfe2ea", base, 0.12), seam: SEAM }),
98
+ home: construction === "classic" ? CLASSIC_HOME : undefined,
99
+ draw: (s, base, accent, _colors, custom) => layout(s, { light: WHITE, dark: base, mid: accent ?? "#dfe2ea", deep: blend(accent ?? "#dfe2ea", base, 0.12), seam: SEAM, under: construction === "classic" ? (u) => custom?.printer?.(u, custom.print, "soccer", [custom.paint?.body ?? WHITE]) ?? "" : undefined }),
98
100
  };
99
101
  }
100
102
  const c = COLOURWAYS[way];
@@ -102,7 +104,8 @@ function specOf(construction, way, view) {
102
104
  view,
103
105
  base: c.base,
104
106
  mat: { ...(construction === 6 ? { ...gloss, gloss: 0.9, grain: { f: 0.7, a: 0.07 } } : gloss), shade: mix(c.base, -0.65) },
105
- draw: (s, base, accent) => layout(s, { light: base, dark: accent ?? c.accent, mid: blend(base, "#ffffff", 0.4), deep: blend(base, "#ffffff", 0.18), seam: mix(base, -0.5) }),
107
+ home: construction === "classic" ? CLASSIC_HOME : undefined,
108
+ draw: (s, base, accent, _colors, custom) => layout(s, { light: base, dark: accent ?? c.accent, mid: blend(base, "#ffffff", 0.4), deep: blend(base, "#ffffff", 0.18), seam: mix(base, -0.5), under: construction === "classic" ? (u) => custom?.printer?.(u, custom.print, "soccer", [base]) ?? "" : undefined }),
106
109
  };
107
110
  }
108
111
  /** The id, and the English and Japanese names, of each construction in each colourway (short, because the name is the accessible name: 26 characters in English and 12 in Japanese at most). */
@@ -1,2 +1,4 @@
1
1
  import type { PieceSize } from "./ball.core.ts";
2
2
  export declare const SOCCER_SIZE: PieceSize;
3
+ /** Where the classic 32-panel ball takes a logo and lettering: the middle of a white hexagon, the way up there, and the view that shows it (the hexagon on the far side is the same, so the label can go there). */
4
+ export declare const CLASSIC_HOME: import("./ball.engine.ts").BallSpec["home"];
@@ -6,3 +6,11 @@ export const SOCCER_SIZE = {
6
6
  rule: "IFAB Laws of the Game, Law 2 The Ball, section 2.1 Qualities and measurements",
7
7
  note: "Diameter worked out from the middle of the circumference range (68 to 70 cm).",
8
8
  };
9
+ /** Where the classic 32-panel ball takes a logo and lettering: the middle of a white hexagon, the way up there, and the view that shows it (the hexagon on the far side is the same, so the label can go there). */
10
+ export const CLASSIC_HOME = {
11
+ c: [-0.577, -0.577, -0.577],
12
+ up: [-0.408, 0.816, -0.408],
13
+ reach: 0.38,
14
+ view: { yaw: 124.5, pitch: -40.8, roll: -6.1 },
15
+ word: "soccer",
16
+ };
@@ -1,5 +1,5 @@
1
1
  // Softball: an optic-yellow ball with red stitches along the seam (88 here, which the rule leaves free).
2
- import { seamedBall } from "./ball.family.seamed.js";
2
+ import { SEAMED_HOME, seamedBall } from "./ball.family.seamed.js";
3
3
  export const BALL_SOFTBALL = /* @__PURE__ */ seamedBall({
4
4
  id: "ball:softball",
5
5
  en: "Softball",
@@ -9,6 +9,7 @@ export const BALL_SOFTBALL = /* @__PURE__ */ seamedBall({
9
9
  b: 0.4,
10
10
  view: { yaw: 18, pitch: 28, roll: -10 },
11
11
  mat: { gloss: 0.35, shade: "#4c5a10", grain: { f: 1.4, a: 0.12 } },
12
+ home: SEAMED_HOME("softball"),
12
13
  groove: "#8a9420",
13
14
  stitch: { colour: "#d0262c", count: 88, half: 0.085, width: 0.95, lean: 0.04 },
14
15
  });