@tinylap/track-format 0.0.0-stage → 0.1.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.
@@ -0,0 +1,71 @@
1
+ // SPDX-FileCopyrightText: 2026 The TinyLap contributors
2
+ //
3
+ // SPDX-License-Identifier: MPL-2.0
4
+
5
+ // Reference layouts: hand-derived, verified against the model, and shared by
6
+ // every app's tests. stockL is the layout printed in the 87033 manual.
7
+
8
+ // Outer 7x9, hole 3x5, corridor 2 mats wide, 48 mats.
9
+ // Corner rotations: rot 0 exits N and E, so each corner is rotated to face
10
+ // the two neighbours it must meet.
11
+ /** @import { PlacedPiece } from './types.js' */
12
+
13
+ /** @returns {PlacedPiece[]} */
14
+ export function rectangularRing() {
15
+ /** @type {PlacedPiece[]} */
16
+ const pieces = [
17
+ { type: 'cornerR30', x: 0, y: 0, rot: 90 }, // top-left, ports E + S
18
+ { type: 'cornerR30', x: 5, y: 0, rot: 180 }, // top-right, ports S + W
19
+ { type: 'cornerR30', x: 5, y: 7, rot: 270 }, // bot-right, ports W + N
20
+ { type: 'cornerR30', x: 0, y: 7, rot: 0 }, // bot-left, ports N + E
21
+ ];
22
+ for (const x of [2, 3, 4]) {
23
+ pieces.push({ type: 'straight', x, y: 0, rot: 90 });
24
+ pieces.push({ type: 'straight', x, y: 7, rot: 90 });
25
+ }
26
+ for (const y of [2, 3, 4, 5, 6]) {
27
+ pieces.push({ type: 'straight', x: 0, y, rot: 0 });
28
+ pieces.push({ type: 'straight', x: 5, y, rot: 0 });
29
+ }
30
+ return pieces;
31
+ }
32
+
33
+ // Task 16: the 87033 manual shows an L-shaped layout labelled 2.1 m x 2.7 m
34
+ // (a 7x9 mat bounding box). An earlier analysis in spec 13.2 concluded the
35
+ // published figures (48 mats, 7x9 bounding box, 6 quarter-rounds) could not
36
+ // all hold in one 2-mat-wide L ring at once — that conclusion was wrong; a
37
+ // corrected erosion argument in spec 13.2 shows a construction exists that
38
+ // hits all of them exactly, and this preset (and test/stock-l.test.js) is
39
+ // that construction, verified against the model code. Built from the
40
+ // constraints that ARE certain: a 7x9 outer box with a corner notch cut out
41
+ // to make an L, eroded inward by 2 mats (the track width) to leave the
42
+ // infield hole.
43
+ //
44
+ // The notch has to be at least 2x2 for the reflex corner it creates to be
45
+ // a genuine corner rather than a 1-cell nick (see spec 13.2's "a, b >= 2"
46
+ // reasoning) — and 2x2 is also the notch that maximises the mat count, so
47
+ // that's what's used here. Cutting it from the top-right is an arbitrary
48
+ // choice; any corner gives the same piece counts, just mirrored/rotated.
49
+ //
50
+ // An L ring needs six R30 corners, not four: five convex ones on the
51
+ // outer boundary, plus a sixth where the infield hole's own corner rounds
52
+ // off right next to the notch (the "elbow"). That sixth corner is what
53
+ // supplies the extra quarter-round mat, curve barrier and corner cap the
54
+ // 87033 box ships beyond a plain rectangular ring's four.
55
+ /** @returns {PlacedPiece[]} */
56
+ export function stockL() {
57
+ /** @type {PlacedPiece[]} */
58
+ const pieces = [
59
+ { type: 'cornerR30', x: 0, y: 0, rot: 90 }, // outer top-left, ports E + S
60
+ { type: 'cornerR30', x: 3, y: 0, rot: 180 }, // outer top-right, ports S + W
61
+ { type: 'cornerR30', x: 5, y: 2, rot: 180 }, // outer notch corner, ports S + W
62
+ { type: 'cornerR30', x: 5, y: 7, rot: 270 }, // outer bottom-right, ports W + N
63
+ { type: 'cornerR30', x: 0, y: 7, rot: 0 }, // outer bottom-left, ports N + E
64
+ { type: 'cornerR30', x: 3, y: 2, rot: 0 }, // infield elbow, ports N + E
65
+ ];
66
+ pieces.push({ type: 'straight', x: 2, y: 0, rot: 90 }); // stub joining the two top corners
67
+ for (const y of [2, 3, 4, 5, 6]) pieces.push({ type: 'straight', x: 0, y, rot: 0 });
68
+ for (const y of [4, 5, 6]) pieces.push({ type: 'straight', x: 5, y, rot: 0 });
69
+ for (const x of [2, 3, 4]) pieces.push({ type: 'straight', x, y: 7, rot: 90 });
70
+ return pieces;
71
+ }
package/svg.js ADDED
@@ -0,0 +1,153 @@
1
+ // SPDX-FileCopyrightText: 2026 The TinyLap contributors
2
+ //
3
+ // SPDX-License-Identifier: MPL-2.0
4
+
5
+ import { placedCells } from './geometry.js';
6
+ import { computeBarriers, computeMetrics, findOverlaps } from './model.js';
7
+
8
+ /** @import { Corner, Cell, ArcWall, Layout } from './types.js' */
9
+ /** @typedef {{ mat: string, matEdge: string, wall: string, bg: string }} Theme */
10
+
11
+ export const MAT_PX = 24;
12
+
13
+ /** @type {Record<Corner, (cell: Cell) => Cell>} */
14
+ const CORNER_VERTEX = {
15
+ NE: ([cx, cy]) => [cx + 1, cy],
16
+ SE: ([cx, cy]) => [cx + 1, cy + 1],
17
+ SW: ([cx, cy]) => [cx, cy + 1],
18
+ NW: ([cx, cy]) => [cx, cy],
19
+ };
20
+
21
+ // Same step vectors as CORNER_EDGES in model.js.
22
+ /** @type {Record<Corner, [Cell, Cell]>} */
23
+ const CORNER_STEPS = {
24
+ NE: [[-1, 0], [0, 1]],
25
+ SE: [[-1, 0], [0, -1]],
26
+ SW: [[1, 0], [0, -1]],
27
+ NW: [[1, 0], [0, 1]],
28
+ };
29
+
30
+ // Guards every coordinate interpolated into SVG markup strings. A
31
+ // non-finite value is meaningless geometry, not content to sanitise, so
32
+ // it is rejected outright rather than escaped.
33
+ /** @param {number} value @param {string} label @returns {number} */
34
+ function assertFinite(value, label) {
35
+ if (!Number.isFinite(value)) throw new Error(`Invalid ${label}: ${value}`);
36
+ return value;
37
+ }
38
+
39
+ // Corner vertex, endpoints and sweep flag for a wall's arc.
40
+ //
41
+ // Every arc wall is a convex quarter-round: its centre sits diagonally
42
+ // inward from the corner vertex V at V + r*(s1+s2), so it curves AWAY from
43
+ // V and meets the two adjoining straight walls tangentially. That holds for
44
+ // the R60 inner sweep too (arcLarge): it is centred on the turn's centre,
45
+ // concentric with the corridor's centreline. `sweep` picks the arc direction
46
+ // from the sign of the cross product of the endpoint radii.
47
+ /** @param {ArcWall} wall */
48
+ function arcParts(wall) {
49
+ const { r, cell, corner } = wall;
50
+ const v = CORNER_VERTEX[corner](cell);
51
+ const [s1, s2] = CORNER_STEPS[corner];
52
+ const a = [v[0] + s1[0] * r, v[1] + s1[1] * r];
53
+ const b = [v[0] + s2[0] * r, v[1] + s2[1] * r];
54
+ const c = [v[0] + (s1[0] + s2[0]) * r, v[1] + (s1[1] + s2[1]) * r];
55
+ const cross = (a[0] - c[0]) * (b[1] - c[1]) - (a[1] - c[1]) * (b[0] - c[0]);
56
+ return { v, a, b, r, sweep: cross > 0 ? 1 : 0 };
57
+ }
58
+
59
+ // The region a cut removes: corner vertex -> first endpoint -> along the
60
+ // arc -> back to the vertex. Painted in the canvas colour over the mats so
61
+ // a cut corner reads as cut. Without this every arc was drawn as a stroke
62
+ // lying across an intact square, which is what made the large corner look
63
+ // like it was missing a tile rather than swept.
64
+ /** @param {ArcWall} wall @returns {string} */
65
+ export function cutPath(wall) {
66
+ const { v, a, b, r, sweep } = arcParts(wall);
67
+ // Remove the sliver between the corner vertex and the arc.
68
+ return `M ${v[0]} ${v[1]} L ${a[0]} ${a[1]} A ${r} ${r} 0 0 ${sweep} ${b[0]} ${b[1]} Z`;
69
+ }
70
+
71
+ /** @param {ArcWall} wall @returns {string} */
72
+ export function arcPath(wall) {
73
+ const { r, cell, corner } = wall;
74
+ const [vx, vy] = CORNER_VERTEX[corner](cell);
75
+ const [s1, s2] = CORNER_STEPS[corner];
76
+ const a = [vx + s1[0] * r, vy + s1[1] * r];
77
+ const b = [vx + s2[0] * r, vy + s2[1] * r];
78
+ const c = [vx + (s1[0] + s2[0]) * r, vy + (s1[1] + s2[1]) * r];
79
+ const cross = (a[0] - c[0]) * (b[1] - c[1]) - (a[1] - c[1]) * (b[0] - c[0]);
80
+ const sweep = cross > 0 ? 1 : 0;
81
+ const ax = assertFinite(a[0], 'arc coordinate');
82
+ const ay = assertFinite(a[1], 'arc coordinate');
83
+ const rr = assertFinite(r, 'arc radius');
84
+ const bx = assertFinite(b[0], 'arc coordinate');
85
+ const by = assertFinite(b[1], 'arc coordinate');
86
+ return `M ${ax} ${ay} A ${rr} ${rr} 0 0 ${sweep} ${bx} ${by}`;
87
+ }
88
+
89
+ /** @param {string} id */
90
+ function edgeLine(id) {
91
+ const [axis, x, y] = id.split(':');
92
+ const [ix, iy] = [Number(x), Number(y)];
93
+ return axis === 'H'
94
+ ? { x1: ix, y1: iy, x2: ix + 1, y2: iy }
95
+ : { x1: ix, y1: iy, x2: ix, y2: iy + 1 };
96
+ }
97
+
98
+ /** @param {Layout} layout */
99
+ export function layoutGeometry(layout) {
100
+ const { straightEdgeIds, arcs } = computeBarriers(layout);
101
+ const { bbox } = computeMetrics(layout);
102
+ const overlaps = new Set(findOverlaps(layout));
103
+
104
+ /** @type {{ x: number, y: number, pieceIndex: number, overlapping: boolean }[]} */
105
+ const mats = [];
106
+ layout.pieces.forEach((piece, index) => {
107
+ for (const cell of placedCells(piece)) {
108
+ mats.push({ x: cell[0], y: cell[1], pieceIndex: index, overlapping: overlaps.has(`${cell[0]},${cell[1]}`) });
109
+ }
110
+ });
111
+
112
+ return {
113
+ mats,
114
+ lines: straightEdgeIds.map(edgeLine),
115
+ arcs: arcs.map((w) => ({ d: arcPath(w), kind: w.kind })),
116
+ cuts: arcs.map((w) => cutPath(w)),
117
+ bbox: bbox ?? { x: 0, y: 0, w: 0, h: 0 },
118
+ };
119
+ }
120
+
121
+ // Two palettes. `dark` matches the on-screen canvas and is what the
122
+ // standalone export uses. `light` is for thumbnails, which sit on white
123
+ // cards in the palette and preset gallery — dark-on-dark reads as mud at
124
+ // 3rem, so those invert to palace's dark-on-light.
125
+ // dark repeats @tinylap/design's --tl-track-* values; SVG exports cannot read CSS variables.
126
+ /** @type {Record<string, Theme>} */
127
+ const THEMES = {
128
+ dark: { mat: '#262626', matEdge: '#3a3a3a', wall: '#ffffff', bg: '#0a0a0a' },
129
+ light: { mat: '#d9d9d9', matEdge: '#bdbdbd', wall: '#0a0a0a', bg: '#f7f7f7' },
130
+ };
131
+
132
+ /** @param {Layout} layout @param {{ pad?: number, theme?: string }} [options] @returns {string} */
133
+ export function layoutToSvgString(layout, { pad = 1, theme = 'dark' } = {}) {
134
+ const t = THEMES[theme] ?? THEMES.dark;
135
+ const g = layoutGeometry(layout);
136
+ const vb = [
137
+ assertFinite(g.bbox.x - pad, 'viewBox x'),
138
+ assertFinite(g.bbox.y - pad, 'viewBox y'),
139
+ assertFinite(g.bbox.w + pad * 2, 'viewBox width'),
140
+ assertFinite(g.bbox.h + pad * 2, 'viewBox height'),
141
+ ];
142
+ const mats = g.mats
143
+ .map((m) => `<rect x="${assertFinite(m.x, 'mat x')}" y="${assertFinite(m.y, 'mat y')}" width="1" height="1" fill="${t.mat}" stroke="${t.matEdge}" stroke-width="0.03"/>`)
144
+ .join('');
145
+ const lines = g.lines
146
+ .map((l) => `<line x1="${assertFinite(l.x1, 'line x1')}" y1="${assertFinite(l.y1, 'line y1')}" x2="${assertFinite(l.x2, 'line x2')}" y2="${assertFinite(l.y2, 'line y2')}" stroke="${t.wall}" stroke-width="0.16" stroke-linecap="square"/>`)
147
+ .join('');
148
+ const cuts = g.cuts.map((d) => `<path d="${d}" fill="${t.bg}"/>`).join('');
149
+ const arcs = g.arcs
150
+ .map((a) => `<path d="${a.d}" fill="none" stroke="${t.wall}" stroke-width="0.16" stroke-linecap="square"/>`)
151
+ .join('');
152
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${vb.join(' ')}" width="${vb[2] * MAT_PX}" height="${vb[3] * MAT_PX}"><rect x="${vb[0]}" y="${vb[1]}" width="${vb[2]}" height="${vb[3]}" fill="${t.bg}"/>${mats}${cuts}${lines}${arcs}</svg>`;
153
+ }
package/types.d.ts ADDED
@@ -0,0 +1,49 @@
1
+ // The TinyLap track data model. Cells are [col, row], with rows increasing
2
+ // downward like SVG; one cell is one 300 mm Circuit 30 mat.
3
+
4
+ export type Dir = 'N' | 'E' | 'S' | 'W';
5
+ export type Corner = 'NE' | 'SE' | 'SW' | 'NW';
6
+ /** Clockwise on screen. layoutFromData refuses anything else. */
7
+ export type Rotation = 0 | 90 | 180 | 270;
8
+ export type Cell = [number, number];
9
+ export type PieceType = 'straight' | 'cornerR30' | 'cornerR60';
10
+
11
+ export interface Port { cells: Cell[]; dir: Dir }
12
+
13
+ export interface CapWall { kind: 'cap'; vertex: Cell }
14
+ export interface ArcWall { kind: 'arc' | 'arcLarge'; r: number; cell: Cell; corner: Corner }
15
+ export type Wall = CapWall | ArcWall;
16
+
17
+ export interface MatCounts { square?: number; quarterRound?: number; arcLarge?: number }
18
+
19
+ export interface PieceDef {
20
+ label: string;
21
+ cells: Cell[];
22
+ ports: Port[];
23
+ walls: Wall[];
24
+ mats: MatCounts;
25
+ centrelineMm: number;
26
+ }
27
+
28
+ export interface PlacedPiece { type: PieceType; x: number; y: number; rot: Rotation }
29
+ export interface Room { w: number; h: number }
30
+
31
+ /** The persisted layout, format version 1. */
32
+ export interface Layout {
33
+ version: 1;
34
+ unit: 'mat';
35
+ room: Room | null;
36
+ pieces: PlacedPiece[];
37
+ }
38
+
39
+ export interface BBox { x: number; y: number; w: number; h: number }
40
+
41
+ export interface Metrics {
42
+ bbox: BBox | null;
43
+ matCount: number;
44
+ mats: Record<string, number>;
45
+ lapMm: number;
46
+ }
47
+
48
+ export interface BarrierCounts { straight: number; curve: number; curveLarge: number; cornerCap: number }
49
+ export interface Barriers { counts: BarrierCounts; straightEdgeIds: string[]; arcs: ArcWall[] }