@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/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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }