@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
package/model.js
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 The TinyLap contributors
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
|
|
5
|
+
import { placedCells, placedPorts, placedWalls, rotatedDef } from './geometry.js';
|
|
6
|
+
|
|
7
|
+
/** @import { Dir, Corner, Rotation, Cell, PieceType, Port, ArcWall, PlacedPiece, Layout, Metrics, Barriers, BarrierCounts } from './types.js' */
|
|
8
|
+
/** @typedef {{ pieceIndex: number, port: Port }} PortEntry */
|
|
9
|
+
|
|
10
|
+
/** @param {Partial<Layout>} [overrides] @returns {Layout} */
|
|
11
|
+
export function createLayout(overrides = {}) {
|
|
12
|
+
return { version: 1, unit: 'mat', room: null, pieces: [], ...overrides };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** @param {Cell} cell @returns {string} */
|
|
16
|
+
export const cellKey = ([x, y]) => `${x},${y}`;
|
|
17
|
+
/** @param {string} k @returns {Cell} */
|
|
18
|
+
export const parseCellKey = (k) => /** @type {Cell} */ (k.split(',').map(Number));
|
|
19
|
+
|
|
20
|
+
/** @param {Layout} layout @returns {Map<string, number[]>} */
|
|
21
|
+
export function occupancy(layout) {
|
|
22
|
+
/** @type {Map<string, number[]>} */
|
|
23
|
+
const map = new Map();
|
|
24
|
+
layout.pieces.forEach((piece, index) => {
|
|
25
|
+
for (const cell of placedCells(piece)) {
|
|
26
|
+
const key = cellKey(cell);
|
|
27
|
+
const owners = map.get(key);
|
|
28
|
+
if (owners) owners.push(index);
|
|
29
|
+
else map.set(key, [index]);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
return map;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @param {Layout} layout @returns {Set<string>} */
|
|
36
|
+
export function occupiedSet(layout) {
|
|
37
|
+
return new Set(occupancy(layout).keys());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @param {Layout} layout @returns {string[]} */
|
|
41
|
+
export function findOverlaps(layout) {
|
|
42
|
+
return [...occupancy(layout)]
|
|
43
|
+
.filter(([, owners]) => owners.length > 1)
|
|
44
|
+
.map(([key]) => key);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @param {Cell} cell @param {Dir} dir @returns {string} */
|
|
48
|
+
export function edgeId([x, y], dir) {
|
|
49
|
+
switch (dir) {
|
|
50
|
+
case 'N': return `H:${x}:${y}`;
|
|
51
|
+
case 'S': return `H:${x}:${y + 1}`;
|
|
52
|
+
case 'W': return `V:${x}:${y}`;
|
|
53
|
+
case 'E': return `V:${x + 1}:${y}`;
|
|
54
|
+
default: throw new Error(`bad direction: ${dir}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @param {Port} port @returns {string[]} */
|
|
59
|
+
export const portEdgeIds = (port) => port.cells.map((c) => edgeId(c, port.dir));
|
|
60
|
+
/** @param {Port} port @returns {string} */
|
|
61
|
+
export const portKey = (port) => portEdgeIds(port).slice().sort().join('|');
|
|
62
|
+
|
|
63
|
+
/** @type {Record<Dir, Dir>} */
|
|
64
|
+
const OPPOSITE = { N: 'S', S: 'N', E: 'W', W: 'E' };
|
|
65
|
+
|
|
66
|
+
/** @param {Layout} layout @returns {{ matched: Set<string>, open: PortEntry[] }} */
|
|
67
|
+
export function matchPorts(layout) {
|
|
68
|
+
/** @type {Map<string, PortEntry[]>} */
|
|
69
|
+
const byKey = new Map();
|
|
70
|
+
layout.pieces.forEach((piece, pieceIndex) => {
|
|
71
|
+
for (const port of placedPorts(piece)) {
|
|
72
|
+
const key = portKey(port);
|
|
73
|
+
const bucket = byKey.get(key);
|
|
74
|
+
if (bucket) bucket.push({ pieceIndex, port });
|
|
75
|
+
else byKey.set(key, [{ pieceIndex, port }]);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/** @type {Set<string>} */
|
|
80
|
+
const matched = new Set();
|
|
81
|
+
/** @type {PortEntry[]} */
|
|
82
|
+
const open = [];
|
|
83
|
+
for (const [key, entries] of byKey) {
|
|
84
|
+
const paired =
|
|
85
|
+
entries.length === 2 && entries[0].port.dir === OPPOSITE[entries[1].port.dir];
|
|
86
|
+
if (paired) matched.add(key);
|
|
87
|
+
else open.push(...entries);
|
|
88
|
+
}
|
|
89
|
+
return { matched, open };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Edges of unmatched ports. These are openings, not walls, so the
|
|
93
|
+
// boundary walk must not put a barrier on them.
|
|
94
|
+
/** @param {Layout} layout @returns {Set<string>} */
|
|
95
|
+
export function openEdgeIds(layout) {
|
|
96
|
+
/** @type {Set<string>} */
|
|
97
|
+
const ids = new Set();
|
|
98
|
+
for (const { port } of matchPorts(layout).open) {
|
|
99
|
+
for (const id of portEdgeIds(port)) ids.add(id);
|
|
100
|
+
}
|
|
101
|
+
return ids;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** @param {Layout} layout @returns {boolean} */
|
|
105
|
+
export function isClosedCircuit(layout) {
|
|
106
|
+
if (layout.pieces.length === 0) return false;
|
|
107
|
+
if (findOverlaps(layout).length > 0) return false;
|
|
108
|
+
if (matchPorts(layout).open.length > 0) return false;
|
|
109
|
+
return isSingleComponent(layout);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** @param {Layout} layout @returns {boolean} */
|
|
113
|
+
function isSingleComponent(layout) {
|
|
114
|
+
const cells = occupiedSet(layout);
|
|
115
|
+
const first = cells.values().next().value;
|
|
116
|
+
if (!first) return false;
|
|
117
|
+
const seen = new Set([first]);
|
|
118
|
+
const queue = [parseCellKey(first)];
|
|
119
|
+
while (queue.length > 0) {
|
|
120
|
+
const [x, y] = /** @type {Cell} */ (queue.pop());
|
|
121
|
+
for (const [nx, ny] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]) {
|
|
122
|
+
const key = cellKey([nx, ny]);
|
|
123
|
+
if (cells.has(key) && !seen.has(key)) {
|
|
124
|
+
seen.add(key);
|
|
125
|
+
queue.push([nx, ny]);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return seen.size === cells.size;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** @type {Record<Dir, Cell>} */
|
|
133
|
+
export const NEIGHBOUR = { N: [0, -1], E: [1, 0], S: [0, 1], W: [-1, 0] };
|
|
134
|
+
|
|
135
|
+
// For each corner, the two edges meeting there and the direction each
|
|
136
|
+
// one runs as you walk away from the corner.
|
|
137
|
+
/** @type {Record<Corner, [Dir, Cell][]>} */
|
|
138
|
+
const CORNER_EDGES = {
|
|
139
|
+
NE: [['N', [-1, 0]], ['E', [0, 1]]],
|
|
140
|
+
SE: [['S', [-1, 0]], ['E', [0, -1]]],
|
|
141
|
+
SW: [['S', [1, 0]], ['W', [0, -1]]],
|
|
142
|
+
NW: [['N', [1, 0]], ['W', [0, 1]]],
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// How many walls each edge carries, counted per piece-side.
|
|
146
|
+
//
|
|
147
|
+
// Barriers belong to the PIECES, not to the outline of the mats. A piece
|
|
148
|
+
// walls every side of itself that is not one of its own ports, whatever is
|
|
149
|
+
// beyond it. So:
|
|
150
|
+
// - outline edge -> 1 (the one piece facing it)
|
|
151
|
+
// - two pieces joined by a port -> 0 (both sides are openings)
|
|
152
|
+
// - two pieces merely touching -> 2 (each mounts its own wall on its
|
|
153
|
+
// own tile; there is no shared
|
|
154
|
+
// barrier — see spec 2.1)
|
|
155
|
+
// - one piece's open port abutted
|
|
156
|
+
// by another piece's flank -> 1 (an opening one side, wall the other)
|
|
157
|
+
//
|
|
158
|
+
// That last case is why this counts per side rather than testing "is this
|
|
159
|
+
// a port edge" globally: the same edge can be an opening for one piece and
|
|
160
|
+
// a wall for its neighbour. Getting it wrong swallowed real walls and
|
|
161
|
+
// invented others.
|
|
162
|
+
/** @param {Layout} layout @returns {Map<string, number>} */
|
|
163
|
+
export function wallCounts(layout) {
|
|
164
|
+
const occ = occupancy(layout);
|
|
165
|
+
|
|
166
|
+
// Each piece's own port edges are openings in THAT piece's wall, whether
|
|
167
|
+
// the port is matched or not. Keeping them per piece is the whole point:
|
|
168
|
+
// an edge can be one piece's open port and another piece's plain outside
|
|
169
|
+
// wall at the same time, and then it carries exactly one wall, not none
|
|
170
|
+
// and not two. Treating "is a port edge" as a global property got this
|
|
171
|
+
// wrong in both directions.
|
|
172
|
+
const portIdsByPiece = layout.pieces.map((piece) => {
|
|
173
|
+
/** @type {Set<string>} */
|
|
174
|
+
const ids = new Set();
|
|
175
|
+
for (const port of placedPorts(piece)) {
|
|
176
|
+
for (const id of portEdgeIds(port)) ids.add(id);
|
|
177
|
+
}
|
|
178
|
+
return ids;
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
/** @type {Map<string, number>} */
|
|
182
|
+
const counts = new Map();
|
|
183
|
+
layout.pieces.forEach((piece, index) => {
|
|
184
|
+
const ownPorts = portIdsByPiece[index];
|
|
185
|
+
for (const cell of placedCells(piece)) {
|
|
186
|
+
const [x, y] = cell;
|
|
187
|
+
for (const dir of /** @type {Dir[]} */ (['N', 'E', 'S', 'W'])) {
|
|
188
|
+
const [dx, dy] = NEIGHBOUR[dir];
|
|
189
|
+
const nOwners = occ.get(cellKey([x + dx, y + dy]));
|
|
190
|
+
if (nOwners && nOwners.includes(index)) continue; // inside this piece
|
|
191
|
+
const id = edgeId([x, y], dir);
|
|
192
|
+
if (ownPorts.has(id)) continue; // this piece's opening
|
|
193
|
+
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
return counts;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** @param {Layout} layout @returns {Set<string>} */
|
|
201
|
+
export function boundaryEdges(layout) {
|
|
202
|
+
return new Set(wallCounts(layout).keys());
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Shared setup for enclosedInfields and maxTrackDepth: flood-fill the empty
|
|
206
|
+
// complement of the occupied cells outward from a corner of a one-cell
|
|
207
|
+
// margin around the bounding box. Everything the flood reaches is "outside"
|
|
208
|
+
// — connected to the open edge of the layout; everything empty it never
|
|
209
|
+
// reaches is boxed in by occupied cells on every side. Returns null for an
|
|
210
|
+
// empty layout (nothing to flood).
|
|
211
|
+
/** @param {Layout} layout */
|
|
212
|
+
function floodOutsideMargin(layout) {
|
|
213
|
+
const cells = occupiedSet(layout);
|
|
214
|
+
if (cells.size === 0) return null;
|
|
215
|
+
|
|
216
|
+
const points = [...cells].map(parseCellKey);
|
|
217
|
+
const minX = Math.min(...points.map(([x]) => x)) - 1;
|
|
218
|
+
const maxX = Math.max(...points.map(([x]) => x)) + 1;
|
|
219
|
+
const minY = Math.min(...points.map(([, y]) => y)) - 1;
|
|
220
|
+
const maxY = Math.max(...points.map(([, y]) => y)) + 1;
|
|
221
|
+
/** @param {Cell} cell */
|
|
222
|
+
const inBounds = ([x, y]) => x >= minX && x <= maxX && y >= minY && y <= maxY;
|
|
223
|
+
/** @param {Cell} cell @returns {Cell[]} */
|
|
224
|
+
const emptyNeighbours = ([x, y]) =>
|
|
225
|
+
Object.values(NEIGHBOUR)
|
|
226
|
+
.map(([dx, dy]) => /** @type {Cell} */ ([x + dx, y + dy]))
|
|
227
|
+
.filter((n) => inBounds(n) && !cells.has(cellKey(n)));
|
|
228
|
+
|
|
229
|
+
// Flood outward from a corner of the margin — guaranteed empty, and
|
|
230
|
+
// guaranteed connected to every other margin cell round the outside.
|
|
231
|
+
const outside = new Set([cellKey([minX, minY])]);
|
|
232
|
+
/** @type {Cell[]} */
|
|
233
|
+
const queue = [[minX, minY]];
|
|
234
|
+
while (queue.length > 0) {
|
|
235
|
+
const cell = /** @type {Cell} */ (queue.pop());
|
|
236
|
+
for (const n of emptyNeighbours(cell)) {
|
|
237
|
+
const key = cellKey(n);
|
|
238
|
+
if (outside.has(key)) continue;
|
|
239
|
+
outside.add(key);
|
|
240
|
+
queue.push(n);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return { cells, minX, maxX, minY, maxY, outside, emptyNeighbours };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// The filled outline: every cell in the margined bounding box that
|
|
248
|
+
// floodOutsideMargin's flood never reached, i.e. the occupied cells plus
|
|
249
|
+
// everything they enclose (the infield). Shared by maxTrackDepth and
|
|
250
|
+
// subWidthNotches — both reason about the SAME filled shape, one by
|
|
251
|
+
// Chebyshev depth, the other by morphological closing.
|
|
252
|
+
/** @param {{ minX: number, maxX: number, minY: number, maxY: number, outside: Set<string> }} flood @returns {Set<string>} */
|
|
253
|
+
function filledOutline({ minX, maxX, minY, maxY, outside }) {
|
|
254
|
+
/** @type {Set<string>} */
|
|
255
|
+
const R = new Set();
|
|
256
|
+
for (let x = minX; x <= maxX; x++) {
|
|
257
|
+
for (let y = minY; y <= maxY; y++) {
|
|
258
|
+
const key = cellKey([x, y]);
|
|
259
|
+
if (!outside.has(key)) R.add(key);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return R;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// How many separate enclosed empty regions the occupied cells surround.
|
|
266
|
+
// Port-closed and overlap-free is not enough to be a real track: a solid
|
|
267
|
+
// block of mats with a wall round the outside satisfies both while
|
|
268
|
+
// enclosing nothing to drive around. Every empty region floodOutsideMargin's
|
|
269
|
+
// flood never reaches is cut off by the occupied cells on all sides, i.e.
|
|
270
|
+
// an enclosed infield.
|
|
271
|
+
/** @param {Layout} layout @returns {number} */
|
|
272
|
+
export function enclosedInfields(layout) {
|
|
273
|
+
const flood = floodOutsideMargin(layout);
|
|
274
|
+
if (!flood) return 0;
|
|
275
|
+
const { cells, minX, maxX, minY, maxY, outside, emptyNeighbours } = flood;
|
|
276
|
+
|
|
277
|
+
// Count connected components among the empty cells the outside flood
|
|
278
|
+
// never reached — each one is a distinct region walled off on every side.
|
|
279
|
+
/** @type {Set<string>} */
|
|
280
|
+
const visited = new Set();
|
|
281
|
+
let infields = 0;
|
|
282
|
+
for (let x = minX; x <= maxX; x++) {
|
|
283
|
+
for (let y = minY; y <= maxY; y++) {
|
|
284
|
+
const key = cellKey([x, y]);
|
|
285
|
+
if (cells.has(key) || outside.has(key) || visited.has(key)) continue;
|
|
286
|
+
infields += 1;
|
|
287
|
+
visited.add(key);
|
|
288
|
+
/** @type {Cell[]} */
|
|
289
|
+
const componentQueue = [[x, y]];
|
|
290
|
+
while (componentQueue.length > 0) {
|
|
291
|
+
const cell = /** @type {Cell} */ (componentQueue.pop());
|
|
292
|
+
for (const n of emptyNeighbours(cell)) {
|
|
293
|
+
const nKey = cellKey(n);
|
|
294
|
+
if (outside.has(nKey) || visited.has(nKey)) continue;
|
|
295
|
+
visited.add(nKey);
|
|
296
|
+
componentQueue.push(n);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return infields;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Every shared edge between two occupied cells belonging to different
|
|
305
|
+
// pieces that is NOT a matched-port join. Barriers belong to the pieces
|
|
306
|
+
// themselves — a straight carries a barrier down each long side — so where
|
|
307
|
+
// two pieces are edge-adjacent without actually connecting through a
|
|
308
|
+
// matched port, that edge should have carried a barrier but the outline
|
|
309
|
+
// walk (boundaryEdges/computeBarriers) never sees it: both cells are
|
|
310
|
+
// occupied, so the edge is interior, and the two lanes silently merge. A
|
|
311
|
+
// loop that runs back alongside itself produces exactly this: it can still
|
|
312
|
+
// enclose one infield and stay within two mats of its own edge in places,
|
|
313
|
+
// while two of its own straights rest side by side with no wall between
|
|
314
|
+
// them.
|
|
315
|
+
//
|
|
316
|
+
// Cells belonging to the SAME piece touching each other is normal (e.g. an
|
|
317
|
+
// R30 corner's own two cells) and is never reported. Returns an empty
|
|
318
|
+
// array for a valid track.
|
|
319
|
+
/** @param {Layout} layout @returns {string[]} */
|
|
320
|
+
export function unjoinedContacts(layout) {
|
|
321
|
+
const occ = occupancy(layout);
|
|
322
|
+
const { matched } = matchPorts(layout);
|
|
323
|
+
/** @type {Set<string>} */
|
|
324
|
+
const legit = new Set();
|
|
325
|
+
layout.pieces.forEach((piece) => {
|
|
326
|
+
for (const port of placedPorts(piece)) {
|
|
327
|
+
if (!matched.has(portKey(port))) continue;
|
|
328
|
+
for (const id of portEdgeIds(port)) legit.add(id);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
/** @type {Set<string>} */
|
|
333
|
+
const contacts = new Set();
|
|
334
|
+
for (const [key, owners] of occ) {
|
|
335
|
+
const [x, y] = parseCellKey(key);
|
|
336
|
+
for (const dir of /** @type {Dir[]} */ (['N', 'E', 'S', 'W'])) {
|
|
337
|
+
const [dx, dy] = NEIGHBOUR[dir];
|
|
338
|
+
const nOwners = occ.get(cellKey([x + dx, y + dy]));
|
|
339
|
+
if (!nOwners) continue;
|
|
340
|
+
if (owners.some((o) => nOwners.includes(o))) continue; // same piece
|
|
341
|
+
const id = edgeId([x, y], dir);
|
|
342
|
+
if (legit.has(id)) continue;
|
|
343
|
+
contacts.add(id);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return [...contacts];
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// The 8-neighbourhood (including diagonals) — Chebyshev-adjacent cells.
|
|
350
|
+
/** @type {Cell[]} */
|
|
351
|
+
const NEIGHBOURS8 = [
|
|
352
|
+
[-1, -1], [0, -1], [1, -1],
|
|
353
|
+
[-1, 0], [1, 0],
|
|
354
|
+
[-1, 1], [0, 1], [1, 1],
|
|
355
|
+
];
|
|
356
|
+
|
|
357
|
+
// How deep the thickest part of the track is, measured inward from the
|
|
358
|
+
// edge of its own outline. Let R be the filled outline: the occupied cells
|
|
359
|
+
// plus everything they enclose (the infield(s) floodOutsideMargin's flood
|
|
360
|
+
// never reaches). Give every cell of R its Chebyshev distance to the
|
|
361
|
+
// nearest cell outside R — 1 on the edge, 2 the next ring in, and so on.
|
|
362
|
+
// A real two-mat-wide track is exactly the cells of R at depth 1 or 2; a
|
|
363
|
+
// depth of 3 or more means the track is wider than two mats there. Two
|
|
364
|
+
// non-consecutive straight runs sitting side by side pass the infield
|
|
365
|
+
// check (there is still exactly one hole) but fail this one.
|
|
366
|
+
/** @param {Layout} layout @returns {number} */
|
|
367
|
+
export function maxTrackDepth(layout) {
|
|
368
|
+
const flood = floodOutsideMargin(layout);
|
|
369
|
+
if (!flood) return 0;
|
|
370
|
+
const { cells } = flood;
|
|
371
|
+
const R = filledOutline(flood);
|
|
372
|
+
|
|
373
|
+
// Multi-source BFS inward, over the 8-neighbourhood, starting from every
|
|
374
|
+
// cell of R that touches a non-R cell (depth 1).
|
|
375
|
+
/** @type {Map<string, number>} */
|
|
376
|
+
const depth = new Map();
|
|
377
|
+
/** @type {string[]} */
|
|
378
|
+
const queue = [];
|
|
379
|
+
for (const key of R) {
|
|
380
|
+
const [x, y] = parseCellKey(key);
|
|
381
|
+
const onEdge = NEIGHBOURS8.some(([dx, dy]) => !R.has(cellKey([x + dx, y + dy])));
|
|
382
|
+
if (onEdge) {
|
|
383
|
+
depth.set(key, 1);
|
|
384
|
+
queue.push(key);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
let head = 0;
|
|
388
|
+
while (head < queue.length) {
|
|
389
|
+
const key = queue[head++];
|
|
390
|
+
const d = /** @type {number} */ (depth.get(key));
|
|
391
|
+
const [x, y] = parseCellKey(key);
|
|
392
|
+
for (const [dx, dy] of NEIGHBOURS8) {
|
|
393
|
+
const nKey = cellKey([x + dx, y + dy]);
|
|
394
|
+
if (R.has(nKey) && !depth.has(nKey)) {
|
|
395
|
+
depth.set(nKey, d + 1);
|
|
396
|
+
queue.push(nKey);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
let max = 0;
|
|
402
|
+
for (const key of cells) max = Math.max(max, depth.get(key) ?? 0);
|
|
403
|
+
return max;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Shift every cell of `cells` by each offset in `offsets` and union the
|
|
407
|
+
// results — the set-based form of dilating `cells` by the structuring
|
|
408
|
+
// element `offsets` (a Minkowski sum). Unbounded: callers never need to
|
|
409
|
+
// pre-size a grid, since only cells that could plausibly be touched are
|
|
410
|
+
// ever visited.
|
|
411
|
+
/** @param {Set<string>} cells @param {Cell[]} offsets @returns {Set<string>} */
|
|
412
|
+
function dilateBy(cells, offsets) {
|
|
413
|
+
/** @type {Set<string>} */
|
|
414
|
+
const out = new Set();
|
|
415
|
+
for (const key of cells) {
|
|
416
|
+
const [x, y] = parseCellKey(key);
|
|
417
|
+
for (const [dx, dy] of offsets) out.add(cellKey([x + dx, y + dy]));
|
|
418
|
+
}
|
|
419
|
+
return out;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Erode `cells` by the structuring element `offsets`: a point survives only
|
|
423
|
+
// if every offset shift of it lands back in `cells`. Computed by counting,
|
|
424
|
+
// for each candidate point, how many of the offsets it satisfies — cheaper
|
|
425
|
+
// than testing every point of an unbounded plane against `cells` directly.
|
|
426
|
+
/** @param {Set<string>} cells @param {Cell[]} offsets @returns {Set<string>} */
|
|
427
|
+
function erodeBy(cells, offsets) {
|
|
428
|
+
/** @type {Map<string, number>} */
|
|
429
|
+
const counts = new Map();
|
|
430
|
+
for (const key of cells) {
|
|
431
|
+
const [x, y] = parseCellKey(key);
|
|
432
|
+
for (const [dx, dy] of offsets) {
|
|
433
|
+
const point = cellKey([x - dx, y - dy]);
|
|
434
|
+
counts.set(point, (counts.get(point) ?? 0) + 1);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
/** @type {Set<string>} */
|
|
438
|
+
const out = new Set();
|
|
439
|
+
for (const [key, count] of counts) if (count === offsets.length) out.add(key);
|
|
440
|
+
return out;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// A 2x2 square, the structuring element subWidthNotches closes with. It
|
|
444
|
+
// contains its own origin (0,0), which is what makes the closing below
|
|
445
|
+
// extensive (R is always a subset of its own closing) — so "cells the
|
|
446
|
+
// closing added" is always well-defined as closing \ R, never the reverse.
|
|
447
|
+
/** @type {Cell[]} */
|
|
448
|
+
const SQUARE_2X2 = [[0, 0], [1, 0], [0, 1], [1, 1]];
|
|
449
|
+
|
|
450
|
+
// How many sub-width notches are cut into the outline: slots narrower than
|
|
451
|
+
// the two-mat track width that a driver cannot actually use — what made an
|
|
452
|
+
// "8-corner shape 3" suggestion look like a rectangle with a pimple.
|
|
453
|
+
//
|
|
454
|
+
// Measured as a morphological CLOSING (dilate then erode) of the filled
|
|
455
|
+
// outline R — the same occupied-plus-enclosed-infield shape maxTrackDepth
|
|
456
|
+
// computes — by a 2x2 square: closing fills in any gap too narrow for the
|
|
457
|
+
// square to sit inside fully, so any cell the closing gains that R did not
|
|
458
|
+
// already have was part of a gap narrower than two mats.
|
|
459
|
+
//
|
|
460
|
+
// Deliberately CLOSING, not opening. Opening (erode then dilate) detects
|
|
461
|
+
// thin TRACK, and this track is exactly two mats wide everywhere by
|
|
462
|
+
// construction — an earlier attempt at this filter used opening and it
|
|
463
|
+
// passed 100% of generated layouts, calibration included, because nothing
|
|
464
|
+
// here is ever thin FOREGROUND. Closing detects thin GAPS instead, which is
|
|
465
|
+
// what a sub-width notch actually is: two lobes of the outline pinched
|
|
466
|
+
// together across a gap too narrow to be real track.
|
|
467
|
+
//
|
|
468
|
+
// Counted as connected regions of the gained cells (4-connectivity), not a
|
|
469
|
+
// raw cell count — the same "count regions, not cells" shape as
|
|
470
|
+
// enclosedInfields — so one wide notch does not outweigh two separate
|
|
471
|
+
// narrow ones purely by area.
|
|
472
|
+
//
|
|
473
|
+
// Calibration: both stockL() and rectangularRing() — real, physically-built
|
|
474
|
+
// layouts — score 0. At two starter kits (stockFromKits({'87033':2})),
|
|
475
|
+
// this catches 50 of the 300 layouts the contact rule alone used to let
|
|
476
|
+
// through.
|
|
477
|
+
/** @param {Layout} layout @returns {number} */
|
|
478
|
+
export function subWidthNotches(layout) {
|
|
479
|
+
const flood = floodOutsideMargin(layout);
|
|
480
|
+
if (!flood) return 0;
|
|
481
|
+
const R = filledOutline(flood);
|
|
482
|
+
|
|
483
|
+
const closed = erodeBy(dilateBy(R, SQUARE_2X2), SQUARE_2X2);
|
|
484
|
+
const gained = new Set([...closed].filter((key) => !R.has(key)));
|
|
485
|
+
|
|
486
|
+
// Connected components of `gained`, 4-connectivity — the same
|
|
487
|
+
// flood-fill-and-count shape enclosedInfields uses for its regions.
|
|
488
|
+
/** @type {Set<string>} */
|
|
489
|
+
const visited = new Set();
|
|
490
|
+
let notches = 0;
|
|
491
|
+
for (const start of gained) {
|
|
492
|
+
if (visited.has(start)) continue;
|
|
493
|
+
notches += 1;
|
|
494
|
+
visited.add(start);
|
|
495
|
+
const queue = [parseCellKey(start)];
|
|
496
|
+
while (queue.length > 0) {
|
|
497
|
+
const [x, y] = /** @type {Cell} */ (queue.pop());
|
|
498
|
+
for (const [dx, dy] of Object.values(NEIGHBOUR)) {
|
|
499
|
+
const nKey = cellKey([x + dx, y + dy]);
|
|
500
|
+
if (gained.has(nKey) && !visited.has(nKey)) {
|
|
501
|
+
visited.add(nKey);
|
|
502
|
+
queue.push([x + dx, y + dy]);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return notches;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** @param {ArcWall} wall @returns {string[]} */
|
|
511
|
+
export function arcEdgeIds(wall) {
|
|
512
|
+
/** @type {string[]} */
|
|
513
|
+
const ids = [];
|
|
514
|
+
for (const [dir, [sx, sy]] of CORNER_EDGES[wall.corner]) {
|
|
515
|
+
for (let i = 0; i < wall.r; i++) {
|
|
516
|
+
ids.push(edgeId([wall.cell[0] + sx * i, wall.cell[1] + sy * i], dir));
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return ids;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// A cap belongs at a concave corner: three of the four cells around the
|
|
523
|
+
// vertex occupied, one empty.
|
|
524
|
+
/** @param {Set<string>} cells @param {Cell} vertex @returns {boolean} */
|
|
525
|
+
function isConcaveVertex(cells, [vx, vy]) {
|
|
526
|
+
/** @type {Cell[]} */
|
|
527
|
+
const around = [[vx - 1, vy - 1], [vx, vy - 1], [vx - 1, vy], [vx, vy]];
|
|
528
|
+
return around.filter((c) => cells.has(cellKey(c))).length === 3;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** @param {Layout} layout @returns {Barriers} */
|
|
532
|
+
export function computeBarriers(layout) {
|
|
533
|
+
const cells = occupiedSet(layout);
|
|
534
|
+
/** @type {BarrierCounts} */
|
|
535
|
+
const counts = { straight: 0, curve: 0, curveLarge: 0, cornerCap: 0 };
|
|
536
|
+
/** @type {ArcWall[]} */
|
|
537
|
+
const arcs = [];
|
|
538
|
+
|
|
539
|
+
// How many walls each edge still needs, rather than whether it needs one.
|
|
540
|
+
// An arc takes ONE of them, leaving any wall the neighbouring piece owns
|
|
541
|
+
// on that same edge behind. Deleting the edge outright (as this did
|
|
542
|
+
// before) swallowed that second wall: it vanished from straightEdgeIds,
|
|
543
|
+
// so the track rendered with an open gap where a straight met a corner,
|
|
544
|
+
// and the count lost it too.
|
|
545
|
+
const wallsLeft = wallCounts(layout);
|
|
546
|
+
|
|
547
|
+
for (const piece of layout.pieces) {
|
|
548
|
+
for (const wall of placedWalls(piece)) {
|
|
549
|
+
if (wall.kind === 'cap') {
|
|
550
|
+
if (isConcaveVertex(cells, wall.vertex)) counts.cornerCap += 1;
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
const claimed = arcEdgeIds(wall);
|
|
554
|
+
if (!claimed.every((id) => /** @type {number} */ (wallsLeft.get(id)) > 0)) continue;
|
|
555
|
+
for (const id of claimed) wallsLeft.set(id, /** @type {number} */ (wallsLeft.get(id)) - 1);
|
|
556
|
+
if (wall.kind === 'arc') counts.curve += 1;
|
|
557
|
+
else counts.curveLarge += 2;
|
|
558
|
+
arcs.push(wall);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Every edge carries one straight barrier, except an unjoined inter-piece
|
|
563
|
+
// contact edge, which carries TWO: there is no shared barrier between two
|
|
564
|
+
// lanes that merely touch without joining — each piece mounts its own
|
|
565
|
+
// wall on its own tile, and the two sit back to back at the seam (spec
|
|
566
|
+
// 2.1; corrected from an earlier, wrong "shared barrier" assumption once
|
|
567
|
+
// the user, who owns the physical kit, pointed out that real halves don't
|
|
568
|
+
// share hardware). boundaryEdges stays a plain set of edges-that-carry-
|
|
569
|
+
// a-wall — right for rendering (one stroke per edge) and for the arc-claim
|
|
570
|
+
// loop above (which only needs to know a wall exists there) — so the
|
|
571
|
+
// doubling happens only in this count, not in the edge set itself.
|
|
572
|
+
let straight = 0;
|
|
573
|
+
/** @type {string[]} */
|
|
574
|
+
const straightEdgeIds = [];
|
|
575
|
+
for (const [id, n] of wallsLeft) {
|
|
576
|
+
straight += n;
|
|
577
|
+
if (n > 0) straightEdgeIds.push(id);
|
|
578
|
+
}
|
|
579
|
+
counts.straight = straight;
|
|
580
|
+
|
|
581
|
+
return { counts, straightEdgeIds, arcs };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** @param {Layout} layout @returns {Metrics} */
|
|
585
|
+
export function computeMetrics(layout) {
|
|
586
|
+
const cells = [...occupiedSet(layout)].map(parseCellKey);
|
|
587
|
+
|
|
588
|
+
const bbox = cells.length === 0 ? null : (() => {
|
|
589
|
+
const xs = cells.map((c) => c[0]);
|
|
590
|
+
const ys = cells.map((c) => c[1]);
|
|
591
|
+
const x = Math.min(...xs);
|
|
592
|
+
const y = Math.min(...ys);
|
|
593
|
+
return { x, y, w: Math.max(...xs) - x + 1, h: Math.max(...ys) - y + 1 };
|
|
594
|
+
})();
|
|
595
|
+
|
|
596
|
+
/** @type {Record<string, number>} */
|
|
597
|
+
const mats = {};
|
|
598
|
+
let lapMm = 0;
|
|
599
|
+
for (const piece of layout.pieces) {
|
|
600
|
+
const def = rotatedDef(piece.type, piece.rot);
|
|
601
|
+
for (const [kind, n] of Object.entries(def.mats)) {
|
|
602
|
+
mats[kind] = (mats[kind] ?? 0) + n;
|
|
603
|
+
}
|
|
604
|
+
lapMm += def.centrelineMm;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return { bbox, matCount: cells.length, mats, lapMm };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** @param {Layout} layout @returns {PortEntry[]} */
|
|
611
|
+
export const openPorts = (layout) => matchPorts(layout).open;
|
|
612
|
+
|
|
613
|
+
/** @param {Cell[]} cells @returns {Cell[]} */
|
|
614
|
+
const sortCells = (cells) => [...cells].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
615
|
+
|
|
616
|
+
// Where a piece of `type`/`rot` must sit so its opposite-facing port meets
|
|
617
|
+
// `port`. Returns null if that rotation has no port facing the right way.
|
|
618
|
+
/** @param {Port} port @param {PieceType} type @param {Rotation} rot @returns {PlacedPiece | null} */
|
|
619
|
+
export function placeAgainst(port, type, rot) {
|
|
620
|
+
const want = OPPOSITE[port.dir];
|
|
621
|
+
const mate = rotatedDef(type, rot).ports.find((p) => p.dir === want);
|
|
622
|
+
if (!mate) return null;
|
|
623
|
+
|
|
624
|
+
const [dx, dy] = NEIGHBOUR[port.dir];
|
|
625
|
+
const target = sortCells(port.cells.map(([x, y]) => [x + dx, y + dy]));
|
|
626
|
+
const local = sortCells(mate.cells);
|
|
627
|
+
|
|
628
|
+
return {
|
|
629
|
+
type,
|
|
630
|
+
rot,
|
|
631
|
+
x: target[0][0] - local[0][0],
|
|
632
|
+
y: target[0][1] - local[0][1],
|
|
633
|
+
};
|
|
634
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tinylap/track-format",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Read, validate and draw Kyosho Mini-Z Grand Prix Circuit 30 track layouts.",
|
|
5
|
+
"license": "MPL-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": { "type": "git", "url": "git+https://github.com/tinylaphq/tinylap.git", "directory": "packages/track-format" },
|
|
8
|
+
"exports": {
|
|
9
|
+
".": { "source": "./index.js", "types": "./dist/index.d.ts", "default": "./index.js" },
|
|
10
|
+
"./types": { "types": "./types.d.ts" }
|
|
11
|
+
},
|
|
12
|
+
"files": ["*.js", "types.d.ts", "dist"],
|
|
13
|
+
"scripts": { "test": "node --test 'test/**/*.test.js'", "typecheck": "tsc -p tsconfig.json", "lint": "eslint .", "prepack": "tsc -p tsconfig.build.json && node -e \"require('node:fs').copyFileSync('types.d.ts', 'dist/types.d.ts')\" && node -e \"require('node:fs').copyFileSync('../../LICENSES/MPL-2.0.txt', 'LICENSE')\"" }
|
|
14
|
+
}
|