@johnmorrisdotca/dougu-sports 0.16.1 → 0.16.2

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/CHANGELOG.md CHANGED
@@ -6,6 +6,27 @@ All notable changes to this project are written here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.16.2] - 2026-10-21
10
+
11
+ Every piece that flips or turns has a body at every angle: a solid edge, a back of its own, and what hangs from it still there.
12
+
13
+ ### Fixed
14
+
15
+ - **Medals, crests, banners, belts, plaques, pennants, gear and the pictograms are slabs, not sheets** (`flipSvg`, and `defineFlat`, which now turns through it): a solid edge, in the face's own colours darkened, runs behind the squeezed face and stays as wide on the screen as the piece is thick, never under about 2.4 pixels whatever the size, so edge on (a quarter turn, either way) a medal is a band and no longer a hairline. The edge is eight slices of the face made opaque again (a thin part such as a stick or a strap keeps its edge when the squeeze would have thinned it away) and the slab is centred, so it does not slide as it turns; it lies on the other side once the back is toward you.
16
+ - **A medal seen from behind keeps its ribbon** (it lost it), a championship banner its pole and a crest pennant its staff, on the other side, as a real turn mirrors them. The plain parts of a piece with no back of its own (a plaque's board, a belt's strap) are drawn behind it too, so the back is as big as the front.
17
+ - **The hockey puck is a straight cylinder** (`drawStack`): the wall ends in the curve of the lower rim, a crisp edge, and no longer in the soft edge of the bottom cap; its edge is knurled with diamonds all round the wall (it covered only part of it), and the knurl slides round the wall as the puck spins. The curling stone's walls end the same way. Seen from the side the puck is one third as thick as it is wide, at 0, 30, 60 and 90 degrees of tilt.
18
+ - **The golf ball reads as a golf ball at every size and tint**: about 330 dimples (it had 190, faint), each a bowl with a shadow on its upper left wall and a bright edge on the lower right, white by default. **A golf ball and a baseball ignore `tint`** (their colour is fixed by the rules of their sports; a baseball takes `paint`); every other ball takes it. `BallSpec.fixed`, `DottedOptions.fixed` and `SeamedOptions.fixed` say so.
19
+ - **The demo's Auto-turn button** (Stop) is an ordinary button beside Reverse again; a class name it shared with the playground's section had stretched it across the row.
20
+
21
+ ### Changed
22
+
23
+ - **README pictures**: the balls row's centre ball, the one that rolls, is the outdoor pickleball with its see-through holes (it was the rugby ball); the flips row and the pickleball picture are redrawn (`pnpm animations`), still half speed and only the centre one turning.
24
+
25
+ ### Tests
26
+
27
+ - `test/thickness.test.ts`: the edge's width in pixels for every flat piece at 24, 64 and 256 pixels, its sides, the back of every flat piece, the puck's wall; `test/golf.test.ts`; `e2e/silhouette.demo.ts` counts real pixels in Chromium (edge on every flat piece is a band at least two pixels wide, from behind it is as solid as its front, the puck is one third as thick as wide at 0, 30, 60 and 90 degrees); an e2e test of the Auto-turn button.
28
+ - Size limits: a medal, a crest or a plaque bundled alone is held to 5.8 kB gzipped (was 4.6), the cost of the one turn they now share.
29
+
9
30
  ## [0.16.1] - 2026-10-21
10
31
 
11
32
  The turning pictures show one thing moving: in a row, only the centre one turns.
package/README.md CHANGED
@@ -75,7 +75,7 @@ The ball and the gear of every sport, trophies, medals, crests and the icon of e
75
75
  <p align="center">
76
76
  <picture>
77
77
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/dougu-sports/main/docs/images/balls-turn-dark.webp">
78
- <img alt="Seven balls under floodlights: a soccer ball, a basketball, a baseball, a rugby ball, a volleyball, a tennis ball and a golf ball. Six stand still and only the rugby ball in the centre rolls, so that its stripes and panels travel across it." src="https://raw.githubusercontent.com/johnmorrisdotca/dougu-sports/main/docs/images/balls-turn-light.webp" width="760">
78
+ <img alt="Seven balls under floodlights: a soccer ball, a basketball, a baseball, an outdoor pickleball, a volleyball, a tennis ball and a golf ball. Six stand still and only the pickleball in the centre rolls, its holes showing the dark inside and the floodlights through as they line up." src="https://raw.githubusercontent.com/johnmorrisdotca/dougu-sports/main/docs/images/balls-turn-light.webp" width="760">
79
79
  </picture>
80
80
  </p>
81
81
 
@@ -102,7 +102,7 @@ The ball and the gear of every sport, trophies, medals, crests and the icon of e
102
102
  <p align="center">
103
103
  <picture>
104
104
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/dougu-sports/main/docs/images/flips-turn-dark.webp">
105
- <img alt="A crest, a hockey skate, a silver medal, a gold medal, a championship banner, a baseball glove and a football helmet on royal purple: six stand still and only the gold medal in the centre flips like a coin, showing its face, then its edge, then its plain back." src="https://raw.githubusercontent.com/johnmorrisdotca/dougu-sports/main/docs/images/flips-turn-light.webp" width="760">
105
+ <img alt="A crest, a hockey skate, a silver medal, a gold medal, a championship banner, a baseball glove and a football helmet on royal purple: six stand still and only the gold medal in the centre flips like a coin, showing its face, then its solid edge, then its back, with the ribbon still hanging from it." src="https://raw.githubusercontent.com/johnmorrisdotca/dougu-sports/main/docs/images/flips-turn-light.webp" width="760">
106
106
  </picture>
