@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.
- package/LICENSE +373 -0
- package/README.md +25 -2
- package/data.js +55 -0
- package/dist/data.d.ts +9 -0
- package/dist/geometry.d.ts +24 -0
- package/dist/index.d.ts +41 -0
- package/dist/model.d.ts +58 -0
- package/dist/reference-layouts.d.ts +6 -0
- package/dist/svg.d.ts +40 -0
- package/dist/types.d.ts +49 -0
- package/geometry.js +184 -0
- package/index.js +29 -0
- package/model.js +634 -0
- package/package.json +12 -4
- package/reference-layouts.js +71 -0
- package/svg.js +153 -0
- package/types.d.ts +49 -0
|
@@ -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[] }
|