@willcgage/module-schematic 0.70.0 → 0.72.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/dist/index.cjs CHANGED
@@ -471,8 +471,12 @@ function moduleFootprint(input) {
471
471
  const widthA = endplateWidthFor(input.endplateWidths, "A");
472
472
  const widthB = endplateWidthFor(input.endplateWidths, "B");
473
473
  const authored = benchworkOutline(input);
474
- const offA = input.endplateTrackOffsets?.["A"] ?? 0;
475
- const offB = input.endplateTrackOffsets?.["B"] ?? 0;
474
+ const offOf = (i, id) => input.endplateTrackOffsets?.[id] ?? endplateCentreOffsetInches({
475
+ config: input.endplateConfigs?.[i],
476
+ main2Below: input.mainsSwapped === true
477
+ });
478
+ const offA = offOf(0, "A");
479
+ const offB = offOf(1, "B");
476
480
  const secs = input.sections ?? [];
477
481
  const sectionsOwnShape = secs.length > 1 || secs.some((s) => (s.outline?.length ?? 0) >= 3);
478
482
  const sectionOutlines = sectionsOwnShape ? sectionFootprints(input, {
@@ -496,13 +500,19 @@ function moduleFootprint(input) {
496
500
  sectionOutlines
497
501
  };
498
502
  }
499
- function endplateTrackOffsetFor(config, authoredTrackOffset) {
500
- const v = -endplateTrackOffsetInches(authoredTrackOffset, config);
503
+ function endplateCentreOffsetInches(input) {
504
+ const v = -endplateTrackOffsetInches(
505
+ input.authoredTrackOffsetInches,
506
+ input.config ?? void 0,
507
+ input.main2Below
508
+ );
501
509
  return v === 0 ? 0 : v;
502
510
  }
503
- function endplateTrackOffsetInches(authored, config) {
511
+ function endplateTrackOffsetInches(authored, config, main2Below = false) {
504
512
  if (typeof authored === "number" && Number.isFinite(authored)) return authored;
505
- return config === "double" ? -FREEMO_TRACK_SPACING_INCHES / 2 : 0;
513
+ if (config !== "double") return 0;
514
+ const half = FREEMO_TRACK_SPACING_INCHES / 2;
515
+ return main2Below ? half : -half;
506
516
  }
507
517
  function checkEndplateWidth(input) {
508
518
  const width = endplateWidthInches(input);
@@ -514,8 +524,13 @@ function checkEndplateWidth(input) {
514
524
  requiredInches: FREEMO_ENDPLATE_WIDTH_MIN_INCHES
515
525
  });
516
526
  }
517
- const off = endplateTrackOffsetInches(input.trackOffsetInches, input.config ?? void 0);
518
- const centres = input.config === "double" ? [off, off + FREEMO_TRACK_SPACING_INCHES] : [off];
527
+ const off = endplateTrackOffsetInches(
528
+ input.trackOffsetInches,
529
+ input.config ?? void 0,
530
+ input.main2Below
531
+ );
532
+ const second = off + (input.main2Below ? -1 : 1) * FREEMO_TRACK_SPACING_INCHES;
533
+ const centres = input.config === "double" ? [off, second] : [off];
519
534
  const worst = Math.max(...centres.map((c) => Math.abs(c)));
520
535
  const clearance = width / 2 - worst;
521
536
  if (clearance < FREEMO_ENDPLATE_TRACK_FASCIA_CLEARANCE_INCHES) {
@@ -526,6 +541,18 @@ function checkEndplateWidth(input) {
526
541
  requiredInches: required
527
542
  });
528
543
  }
544
+ if (input.config === "double") {
545
+ const mid = (off + second) / 2;
546
+ if (Math.abs(mid) > 0.01) {
547
+ issues.push({
548
+ code: "offcentre",
549
+ message: `The two tracks sit ${round2(Math.abs(mid))}\u2033 off the centre of this endplate. The standard recommends they straddle it \u2014 Main 1 at ${round2((input.main2Below ? 1 : -1) * (FREEMO_TRACK_SPACING_INCHES / 2))}\u2033. Clear the offset to use that.`,
550
+ // Not a width problem — no wider plate fixes it — so hand back the
551
+ // width unchanged rather than imply one would.
552
+ requiredInches: width
553
+ });
554
+ }
555
+ }
529
556
  return issues;
530
557
  }
531
558
  var round2 = (n) => Math.round(n * 100) / 100;
@@ -585,6 +612,7 @@ function emptyEditorState(lengthInches) {
585
612
  crossings: [],
586
613
  branches: [],
587
614
  poseOverrides: {},
615
+ flexByTrack: {},
588
616
  endplateWidths: {},
589
617
  endplateTrackOffsets: {},
590
618
  outline: [],
@@ -686,6 +714,18 @@ function withWidths(endplates, widths, offsets = {}) {
686
714
  return out;
687
715
  });
688
716
  }