107
107
  </p>
108
108
 
@@ -140,8 +140,8 @@ Put the string in a page (`element.innerHTML = svg`), write it to a file, or sen
140
140
 
141
141
  - **Equipment first (道具 is the word for it)**: 118 balls and implements drawn in 3D, from the 32-panel soccer ball to the 26, 18, 14, 12 and 6 panel constructions, vintage leather balls, era-style and shop-wall soccer balls, baseballs of other threads, volleyballs in national colours ([docs/vintage-balls.md](./docs/vintage-balls.md)), futsal, beach soccer and the pucks and stones, each with its official size, 8 printed pucks (a flag, a leaf, stars and stripes, a player's number, a made-up signature, your own picture: [docs/pucks.md](./docs/pucks.md)), and 42 pieces of gear (sticks, rackets, helmets, skates, nets, and the Japanese budo kit) drawn to match; see [docs/gallery.md](./docs/gallery.md).
142
142
  - **Three styles** for every emblem: `metal` (polished metal: a gradient, a highlight and a soft rim of reflected light, edged in a darker tone of its own metal, with no heavy outline), `line` (one clean outline in the page's text colour, made to read at 14 pixels) and `color` (flat fills); see [Theming](#theming).
143
- - **Size, tint, accent and theme** as options: a tint recolours the main part (a medal's metal, a crest's colour, a ball's patches), an accent the second colour (a ribbon, a crest's band, the pennants), a theme sets the ink for light, dark or either.
144
- - **Turning**: `emblemFrame` and `ballFrame` return the emblem at an angle, in plain SVG: every emblem turns, a ball rolls, a cup keeps its outline and moves its light, and everything flat (gear, a bat, a crown, a sport's icon) flips like a coin; `bindTurn` lets a drag, the arrow keys or an auto-turn do the turning in a page; see [docs/turning.md](./docs/turning.md).
143
+ - **Size, tint, accent and theme** as options: a tint recolours the main part (a medal's metal, a crest's colour, a ball's patches; not the body of a golf ball or a baseball, whose colour the sport fixes), an accent the second colour (a ribbon, a crest's band, the pennants), a theme sets the ink for light, dark or either.
144
+ - **Turning**: `emblemFrame` and `ballFrame` return the emblem at an angle, in plain SVG: every emblem turns, a ball rolls, a cup keeps its outline and moves its light, and everything flat (a medal, a crest, a banner, gear, a bat, a crown, a sport's icon) flips like a coin with a solid edge, a back of its own and its ribbon or pole seen from behind; `bindTurn` lets a drag, the arrow keys or an auto-turn do the turning in a page; see [docs/turning.md](./docs/turning.md).
145
145
  - **Engraving**: text cut into a medal, a plaque, the belt, a ring, a banner, a pennant and the plinth of a cup, from `label`, `year` and `rank` or one `engrave` string, in the ink that reads at 4.5 to 1 on the metal, and it turns with the emblem; see [engraving](./docs/engraving.md).
146
146
  - **Custom balls**: a baseball, a softball and the classic soccer balls take `paint` (a solid colour, or two: along the seam or in halves, with stitches that still show), `decal` (your own SVG or image as a logo, wrapped onto the sphere so that it turns with the ball), `stamp` (condensed bold text between small stars, with an optional made-up signature), `script` (an original cursive word) and `mark` (a letter in a circle, or an SVG of your own); see [docs/custom-balls.md](./docs/custom-balls.md). The package carries no logo of anyone's: a logo or a mark is the one you pass.
147
147
  - **Labels in two languages**: the `<title>` is English or Japanese (`locale`), your own words (`title: "…"`), or left out for an emblem that sits beside text that says the same (`title: false`).
@@ -545,7 +545,7 @@ The metals are `METALS` (gold `#d9a620`, silver `#aeb6c0`, bronze `#b8733a`); ea
545
545
  ## Limits
546
546
 
547
547
  - A turn is drawn in plain SVG, not in 3D: a cup keeps its silhouette and moves its light, handles and seams; a medal is squeezed; a ball is projected from its own geometry, panel by panel, and has no grain while it turns. Gear, icons and pictograms flip like a coin, with the back as a mirror of the front and a thin rim; a pennant's back is its own drawing; one turn (`src/emblems/emblem.flip.ts`) does it for all of them.
548
- - A tint is one colour, the main one, and an accent a second. The pieces of a trophy that are another metal (the gilt of a plate, the green bands of a globe, the black foot of a cup) keep their own colour.
548
+ - A tint is one colour, the main one, and an accent a second. A golf ball and a baseball are white by the rules of their sports, so they ignore `tint` (a baseball takes `paint`, as the custom balls do); every other ball takes it. The pieces of a trophy that are another metal (the gilt of a plate, the green bands of a globe, the black foot of a cup) keep their own colour.
549
549
  - The trophies are generic: each is drawn for this package in a simplified form that a reader knows at a glance, and none is traced from a photograph or carries a name, a mark or an engraving.
550
550
  - A trophy in the metal style is about 3 to 4.5 kB of markup, a medal or a crest about 1 to 2 kB, a line drawing about 1 to 2.5 kB; a ball in 3D is 5 to 13 kB, and a piece of gear is 1 to 4 kB, drawn in layers.
551
551
  - The size is a number of pixels up to 4096; the drawing is a vector at any size.
@@ -6,6 +6,7 @@ export const BALL_BASEBALL = /* @__PURE__ */ seamedBall({
6
6
  ja: "野球のボール",
7
7
  size: { extentMm: 73.8, diameterMm: 73.8, circumferenceMm: 231.77, massG: [141.75, 148.83], rule: "Official Baseball Rules, Rule 3.01 The Ball", note: "Diameter worked out from the middle of the circumference range." },
8
8
  base: "#f4f0e6",
9
+ fixed: true,
9
10
  b: 0.4,
10
11
  view: { yaw: 18, pitch: 28, roll: -10 },
11
12
  mat: { gloss: 0.3, shade: "#4a3a2a", grain: { f: 1.4, a: 0.12 } },
@@ -57,6 +57,7 @@ export interface Surface {
57
57
  opacity?: number;
58
58
  ink?: boolean;
59
59
  light?: boolean;
60
+ dimple?: string;
60
61
  }): string;
61
62
  /** The facing of a point: 1 straight at you, 0 on the limb. */
62
63
  facing(u: V3): number;
@@ -96,6 +97,8 @@ export interface BallSpec {
96
97
  };
97
98
  /** The main colour of the ball (what `tint` replaces). */
98
99
  base: string;
100
+ /** The ball's real colour is fixed (a golf ball, a baseball): `tint` does not recolour it. The ball's own `paint` still does, where it has one. */
101
+ fixed?: boolean;
99
102
  /** The colour the whole ball is filled with before its panels are laid on, where it is not `base` (a soccer ball is white, its pentagons are `base`). */
100
103
  ground?: string;
101
104
  mat: Material;
@@ -269,6 +269,16 @@ function surfaceOf(g, line, minWidth, home) {
269
269
  if (d.length === 0)
270
270
  return "";
271
271
  const path = d.join("");
272
+ if (o?.dimple !== undefined && !line) {
273
+ // A dimple is a shallow bowl lit from the upper left: its upper left wall is in shadow, its lower right wall catches the light. Three layers for all of them: the whole mark in the shadow's colour, the same marks moved a little
274
+ // toward the lower right and filled with the ball's own colour (which leaves a crisp dark crescent on the upper left), and a bright edge along the lower right.
275
+ const r = s.radius * sr;
276
+ const dx = num(r * 0.2, 2), dy = num(r * 0.26, 2);
277
+ const w = num(Math.max(0.3, Math.min(r * 0.2, 0.9)), 2);
278
+ const key = `dm${hashOf(path)}`;
279
+ const at = `transform="translate(${dx} ${dy})"`;
280
+ return `<defs><path id="${key}" d="${path}"/></defs><use href="#${key}" fill="${fill}" opacity="${o.opacity ?? 0.55}"/><use href="#${key}" fill="${o.dimple}" ${at}/><use href="#${key}" fill="none" stroke="#fff" stroke-width="${w}" opacity=".7" ${at}/>`;
281
+ }
272
282
  const hi = o?.light === false || line ? "" : `<path d="${path}" fill="none" stroke="#fff" stroke-width="${num(Math.max(0.35, minWidth * 0.3), 2)}" opacity=".3" transform="translate(.25 .35)"/>`;
273
283
  return `${hi}<path d="${path}" fill="${line ? INK : fill}"${o?.opacity === undefined ? "" : ` opacity="${o.opacity}"`}/>`;
274
284
  },
@@ -333,12 +343,13 @@ export function drawBall(title, key, spec, opts, turn, turning) {
333
343
  // A ball with a second colour of its own (a soccer ball's panels on a white ground) paints the body as the ground and the second colour as the panels; one with a single colour takes the body as its main colour.
334
344
  const paint = opts.paint;
335
345
  const two = spec.ground !== undefined;
336
- const base = opts.tint ?? (two ? paint?.second : paint?.body) ?? spec.base;
346
+ const tint = spec.fixed === true ? undefined : opts.tint;
347
+ const base = tint ?? (two ? paint?.second : paint?.body) ?? spec.base;
337
348
  const ground = two ? paint?.body ?? spec.ground : undefined;
338
349
  const surface = surfaceOf(g, line, minWidth, homeOf(spec));
339
350
  const layers = spec.draw(surface, base, opts.accent, opts.colors, { paint: opts.paint, print: opts.print, printer: opts.printer });
340
351
  const sil = silhouette(g);
341
- const id = `k${hashOf(`${key}|${opts.style}|${opts.tint ?? ""}|${opts.accent ?? ""}${opts.colors === undefined ? "" : `|${opts.colors.join("")}`}|${sil.tag}${sil.d}${paint === undefined && (opts.print === undefined || opts.printer === undefined) ? "" : `|${JSON.stringify([paint, opts.printer === undefined ? undefined : opts.print])}`}`)}`;
352
+ const id = `k${hashOf(`${key}|${opts.style}|${tint ?? ""}|${opts.accent ?? ""}${opts.colors === undefined ? "" : `|${opts.colors.join("")}`}|${sil.tag}${sil.d}${paint === undefined && (opts.print === undefined || opts.printer === undefined) ? "" : `|${JSON.stringify([paint, opts.printer === undefined ? undefined : opts.print])}`}`)}`;
342
353
  // A long outline (the hull of a pointed ball) is written once, in the definitions, and used wherever the silhouette is.
343
354
  const long = sil.d.length > 400;
344
355
  const outline = long ? `<path id="${id}s" d="${sil.d}"/>` : "";
@@ -22,6 +22,10 @@ export interface DottedOptions {
22
22
  light?: boolean;
23
23
  /** Real holes through the shell, not marks on it (`holeLayers` from ball.holes.ts, taken by the pickleball only): the near ones are cut out and show the inside of the ball, and where two line up the page shows through. The line style still draws marks. */
24
24
  hollow?: Drill;
25
+ /** The ball's real colour is fixed, so `tint` does not recolour it (see `BallSpec.fixed`). */
26
+ fixed?: boolean;
27
+ /** Marks drawn as shallow bowls (dimples): a shadow on the upper left wall, a bright edge on the lower right, the ball's own colour inside. */
28
+ dimples?: boolean;
25
29
  /** Turns the spread, so a ball does not show its spiral's pole straight on. */
26
30
  phase?: number;
27
31
  }
@@ -10,12 +10,13 @@ export function dottedBall(o) {
10
10
  const spec = {
11
11
  view: o.view ?? { yaw: 20, pitch: 25, roll: 0 },
12
12
  base: o.base,
13
+ fixed: o.fixed,
13
14
  mat: o.mat,
14
15
  draw: (s, base) => {
15
16
  const mark = o.mark ?? mix(base, -0.55);
16
17
  if (o.hollow !== undefined && !s.line)
17
18
  return "";
18
- return s.dots(centres, o.radius, mark, { opacity: o.depth ?? 0.55, ink: true, light: o.light ?? true });
19
+ return s.dots(centres, o.radius, mark, { opacity: o.depth ?? 0.55, ink: true, light: o.light ?? true, ...(o.dimples === true ? { dimple: mix(base, -0.04) } : {}) });
19
20
  },
20
21
  holes: o.hollow === undefined ? undefined : (s, id, base, shape, minWidth) => o.hollow(s, id, { centres, radius: o.radius }, base, shape, minWidth),
21
22
  };
@@ -9,6 +9,8 @@ export interface SeamedOptions {
9
9
  base: string;
10
10
  /** The seam's parameter b. */
11
11
  b: number;
12
+ /** The ball's real colour is fixed, so `tint` does not recolour it (see `BallSpec.fixed`). */
13
+ fixed?: boolean;
12
14
  view?: BallSpec["view"];
13
15
  mat: BallSpec["mat"];
14
16
  /** A strip along the seam: its colour and width. */
@@ -19,6 +19,7 @@ export function seamedBall(o) {
19
19
  const spec = {
20
20
  view: o.view ?? { yaw: 0, pitch: 0, roll: 0 },
21
21
  base: o.base,
22
+ fixed: o.fixed,
22
23
  mat: o.mat,
23
24
  home: o.home,
24
25
  draw: (s, base, _accent, _colors, custom) => {
@@ -6,10 +6,12 @@ export const BALL_GOLF = /* @__PURE__ */ dottedBall({
6
6
  ja: "ゴルフボール",
7
7
  size: { extentMm: 42.67, diameterMm: 42.67, massG: [0, 45.93], rule: "Rules of Equipment (R&A / USGA), Part 4 Conformance of Balls, 4.2 Weight and 4.3 Size", note: "A minimum diameter and a maximum mass: the rules set no maximum size and no minimum weight." },
8
8
  base: "#f7f7f5",
9
- count: 190,
10
- radius: 0.066,
11
- depth: 0.2,
9
+ fixed: true,
10
+ count: 330,
11
+ radius: 0.074,
12
+ depth: 0.62,
13
+ dimples: true,
12
14
  light: false,
13
- mark: "#5a6070",
15
+ mark: "#7d8594",
14
16
  mat: { gloss: 0.85, shade: "#2a3350", grain: undefined },
15
17
  });
@@ -13,6 +13,8 @@ export interface ObjectContext {
13
13
  turning: boolean;
14
14
  /** An id prefix that no other drawing of this piece in the page shares. */
15
15
  id: string;
16
+ /** The turn about the object's own axis, in degrees (its resting view's and the turn's together): what a knurled edge slides by. */
17
+ spin: number;
16
18
  /** The thinnest line that shows at this size, in box units. */
17
19
  minWidth: number;
18
20
  /** A point of the object in its own space to the box: x, y and depth (positive toward you); one unit is 46 box units. */
@@ -14,7 +14,7 @@ export function defineObject(def) {
14
14
  const q = apply(rot, [(p[0] - sh[0]) * k, (p[1] - sh[1]) * k, (p[2] - sh[2]) * k]);
15
15
  return [50 + 46 * q[0], 50 - 46 * q[1], q[2]];
16
16
  };
17
- const { defs, body } = def.draw({ rot, opts, turning, id, minWidth: 80 / opts.size, at });
17
+ const { defs, body } = def.draw({ rot, opts, turning, id, spin: def.view.yaw + finite(o?.yaw), minWidth: 80 / opts.size, at });
18
18
  return wrapPiece({ title: escape(def.en), size: opts.size, theme: opts.theme, defs, body });
19
19
  };
20
20
  return { id: def.id, en: def.en, ja: def.ja, source: "generated", kind: "ball", model: { ...def.model, view: def.view }, size: def.size, svg: labelled(def.en, def.ja, (o) => render(o, false)), frame: labelled(def.en, def.ja, (o) => render(o, true)) };
@@ -14,7 +14,7 @@ export const puckSpec = (extra) => ({
14
14
  gloss: 0.5,
15
15
  shade: "#000",
16
16
  grain: { f: 1.6, a: 0.25 },
17
- grooves: { count: 30, slices: [0], colour: "#000", opacity: 0.5 },
17
+ knurl: { count: 44, slices: [0], colour: "#000", opacity: 0.7 },
18
18
  topRing: { r: 0.9, colour: "#4a4d56" },
19
19
  ...extra,
20
20
  });
@@ -25,6 +25,13 @@ export interface StackSpec {
25
25
  colour: string;
26
26
  opacity: number;
27
27
  };
28
+ /** A knurled band: diamonds cut across the whole wall of the slices named, `count` of them round the slice, in lines of `colour`. It follows the wall all round and to its crisp lower edge. */
29
+ knurl?: {
30
+ count: number;
31
+ slices: readonly number[];
32
+ colour: string;
33
+ opacity: number;
34
+ };
28
35
  /** A ring on the top face: radius as a fraction, colour. */
29
36
  topRing?: {
30
37
  r: number;
@@ -45,7 +45,6 @@ export function drawStack(c, spec) {
45
45
  const [x0, y0] = at(s.y0), [x1, y1] = at(s.y1);
46
46
  const rb = 46 * radius(s, 0), rt = 46 * radius(s, 1);
47
47
  const pts = [[x0 + perp[0] * rb, y0 + perp[1] * rb], [x1 + perp[0] * rt, y1 + perp[1] * rt], [x1 - perp[0] * rt, y1 - perp[1] * rt], [x0 - perp[0] * rb, y0 - perp[1] * rb]];
48
- const d4 = `M${pts.map((p) => `${num(p[0], 2)} ${num(p[1], 2)}`).join("L")}Z`;
49
48
  if (line) {
50
49
  if (s.outline !== true)
51
50
  return "";
@@ -57,9 +56,18 @@ export function drawStack(c, spec) {
57
56
  const near = topVisible ? -1 : 1;
58
57
  return `<path d="${lines}${rim(s.y0, radius(s, 0), near)}${i === slices.length - 1 ? "" : rim(s.y1, radius(s, 1), near)}" fill="none"/>`;
59
58
  }
60
- let out = `<path d="${d4}" fill="url(#${gid})"/>`;
59
+ // The wall is the band between the two ends and the half of the far end's rim that bulges toward you, so it ends in a crisp curved edge like a real cylinder's, not in the soft edge of its cap.
60
+ const bulge = (y, r) => {
61
+ const [cx, cy] = at(y);
62
+ const rad = (perpDeg * Math.PI) / 180;
63
+ const ex = cx - Math.cos(rad) * 46 * r, ey = cy - Math.sin(rad) * 46 * r;
64
+ return `A${num(46 * r, 2)} ${num(Math.max(46 * r * Math.abs(nz), 0.02), 2)} ${num(perpDeg, 1)} 0 ${topVisible ? 1 : 0} ${num(ex, 2)} ${num(ey, 2)}`;
65
+ };
66
+ const p = (k) => `${num(pts[k][0], 2)} ${num(pts[k][1], 2)}`;
67
+ const wall = topVisible ? `M${p(1)}L${p(0)}${bulge(s.y0, radius(s, 0))}L${p(2)}Z` : `M${p(0)}L${p(1)}${bulge(s.y1, radius(s, 1))}L${p(3)}Z`;
68
+ let out = `<path d="${wall}" fill="url(#${gid})"/>`;
61
69
  const gr = spec.grooves;
62
- if (!line && gr !== undefined && gr.slices.includes(i)) {
70
+ if (gr !== undefined && gr.slices.includes(i)) {
63
71
  let marks = "";
64
72
  for (let k = 0; k < gr.count; k++) {
65
73
  const t = ((k + 0.5) / gr.count) * Math.PI;
@@ -71,6 +79,36 @@ export function drawStack(c, spec) {
71
79
  }
72
80
  out += `<path d="${marks}" stroke="${gr.colour}" stroke-width="${num(Math.max(0.5, c.minWidth * 0.5), 2)}" opacity="${gr.opacity}" fill="none"/>`;
73
81
  }
82
+ const kn = spec.knurl;
83
+ if (kn !== undefined && kn.slices.includes(i)) {
84
+ // Each line of the knurl runs up the wall as it goes round it, one family each way, and is cut where it would leave the half of the wall you can see.
85
+ const toward = topVisible ? -1 : 1;
86
+ const point = (theta, v) => {
87
+ const rr = rb + (rt - rb) * v;
88
+ const cx = x0 + (x1 - x0) * v, cy = y0 + (y1 - y0) * v;
89
+ return `${num(cx + perp[0] * rr * Math.cos(theta) + toward * d[0] * rr * Math.abs(nz) * Math.sin(theta), 1)} ${num(cy + perp[1] * rr * Math.cos(theta) + toward * d[1] * rr * Math.abs(nz) * Math.sin(theta), 1)}`;
90
+ };
91
+ const pitch = (2 * Math.PI) / kn.count;
92
+ // A turn about the axis slides the knurl round the wall (a positive turn carries its front to the right).
93
+ const slide = ((((-c.spin * Math.PI) / 180) % pitch) + pitch) % pitch;
94
+ let marks = "";
95
+ for (const way of [1, -1]) {
96
+ for (let k = -2; k <= kn.count / 2 + 2; k++) {
97
+ let pen = false;
98
+ for (let j = 0; j <= 4; j++) {
99
+ const v = j / 4;
100
+ const theta = k * pitch + slide + way * 2 * pitch * v;
101
+ if (theta < 0 || theta > Math.PI) {
102
+ pen = false;
103
+ continue;
104
+ }
105
+ marks += `${pen ? "L" : "M"}${point(theta, v)}`;
106
+ pen = true;
107
+ }
108
+ }
109
+ }
110
+ out += `<path d="${marks}" stroke="${kn.colour}" stroke-width="${num(Math.max(0.5, c.minWidth * 0.55), 2)}" opacity="${kn.opacity}" fill="none" stroke-linecap="butt"/>`;
111
+ }
74
112
  return out;
75
113
  };
76
114
  const cap = (y, r, colour, i, topFace) => {
@@ -6,5 +6,9 @@ export interface FlipOptions {
6
6
  back?: string;
7
7
  thick?: number;
8
8
  }
9
- /** A document turned by `yaw` and `pitch` degrees from its still drawing. With no turn it is the document itself. */
9
+ /**
10
+ * A document turned by `yaw` and `pitch` degrees from its still drawing. With no turn it is the document itself.
11
+ * The piece is a slab: its face is squeezed by the cosine of the turn, and between the face and its other side runs a solid edge (the thickness) that stays the same width on the screen while the face
12
+ * narrows, so that edge on it is a band and never a hairline. The edge is the face's own colours darkened, and a thin part (a stick, a strap) keeps its edge because the squeezed face is made opaque again before it is moved.
13
+ */
10
14
  export declare function flipSvg(svg: string, o: FlipOptions): string;
@@ -11,7 +11,15 @@ function split(svg) {
11
11
  const box = (/viewBox="([^"]*)"/.exec(open)?.[1] ?? "0 0 64 64").split(/\s+/).map(Number);
12
12
  return { open, head, rest: after.slice(head.length), box: [box[0] ?? 0, box[1] ?? 0, box[2] || 64, box[3] || 64] };
13
13
  }
14
- /** A document turned by `yaw` and `pitch` degrees from its still drawing. With no turn it is the document itself. */
14
+ /** The slices an edge is built from: each is the silhouette moved a little further along the thickness, so that together they are a solid band. */
15
+ const SLICES = 8;
16
+ /** The least thickness of the edge on the screen, in pixels, so that a piece edge on never thins to a hairline however small it is drawn. */
17
+ const LEAST_PX = 2.4;
18
+ /**
19
+ * A document turned by `yaw` and `pitch` degrees from its still drawing. With no turn it is the document itself.
20
+ * The piece is a slab: its face is squeezed by the cosine of the turn, and between the face and its other side runs a solid edge (the thickness) that stays the same width on the screen while the face
21
+ * narrows, so that edge on it is a band and never a hairline. The edge is the face's own colours darkened, and a thin part (a stick, a strap) keeps its edge because the squeezed face is made opaque again before it is moved.
22
+ */
15
23
  export function flipSvg(svg, o) {
16
24
  if (o.yaw === 0 && o.pitch === 0)
17
25
  return svg;
@@ -24,23 +32,32 @@ export function flipSvg(svg, o) {
24
32
  const [x0, y0, w, h] = front.box;
25
33
  const sx = Math.max(Math.abs(across), 0.02);
26
34
  const sy = Math.max(Math.abs(tilt), 0.02);
27
- const thick = w * (o.thick ?? 0.035);
28
- // A turn to a positive yaw carries the front toward the right, as it does for a ball and a cup (and for a drag to the right), so the rim is seen on the left, on the edge that comes toward the eye.
29
- const dx = -thick * Math.sin(o.yaw * RAD);
30
- const dy = -thick * Math.sin(o.pitch * RAD) * 0.6;
35
+ const pixels = Number(/\swidth="([\d.]+)"/.exec(front.open)?.[1]) || 64;
36
+ const thick = Math.min(Math.max(w * (o.thick ?? 0.035), (LEAST_PX * w) / pixels), w * 0.1);
37
+ // The edge runs from the face toward the far side. For a face that looks to the eye it is behind it (a positive yaw carries the front toward the right, as it does for a ball and a cup, so the edge is on the
38
+ // left); past a quarter turn the face seen is the back and the edge lies toward the front, on the other side. The whole slab is centred on the piece, so it does not slide as it turns.
39
+ const side = showBack ? -1 : 1;
40
+ const dx = -thick * Math.sin(o.yaw * RAD) * side;
41
+ const dy = -thick * Math.sin(o.pitch * RAD) * Math.abs(across) * 0.6;
31
42
  const mirror = showBack && own === undefined;
32
43
  const cx = x0 + w / 2;
33
44
  const cy = y0 + h / 2;
34
- // The turned face, then the filter that adds the rim behind it (and dims a mirrored back).
45
+ const flat = Math.abs(dx) + Math.abs(dy) < 0.05;
35
46
  // The id names everything the definition depends on (its region comes from the box), so two drawings in one page that share an id share one filter.
36
- const id = `dgf${o.line === true ? "l" : "c"}${mirror ? "b" : "f"}${Math.round(dx * 100)}_${Math.round(dy * 100)}_${[x0, y0, w, h].map((v) => Math.round(v)).join("_")}`;
37
- const flood = o.line === true ? '<feFlood flood-color="currentColor" flood-opacity=".5"/>' : '<feFlood flood-color="#000" flood-opacity=".42"/>';
38
- const steps = [1, 2, 3].map((k) => (Math.abs(dx) + Math.abs(dy) < 0.05 ? "" : `<feOffset in="s" dx="${n2((dx * k) / 3)}" dy="${n2((dy * k) / 3)}" result="o${k}"/>`)).join("");
39
- const nodes = Math.abs(dx) + Math.abs(dy) < 0.05 ? "" : '<feMergeNode in="o3"/><feMergeNode in="o2"/><feMergeNode in="o1"/>';
40
- const dim = mirror && o.line !== true ? '<feColorMatrix in="SourceGraphic" values=".8 0 0 0 0 0 .8 0 0 0 0 0 .8 0 0 0 0 0 1 0" result="d"/>' : "";
47
+ const lift = Math.round(Math.min(1 / sx, 24) * 10) / 10;
48
+ const dark = mirror && o.line !== true ? 0.8 : 1;
49
+ const id = `dgf${o.line === true ? "l" : "c"}${mirror ? "b" : "f"}${Math.round(dx * 100)}_${Math.round(dy * 100)}_${lift}_${[x0, y0, w, h].map((v) => Math.round(v)).join("_")}`;
50
+ const steps = flat ? "" : Array.from({ length: SLICES }, (_, k) => `<feOffset in="s" dx="${n2((dx * (k + 1)) / SLICES)}" dy="${n2((dy * (k + 1)) / SLICES)}" result="o${k + 1}"/>`).join("");
51
+ const nodes = flat ? "" : Array.from({ length: SLICES }, (_, k) => `<feMergeNode in="o${SLICES - k}"/>`).join("");
52
+ // The edge: the face's colours, darkened and made opaque again (the squeeze spread them thin); in the line style a flat dim ink.
53
+ const edge = o.line === true
54
+ ? `<feColorMatrix in="SourceGraphic" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 ${lift} 0" result="a"/><feFlood flood-color="currentColor"/><feComposite in2="a" operator="in" result="b"/><feComponentTransfer in="b" result="s"><feFuncA type="linear" slope=".5"/></feComponentTransfer>`
55
+ : `<feColorMatrix in="SourceGraphic" values=".58 0 0 0 0 0 .58 0 0 0 0 0 .58 0 0 0 0 0 ${lift} 0" result="s"/>`;
56
+ const dim = mirror && o.line !== true ? `<feColorMatrix in="SourceGraphic" values="${dark} 0 0 0 0 0 ${dark} 0 0 0 0 0 ${dark} 0 0 0 0 0 1 0" result="d"/>` : "";
41
57
  const filter = `<filter id="${id}" x="${n2(x0 - w * 0.15)}" y="${n2(y0 - h * 0.15)}" width="${n2(w * 1.3)}" height="${n2(h * 1.3)}" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">` +
42
- `${flood}<feComposite in2="SourceAlpha" operator="in" result="s"/>${steps}${dim}<feMerge>${nodes}<feMergeNode in="${dim === "" ? "SourceGraphic" : "d"}"/></feMerge></filter>`;
58
+ `${flat ? "" : edge + steps}${dim}<feMerge>${nodes}<feMergeNode in="${dim === "" ? "SourceGraphic" : "d"}"/></feMerge></filter>`;
43
59
  const flip = mirror ? -1 : 1;
44
- const group = `<g filter="url(#${id})"><g transform="translate(${n2(cx)} ${n2(cy)}) scale(${n2(sx * flip)} ${n2(sy)}) translate(${n2(-cx)} ${n2(-cy)})">${face.rest}</g></g>`;
60
+ const shift = flat ? "" : `translate(${n2(-dx / 2)} ${n2(-dy / 2)}) `;
61
+ const group = `<g filter="url(#${id})"><g transform="${shift}translate(${n2(cx)} ${n2(cy)}) scale(${n2(sx * flip)} ${n2(sy)}) translate(${n2(-cx)} ${n2(-cy)})">${face.rest}</g></g>`;
45
62
  return `${front.open}${front.head}<defs>${filter}</defs>${group}</svg>`;
46
63
  }
@@ -26,7 +26,8 @@ export function defineMedal(id, en, ja, metal) {
26
26
  ["M", starPath(32, 43, 9, 3.9)],
27
27
  ["l", "M19.2 40a13.4 13.4 0 0 1 9.4-10.4 14 14 0 0 0-6.6 12.6z"],
28
28
  ];
29
- const back = [...disc, ["E", circlePath(32, 43, 8.4), "1.4"]];
29
+ // From behind the ribbon is still there (the straps cross the other way, as the turn mirrors them) and the disc is plain, with a ring where a name is cut.
30
+ const back = [...ribbon(), ...disc, ["E", circlePath(32, 43, 8.4), "1.4"]];
30
31
  return defineFlat(id, en, ja, "ours", { metal, front, back, rim: 3, engrave: { front: { x: 32, y: 43, w: 19, h: 15 }, back: { x: 32, y: 43, w: 20, h: 13 } } });
31
32
  }
32
33
  /** The numerals 1, 2 and 3 as strokes. */
@@ -38,7 +38,11 @@ export interface FlatModel {
38
38
  /** Where a crest's letters go: the centre's x and y, and the size for one letter. */
39
39
  letters?: readonly [x: number, y: number, size: number];
40
40
  }
41
- /** A flat object (a medal, a crest, a plaque): drawn as it is by `svg`; turned about its vertical axis it flips like a coin and shows its back, with its rim for a moment edge on. */
41
+ /**
42
+ * A flat object (a medal, a crest, a plaque, a banner): drawn as it is by `svg`; turned about its vertical axis it is a slab with a front, a back and a solid edge (`flipSvg`). The edge stays as wide on the screen as the
43
+ * thickness the model gives (`rim`), so edge on it is a band and never a hairline. The back is the model's `back` shapes (what hangs from it, a ribbon or a pole, is there too) seen from behind, so they are mirrored,
44
+ * as are the front's plain parts (its metal and its second colour, without the details) when there is no `back`; an engraving on the back is cut to read from behind, so it is not mirrored.
45
+ */
42
46
  export declare function defineFlat(id: string, en: string, ja: string, source: EmblemSource, model: FlatModel): Emblem<FlatModel>;
43
47
  /** An icon: one drawing from shapes. Turned, it is a thin cut-out: it squeezes like a coin, shows its front mirrored and a little darker past a quarter turn, and has a dark rim. */
44
48
  export declare function defineIcon(id: string, en: string, ja: string, source: EmblemSource, model: FlatModel): Emblem<FlatModel>;
@@ -83,24 +83,26 @@ function context(model, r) {
83
83
  function document(title, model, r, ctx, body) {
84
84
  return wrap({ title, size: r.size, style: r.style, theme: r.theme, defs: r.style === "metal" ? defsOf(ctx.used) : "", body, box: model.box, edge: model.ink === true ? undefined : mix(ctx.main, -0.5), width: model.ink === true ? undefined : 0.8 });
85
85
  }
86
- /** A flat object (a medal, a crest, a plaque): drawn as it is by `svg`; turned about its vertical axis it flips like a coin and shows its back, with its rim for a moment edge on. */
86
+ /**
87
+ * A flat object (a medal, a crest, a plaque, a banner): drawn as it is by `svg`; turned about its vertical axis it is a slab with a front, a back and a solid edge (`flipSvg`). The edge stays as wide on the screen as the
88
+ * thickness the model gives (`rim`), so edge on it is a band and never a hairline. The back is the model's `back` shapes (what hangs from it, a ribbon or a pole, is there too) seen from behind, so they are mirrored,
89
+ * as are the front's plain parts (its metal and its second colour, without the details) when there is no `back`; an engraving on the back is cut to read from behind, so it is not mirrored.
90
+ */
87
91
  export function defineFlat(id, en, ja, source, model) {
88
92
  const draw = (o, yaw, pitch) => {
89
93
  const r = resolve(o);
90
94
  const ctx = context(model, r);
91
95
  ctx.engrave = engravingOf(o);
96
+ const front = document(en, model, r, ctx, face(model, model.front, ctx, engraved(model, ctx, "front")));
92
97
  if (yaw === 0 && pitch === 0)
93
- return document(en, model, r, ctx, face(model, model.front, ctx, engraved(model, ctx, "front")));
94
- const across = Math.cos((yaw * Math.PI) / 180);
95
- const tilt = Math.cos((pitch * Math.PI) / 180);
96
- const shown = across >= 0 ? model.front : (model.back ?? model.front.filter((x) => x[0] === "m" || x[0] === "o" || x[0] === "s"));
97
- const sx = Math.max(Math.abs(across), 0.02);
98
- const sy = Math.max(Math.abs(tilt), 0.02);
99
- // The rim is on the left for a positive yaw, which carries the front toward the right (as it does for a ball, a cup and a drag to the right).
100
- const rim = -(model.rim ?? 0) * Math.sin((yaw * Math.PI) / 180);
101
- const [x0, y0, w, h] = (model.box ?? "0 0 64 64").split(" ").map(Number);
102
- const thick = rim === 0 ? "" : `<g transform="translate(${n2(rim)} 0)">${shapesMarkup(model.front.filter((x) => x[0] === "m" || x[0] === "o").map((x) => ["s", x[1]]), ctx)}</g>`;
103
- return document(en, model, r, ctx, `<g transform="translate(${n2(x0 + w / 2)} ${n2(y0 + h / 2)}) scale(${n2(sx)} ${n2(sy)}) translate(${n2(-x0 - w / 2)} ${n2(-y0 - h / 2)})">${thick}${face(model, shown, ctx, engraved(model, ctx, across >= 0 ? "front" : "back"))}</g>`);
98
+ return front;
99
+ const [x0, , w] = (model.box ?? "0 0 64 64").split(" ").map(Number);
100
+ const text = engraved(model, ctx, "back");
101
+ const shown = (model.back ?? model.front.filter((x) => x[0] === "m" || x[0] === "o" || x[0] === "s" || x[0] === "r")).filter((x) => text === "" || x[0] === x[0].toLowerCase());
102
+ const plain = { ...model, blank: undefined, letters: undefined };
103
+ const behind = `<g transform="translate(${n2(2 * x0 + w)} 0) scale(-1 1)">${face(plain, shown, ctx)}</g>${text}`;
104
+ const back = document(en, model, r, ctx, behind);
105
+ return flipSvg(front, { yaw, pitch, line: r.style === "line", back, thick: (model.rim ?? 3) / w });
104
106
  };
105
107
  return { id, en, ja, source, kind: "flat", model, svg: labelled(en, ja, (o) => draw(o, 0, 0)), frame: labelled(en, ja, (o) => draw(o, degrees(o?.yaw), degrees(o?.pitch))) };
106
108
  }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.16.1";
1
+ export declare const VERSION = "0.16.2";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.16.1";
1
+ export const VERSION = "0.16.2";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnmorrisdotca/dougu-sports",
3
- "version": "0.16.1",
3
+ "version": "0.16.2",
4
4
  "description": "Dougu Sports 道具, sports emblems as SVG strings: trophies, medals, crests, sport icons, and the ball and gear of every sport, in metal, line or flat colour, with optional 3D turning. Light and dark, accessible, tree-shakeable. Zero dependencies.",
5
5
  "keywords": [
6
6
  "sports",