From 237b257f87d0bb70c8b5fe9fa91026294ed79f4d Mon Sep 17 00:00:00 2001 From: samtcifihi <43021399+samtcifihi@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:26:13 -0400 Subject: [PATCH] Add piece-relative glyph offsets for upright labels Crosshairs needs altitude numbers to remain upright while their positions follow the rotating planes. Existing glyph nudges follow the glyph's own counter-rotation and therefore stay fixed on screen. Add opt-in nudge.relativeTo = "piece" to translate in enclosing piece/face axes after glyph rotation and flips, retaining scaled offset units. Preserve existing behavior when omitted or set to "glyph". Regenerate schema types and document the option. Validation: 18 new offset tests and 4 existing flip/rotation tests pass; renderer compilation with ES2022 libraries and targeted lint pass. Tested jointly with the Crosshairs change across all 12 angles and 4 display modes. Companion proposal: samtcifihi/ap-gameslib branch crosshairs-board-rotation-30. Release the renderer update before updating gameslib's renderer dependency and integrating that branch. --- docs/glyphs.md | 2 +- src/renderers/_base.ts | 11 +++++++++- src/schemas/schema.d.ts | 4 ++++ src/schemas/schema.json | 5 +++++ test/glyphNudge.render.test.ts | 40 ++++++++++++++++++++++++++++++++++ 5 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 test/glyphNudge.render.test.ts diff --git a/docs/glyphs.md b/docs/glyphs.md index f090c40..04e932d 100644 --- a/docs/glyphs.md +++ b/docs/glyphs.md @@ -173,7 +173,7 @@ Each glyph object in the legend supports these properties. `name` and `text` are | `orientation` | `"fluid"` \| `"vertical"` | `"fluid"` | After rotation, `"vertical"` keeps text upright. | | `flipx` | boolean | `false` | Mirror horizontally. With **`fluid`** (default sheet glyphs), flip is in piece/board coordinates and rotates with the board. With **`vertical`** or text glyphs, flip is relative to the **screen** (left/right stays fixed when `board.rotate` changes). | | `flipy` | boolean | `false` | Mirror vertically. Same **`fluid`** vs **`vertical`** / text behaviour as `flipx`. | -| `nudge` | `{ dx, dy }` | — | Offset from centre in cell units; negative `dx`/`dy` move left/up. | +| `nudge` | `{ dx, dy, relativeTo? }` | — | Offset from centre in glyph-scaled units; negative `dx`/`dy` move left/up. `relativeTo: "glyph"` (default) uses the glyph's rotated/flipped axes. `relativeTo: "piece"` uses the enclosing piece or face axes, so upright text can stay beside a rotating piece. | | `fontFamily` | string | — | CSS font family for text glyphs. | | `fontWeight` | string \| number | — | CSS font weight for text glyphs. | diff --git a/src/renderers/_base.ts b/src/renderers/_base.ts index ba3c98b..121b4d8 100644 --- a/src/renderers/_base.ts +++ b/src/renderers/_base.ts @@ -1050,7 +1050,16 @@ export abstract class RendererBase { if (g.nudge.dy !== undefined) { dy = g.nudge.dy; } - use.dmove(dx, dy); + if (g.nudge.relativeTo === "piece") { + // Translate in the enclosing piece/face axes after glyph rotation + // and flips. Keep the same scaled units as the legacy nudge. + const matrix = use.matrixify(); + matrix.e += dx * factor; + matrix.f += dy * factor; + use.matrix(matrix); + } else { + use.dmove(dx, dy); + } } } diff --git a/src/schemas/schema.d.ts b/src/schemas/schema.d.ts index bf2264e..71f6c0a 100644 --- a/src/schemas/schema.d.ts +++ b/src/schemas/schema.d.ts @@ -578,6 +578,10 @@ export interface Glyph { * The number of units to nudge the glyph from centre. */ nudge?: { + /** + * The offset coordinate system. 'glyph' uses the glyph's rotated/flipped axes. 'piece' uses the enclosing piece (or isometric face) axes, allowing an upright label's position to rotate with its piece. Both use glyph-scaled units. + */ + relativeTo?: "glyph" | "piece"; /** * Negative values move the glyph to the left. */ diff --git a/src/schemas/schema.json b/src/schemas/schema.json index 4a914fe..e787a9a 100644 --- a/src/schemas/schema.json +++ b/src/schemas/schema.json @@ -576,6 +576,11 @@ "description": "The number of units to nudge the glyph from centre.", "type": "object", "properties": { + "relativeTo": { + "description": "The offset coordinate system. 'glyph' uses the glyph's rotated/flipped axes. 'piece' uses the enclosing piece (or isometric face) axes, allowing an upright label's position to rotate with its piece. Both use glyph-scaled units.", + "enum": ["glyph", "piece"], + "default": "glyph" + }, "dx": { "description": "Negative values move the glyph to the left.", "type": "number", diff --git a/test/glyphNudge.render.test.ts b/test/glyphNudge.render.test.ts new file mode 100644 index 0000000..bd80016 --- /dev/null +++ b/test/glyphNudge.render.test.ts @@ -0,0 +1,40 @@ +import { expect } from "chai"; +import { SVG, registerWindow, type Svg } from "@svgdotjs/svg.js"; +import { createSVGWindow } from "svgdom"; +import { render, type Glyph } from "../src/index.js"; + +describe("glyph nudge coordinate systems", () => { + for (const rotation of [0, 30, 90, 180, 270, 330]) { + for (const relativeTo of [undefined, "glyph", "piece"] as const) { + it(`offsets upright text relative to ${relativeTo ?? "legacy glyph"} at ${rotation}°`, () => { + const window = createSVGWindow(); + registerWindow(window, window.document); + const draw = SVG(window.document.documentElement) as Svg; + const glyph: Glyph = { + text: "3", scale: 0.4, rotate: 30, flipx: true, + nudge: { dx: 100, dy: -50, relativeTo }, + }; + render({ + board: { style: "squares", width: 2, height: 2, rotate: 90 }, + legend: { A: glyph }, + pieces: "A-\n--", + }, { target: draw, rotate: rotation }); + const layer = draw.findOne("#A")!.findOne("use")!; + const matrix = layer.matrixify(); + const board = draw.findOne("#board")!.matrixify(); + const x = layer.x() + layer.width() / 2; + const y = layer.y() + layer.height() / 2; + const localX = matrix.a * x + matrix.c * y + matrix.e; + const localY = matrix.b * x + matrix.d * y + matrix.f; + const actualX = board.a * localX + board.c * localY; + const actualY = board.b * localX + board.d * localY; + // Legacy nudges follow the glyph transform (including its flip); + // piece nudges follow only the board and retain the glyph's scale. + const expectedLocalX = relativeTo === "piece" ? 40 : matrix.a * 100 - matrix.c * 50; + const expectedLocalY = relativeTo === "piece" ? -20 : matrix.b * 100 - matrix.d * 50; + expect(actualX).to.be.closeTo(board.a * expectedLocalX + board.c * expectedLocalY, 0.000001); + expect(actualY).to.be.closeTo(board.b * expectedLocalX + board.d * expectedLocalY, 0.000001); + }); + } + } +});