717
+ function withFlex(state, tracks) {
718
+ return tracks.map((t) => {
719
+ const f = state.flexByTrack?.[t.id];
720
+ if (!f) return t;
721
+ const cuts = f.cuts?.filter((c) => Number.isFinite(c)).sort((a, b) => a - b);
722
+ return {
723
+ ...t,
724
+ ...f.partId ? { flexPartId: f.partId } : {},
725
+ ...cuts ? { flexCuts: cuts } : {}
726
+ };
727
+ });
728
+ }
689
729
  function stateToDoc(state, recordNumber) {
690
730
  return {
691
731
  version: 1,
@@ -739,7 +779,10 @@ function stateToDoc(state, recordNumber) {
739
779
  state.endplateWidths,
740
780
  state.endplateTrackOffsets
741
781
  ),
742
- tracks: [
782
+ // Flex settings are attached to every track at once, at the end — the array
783
+ // below has half a dozen branches and adding two fields to each of them is
784
+ // how they'd end up disagreeing (#193).
785
+ tracks: withFlex(state, [
743
786
  state.loop ? (
744
787
  // The main runs the lead from A and turns back at the balloon.
745
788
  { id: MAIN_TRACK_ID, role: "main", lane: 0, fromPos: 0, toPos: state.lengthInches }
@@ -774,7 +817,7 @@ function stateToDoc(state, recordNumber) {
774
817
  ...state.loop && t.inLoop ? { inLoop: true } : {},
775
818
  ...t.path && t.path.length >= 2 ? { path: t.path } : {}
776
819
  }))
777
- ],
820
+ ]),
778
821
  turnouts: state.turnouts.map((t) => ({
779
822
  id: t.id,
780
823
  pos: t.pos,
@@ -916,6 +959,12 @@ function docToState(doc, fallbackLength, moduleTracks = []) {
916
959
  if (typeof e.trackOffsetInches === "number" && Number.isFinite(e.trackOffsetInches))
917
960
  endplateTrackOffsets[e.id] = e.trackOffsetInches;
918
961
  }
962
+ const flexByTrack = {};
963
+ for (const t of d.tracks ?? []) {
964
+ const partId = typeof t.flexPartId === "string" && t.flexPartId ? t.flexPartId : void 0;
965
+ const cuts = Array.isArray(t.flexCuts) ? t.flexCuts.filter((c) => Number.isFinite(c)).map(sc).sort((a, b) => a - b) : void 0;
966
+ if (partId || cuts) flexByTrack[t.id] = { ...partId ? { partId } : {}, ...cuts ? { cuts } : {} };
967
+ }
919
968
  const outline = (d.outline ?? []).filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y)).map((p) => ({
920
969
  x: p.x,
921
970
  y: p.y,
@@ -952,6 +1001,7 @@ function docToState(doc, fallbackLength, moduleTracks = []) {
952
1001
  trackId: ep.trackId ?? null
953
1002
  })),
954
1003
  poseOverrides,
1004
+ flexByTrack,
955
1005
  endplateWidths,
956
1006
  endplateTrackOffsets,
957
1007
  outline,
@@ -1249,10 +1299,141 @@ var ATLAS_CODE55_N = [
1249
1299
  innerRadius: { inches: 15, source: "manufacturer", note: "Atlas product listing" }
1250
1300
  }
1251
1301
  ];
1252
- var BUILT_IN_TRACK_PARTS = [...ATLAS_CODE55_N];
1302
+ var FLEX_TRACK_PARTS = [
1303
+ {
1304
+ id: "atlas-c55-n-flex",
1305
+ manufacturer: "Atlas",
1306
+ line: "Code 55",
1307
+ scale: "N",
1308
+ name: "Code 55 Flex Track",
1309
+ kind: "flex",
1310
+ overallLength: {
1311
+ inches: 30,
1312
+ source: "manufacturer",
1313
+ note: "nominal product length, reported by Will Gage 2026-07-26"
1314
+ }
1315
+ },
1316
+ {
1317
+ id: "me-c55-n-flex",
1318
+ manufacturer: "Micro Engineering",
1319
+ line: "Code 55",
1320
+ scale: "N",
1321
+ name: "Code 55 Flex Track",
1322
+ kind: "flex",
1323
+ overallLength: {
1324
+ inches: 36,
1325
+ source: "manufacturer",
1326
+ note: "nominal product length, reported by Will Gage 2026-07-26"
1327
+ }
1328
+ }
1329
+ ];
1330
+ var DEFAULT_FLEX_PART_ID = "atlas-c55-n-flex";
1331
+ var BUILT_IN_TRACK_PARTS = [...ATLAS_CODE55_N, ...FLEX_TRACK_PARTS];
1332
+ function flexParts(library = BUILT_IN_TRACK_PARTS) {
1333
+ return library.filter((p) => p.kind === "flex");
1334
+ }
1335
+ function flexPartFor(id, library = BUILT_IN_TRACK_PARTS) {
1336
+ const chosen = id ? library.find((p) => p.id === id && p.kind === "flex") : null;
1337
+ return chosen ?? library.find((p) => p.id === DEFAULT_FLEX_PART_ID) ?? flexParts(library)[0] ?? null;
1338
+ }
1339
+ function maxFlexPieceInches(id, library = BUILT_IN_TRACK_PARTS) {
1340
+ return flexPartFor(id, library)?.overallLength?.inches ?? 30;
1341
+ }
1253
1342
  function trackPart(id, library = BUILT_IN_TRACK_PARTS) {
1254
1343
  return library.find((p) => p.id === id) ?? null;
1255
1344
  }
1345
+ var FLEX_EPS = 1e-6;
1346
+ var FLEX_MIN_PIECE_INCHES = 1;
1347
+ function flexPieces(input) {
1348
+ const lo = Math.min(input.fromPos, input.toPos);
1349
+ const hi = Math.max(input.fromPos, input.toPos);
1350
+ if (!(hi - lo > FLEX_EPS)) return [];
1351
+ const max = input.maxPieceInches > FLEX_EPS ? input.maxPieceInches : Infinity;
1352
+ const blocks = (input.occupied ?? []).map((s) => ({
1353
+ from: Math.max(lo, Math.min(s.fromPos, s.toPos)),
1354
+ to: Math.min(hi, Math.max(s.fromPos, s.toPos))
1355
+ })).filter((s) => s.to - s.from >= -FLEX_EPS && s.from < hi + FLEX_EPS && s.to > lo - FLEX_EPS).sort((a, b) => a.from - b.from);
1356
+ const merged = [];
1357
+ for (const b of blocks) {
1358
+ const last = merged[merged.length - 1];
1359
+ if (last && b.from <= last.to + FLEX_EPS) last.to = Math.max(last.to, b.to);
1360
+ else merged.push({ ...b });
1361
+ }
1362
+ const gaps = [];
1363
+ let cursor = lo;
1364
+ for (const b of merged) {
1365
+ if (b.from - cursor > FLEX_EPS) gaps.push({ from: cursor, to: b.from });
1366
+ cursor = Math.max(cursor, b.to);
1367
+ }
1368
+ if (hi - cursor > FLEX_EPS) gaps.push({ from: cursor, to: hi });
1369
+ const authored = input.cuts != null;
1370
+ const out = [];
1371
+ for (const g of gaps) {
1372
+ let inner;
1373
+ if (authored) {
1374
+ inner = [...new Set(input.cuts)].filter((c) => c > g.from + FLEX_EPS && c < g.to - FLEX_EPS).sort((a, b) => a - b);
1375
+ } else {
1376
+ inner = [];
1377
+ for (let at = g.from + max; at < g.to - FLEX_EPS; at += max) inner.push(at);
1378
+ const tail = g.to - (inner[inner.length - 1] ?? g.from);
1379
+ if (inner.length && tail < FLEX_MIN_PIECE_INCHES) {
1380
+ const start = inner.length >= 2 ? inner[inner.length - 2] : g.from;
1381
+ inner[inner.length - 1] = start + (g.to - start) / 2;
1382
+ }
1383
+ }
1384
+ const bounds = [g.from, ...inner, g.to];
1385
+ for (let i = 0; i < bounds.length - 1; i++) {
1386
+ const from = bounds[i];
1387
+ const to = bounds[i + 1];
1388
+ out.push({
1389
+ index: out.length,
1390
+ fromPos: from,
1391
+ toPos: to,
1392
+ lengthInches: to - from,
1393
+ overlong: to - from > max + FLEX_EPS,
1394
+ // The run's own ends are runEnd; anything else is a part body, except
1395
+ // where a cut put a neighbouring piece there.
1396
+ fromEnd: i > 0 ? "piece" : from <= lo + FLEX_EPS ? "runEnd" : "part",
1397
+ toEnd: i < bounds.length - 2 ? "piece" : to >= hi - FLEX_EPS ? "runEnd" : "part"
1398
+ });
1399
+ }
1400
+ }
1401
+ return out;
1402
+ }
1403
+ function turnoutFacing(input) {
1404
+ const far = input.divergeFarPos;
1405
+ const geometric = typeof far === "number" && Number.isFinite(far) ? Math.sign(far - input.pos) || 1 : 1;
1406
+ return input.flipped ? -geometric : geometric;
1407
+ }
1408
+ function turnoutOccupiedSpan(input) {
1409
+ const e = input.extent;
1410
+ if (!e) return null;
1411
+ const a = input.pos - input.facing * e.behindPoints;
1412
+ const b = input.pos + input.facing * e.aheadOfPoints;
1413
+ return { fromPos: Math.min(a, b), toPos: Math.max(a, b) };
1414
+ }
1415
+ function resizeFlexPiece(pieces, index, nextLengthInches) {
1416
+ const piece = pieces[index];
1417
+ const next = pieces[index + 1];
1418
+ if (!piece || !next) return null;
1419
+ if (piece.toEnd !== "piece") return null;
1420
+ if (!Number.isFinite(nextLengthInches)) return null;
1421
+ const pair = piece.lengthInches + next.lengthInches;
1422
+ if (pair < 2 * FLEX_MIN_PIECE_INCHES) return null;
1423
+ const want = Math.max(
1424
+ FLEX_MIN_PIECE_INCHES,
1425
+ Math.min(pair - FLEX_MIN_PIECE_INCHES, nextLengthInches)
1426
+ );
1427
+ const moved = piece.fromPos + want;
1428
+ return pieces.filter((p) => p.toEnd === "piece").map((p) => p.index === index ? moved : p.toPos).map((v) => Math.round(v * 1e3) / 1e3).sort((a, b) => a - b);
1429
+ }
1430
+ function flexUsage(pieces) {
1431
+ return {
1432
+ pieces: pieces.length,
1433
+ totalInches: pieces.reduce((a, p) => a + p.lengthInches, 0),
1434
+ overlong: pieces.filter((p) => p.overlong).length
1435
+ };
1436
+ }
1256
1437
  var TIE_HALF_LENGTH_INCHES = 0.319;
1257
1438
  function partExtent(part) {
1258
1439
  const pts = part?.pointsOffset;
@@ -2151,8 +2332,10 @@ function poseOverridesFromDoc(doc) {
2151
2332
  exports.ATLAS_CODE55_N = ATLAS_CODE55_N;
2152
2333
  exports.BUILT_IN_TRACK_PARTS = BUILT_IN_TRACK_PARTS;
2153
2334
  exports.CODE55_RAIL_HEIGHT_INCHES = CODE55_RAIL_HEIGHT_INCHES;
2335
+ exports.DEFAULT_FLEX_PART_ID = DEFAULT_FLEX_PART_ID;
2154
2336
  exports.ENDPLATE_FASCIA_CLEAR_INCHES = ENDPLATE_FASCIA_CLEAR_INCHES;
2155
2337
  exports.ENDPLATE_LEAD_INCHES = ENDPLATE_LEAD_INCHES;
2338
+ exports.FLEX_TRACK_PARTS = FLEX_TRACK_PARTS;
2156
2339
  exports.FREEMO_ENDPLATE_TRACK_FASCIA_CLEARANCE_INCHES = FREEMO_ENDPLATE_TRACK_FASCIA_CLEARANCE_INCHES;
2157
2340
  exports.FREEMO_ENDPLATE_WIDTH_MIN_INCHES = FREEMO_ENDPLATE_WIDTH_MIN_INCHES;
2158
2341
  exports.FREEMO_ENDPLATE_WIDTH_RECOMMENDED_INCHES = FREEMO_ENDPLATE_WIDTH_RECOMMENDED_INCHES;
@@ -2177,11 +2360,15 @@ exports.deriveEndplatePoses = deriveEndplatePoses;
2177
2360
  exports.divergeSideForHand = divergeSideForHand;
2178
2361
  exports.docToState = docToState;
2179
2362
  exports.emptyEditorState = emptyEditorState;
2363
+ exports.endplateCentreOffsetInches = endplateCentreOffsetInches;
2180
2364
  exports.endplateFaceSegments = endplateFaceSegments;
2181
2365
  exports.endplateLead = endplateLead;
2182
- exports.endplateTrackOffsetFor = endplateTrackOffsetFor;
2183
2366
  exports.endplateTrackOffsetInches = endplateTrackOffsetInches;
2184
2367
  exports.endplateWidthInches = endplateWidthInches;
2368
+ exports.flexPartFor = flexPartFor;
2369
+ exports.flexParts = flexParts;
2370
+ exports.flexPieces = flexPieces;
2371
+ exports.flexUsage = flexUsage;
2185
2372
  exports.frogCasting = frogCasting;
2186
2373
  exports.frogNumberFromName = frogNumberFromName;
2187
2374
  exports.fromSectionRelative = fromSectionRelative;
@@ -2192,6 +2379,7 @@ exports.inchesToScaleFeet = inchesToScaleFeet;
2192
2379
  exports.isLoopDoc = isLoopDoc;
2193
2380
  exports.isTransitionTurnout = isTransitionTurnout;
2194
2381
  exports.leadInchesForSize = leadInchesForSize;
2382
+ exports.maxFlexPieceInches = maxFlexPieceInches;
2195
2383
  exports.mergeImportedParts = mergeImportedParts;
2196
2384
  exports.mergeStoredParts = mergeStoredParts;
2197
2385
  exports.moduleCenterline = moduleCenterline;
@@ -2209,6 +2397,7 @@ exports.pathLengthInches = pathLengthInches;
2209
2397
  exports.poseNeedsManual = poseNeedsManual;
2210
2398
  exports.poseOverridesFromDoc = poseOverridesFromDoc;
2211
2399
  exports.remapPos = remapPos;
2400
+ exports.resizeFlexPiece = resizeFlexPiece;
2212
2401
  exports.returnLoop = returnLoop;
2213
2402
  exports.sampleBenchworkOutline = sampleBenchworkOutline;
2214
2403
  exports.samplePartSegments = samplePartSegments;
@@ -2232,6 +2421,8 @@ exports.trackMeetsEndplateIssues = trackMeetsEndplateIssues;
2232
2421
  exports.trackPart = trackPart;
2233
2422
  exports.trackPath = trackPath;
2234
2423
  exports.turnoutClosure = turnoutClosure;
2424
+ exports.turnoutFacing = turnoutFacing;
2425
+ exports.turnoutOccupiedSpan = turnoutOccupiedSpan;
2235
2426
  exports.turnoutPartForSize = turnoutPartForSize;
2236
2427
  //# sourceMappingURL=index.cjs.map
2237
2428
  //# sourceMappingURL=index.cjs.map