@willcgage/module-schematic 0.71.0 → 0.73.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
@@ -577,6 +577,20 @@ function scaleFeetToInches(feet, ratio = N_SCALE_RATIO) {
577
577
  return feet * 12 / ratio;
578
578
  }
579
579
  var N_CAR_LENGTH_INCHES = 3.3;
580
+ function spanOverhang(input) {
581
+ const lo = Math.min(input.fromPos, input.toPos);
582
+ const hi = Math.max(input.fromPos, input.toPos);
583
+ const tLo = Math.min(input.trackFromPos, input.trackToPos);
584
+ const tHi = Math.max(input.trackFromPos, input.trackToPos);
585
+ const before = Math.max(0, tLo - lo);
586
+ const after = Math.max(0, hi - tHi);
587
+ return {
588
+ beforeInches: before,
589
+ afterInches: after,
590
+ onTrackInches: Math.max(0, Math.min(hi, tHi) - Math.max(lo, tLo)),
591
+ overhangInches: before + after
592
+ };
593
+ }
580
594
  function carCapacity(fromPos, toPos, carLengthInches = N_CAR_LENGTH_INCHES) {
581
595
  if (!(carLengthInches > 0)) return 0;
582
596
  return Math.max(0, Math.floor(Math.abs(toPos - fromPos) / carLengthInches));
@@ -612,6 +626,7 @@ function emptyEditorState(lengthInches) {
612
626
  crossings: [],
613
627
  branches: [],
614
628
  poseOverrides: {},
629
+ flexByTrack: {},
615
630
  endplateWidths: {},
616
631
  endplateTrackOffsets: {},
617
632
  outline: [],
@@ -713,6 +728,18 @@ function withWidths(endplates, widths, offsets = {}) {
713
728
  return out;
714
729
  });
715
730
  }
731
+ function withFlex(state, tracks) {
732
+ return tracks.map((t) => {
733
+ const f = state.flexByTrack?.[t.id];
734
+ if (!f) return t;
735
+ const cuts = f.cuts?.filter((c) => Number.isFinite(c)).sort((a, b) => a - b);
736
+ return {
737
+ ...t,
738
+ ...f.partId ? { flexPartId: f.partId } : {},
739
+ ...cuts ? { flexCuts: cuts } : {}
740
+ };
741
+ });
742
+ }
716
743
  function stateToDoc(state, recordNumber) {
717
744
  return {
718
745
  version: 1,
@@ -766,7 +793,10 @@ function stateToDoc(state, recordNumber) {
766
793
  state.endplateWidths,
767
794
  state.endplateTrackOffsets
768
795
  ),
769
- tracks: [
796
+ // Flex settings are attached to every track at once, at the end — the array
797
+ // below has half a dozen branches and adding two fields to each of them is
798
+ // how they'd end up disagreeing (#193).
799
+ tracks: withFlex(state, [
770
800
  state.loop ? (
771
801
  // The main runs the lead from A and turns back at the balloon.
772
802
  { id: MAIN_TRACK_ID, role: "main", lane: 0, fromPos: 0, toPos: state.lengthInches }
@@ -801,7 +831,7 @@ function stateToDoc(state, recordNumber) {
801
831
  ...state.loop && t.inLoop ? { inLoop: true } : {},
802
832
  ...t.path && t.path.length >= 2 ? { path: t.path } : {}
803
833
  }))
804
- ],
834
+ ]),
805
835
  turnouts: state.turnouts.map((t) => ({
806
836
  id: t.id,
807
837
  pos: t.pos,
@@ -943,6 +973,12 @@ function docToState(doc, fallbackLength, moduleTracks = []) {
943
973
  if (typeof e.trackOffsetInches === "number" && Number.isFinite(e.trackOffsetInches))
944
974
  endplateTrackOffsets[e.id] = e.trackOffsetInches;
945
975
  }
976
+ const flexByTrack = {};
977
+ for (const t of d.tracks ?? []) {
978
+ const partId = typeof t.flexPartId === "string" && t.flexPartId ? t.flexPartId : void 0;
979
+ const cuts = Array.isArray(t.flexCuts) ? t.flexCuts.filter((c) => Number.isFinite(c)).map(sc).sort((a, b) => a - b) : void 0;
980
+ if (partId || cuts) flexByTrack[t.id] = { ...partId ? { partId } : {}, ...cuts ? { cuts } : {} };
981
+ }
946
982
  const outline = (d.outline ?? []).filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y)).map((p) => ({
947
983
  x: p.x,
948
984
  y: p.y,
@@ -979,6 +1015,7 @@ function docToState(doc, fallbackLength, moduleTracks = []) {
979
1015
  trackId: ep.trackId ?? null
980
1016
  })),
981
1017
  poseOverrides,
1018
+ flexByTrack,
982
1019
  endplateWidths,
983
1020
  endplateTrackOffsets,
984
1021
  outline,
@@ -1276,10 +1313,141 @@ var ATLAS_CODE55_N = [
1276
1313
  innerRadius: { inches: 15, source: "manufacturer", note: "Atlas product listing" }
1277
1314
  }
1278
1315
  ];
1279
- var BUILT_IN_TRACK_PARTS = [...ATLAS_CODE55_N];
1316
+ var FLEX_TRACK_PARTS = [
1317
+ {
1318
+ id: "atlas-c55-n-flex",
1319
+ manufacturer: "Atlas",
1320
+ line: "Code 55",
1321
+ scale: "N",
1322
+ name: "Code 55 Flex Track",
1323
+ kind: "flex",
1324
+ overallLength: {
1325
+ inches: 30,
1326
+ source: "manufacturer",
1327
+ note: "nominal product length, reported by Will Gage 2026-07-26"
1328
+ }
1329
+ },
1330
+ {
1331
+ id: "me-c55-n-flex",
1332
+ manufacturer: "Micro Engineering",
1333
+ line: "Code 55",
1334
+ scale: "N",
1335
+ name: "Code 55 Flex Track",
1336
+ kind: "flex",
1337
+ overallLength: {
1338
+ inches: 36,
1339
+ source: "manufacturer",
1340
+ note: "nominal product length, reported by Will Gage 2026-07-26"
1341
+ }
1342
+ }
1343
+ ];
1344
+ var DEFAULT_FLEX_PART_ID = "atlas-c55-n-flex";
1345
+ var BUILT_IN_TRACK_PARTS = [...ATLAS_CODE55_N, ...FLEX_TRACK_PARTS];
1346
+ function flexParts(library = BUILT_IN_TRACK_PARTS) {
1347
+ return library.filter((p) => p.kind === "flex");
1348
+ }
1349
+ function flexPartFor(id, library = BUILT_IN_TRACK_PARTS) {
1350
+ const chosen = id ? library.find((p) => p.id === id && p.kind === "flex") : null;
1351
+ return chosen ?? library.find((p) => p.id === DEFAULT_FLEX_PART_ID) ?? flexParts(library)[0] ?? null;
1352
+ }
1353
+ function maxFlexPieceInches(id, library = BUILT_IN_TRACK_PARTS) {
1354
+ return flexPartFor(id, library)?.overallLength?.inches ?? 30;
1355
+ }
1280
1356
  function trackPart(id, library = BUILT_IN_TRACK_PARTS) {
1281
1357
  return library.find((p) => p.id === id) ?? null;
1282
1358
  }
1359
+ var FLEX_EPS = 1e-6;
1360
+ var FLEX_MIN_PIECE_INCHES = 1;
1361
+ function flexPieces(input) {
1362
+ const lo = Math.min(input.fromPos, input.toPos);
1363
+ const hi = Math.max(input.fromPos, input.toPos);
1364
+ if (!(hi - lo > FLEX_EPS)) return [];
1365
+ const max = input.maxPieceInches > FLEX_EPS ? input.maxPieceInches : Infinity;
1366
+ const blocks = (input.occupied ?? []).map((s) => ({
1367
+ from: Math.max(lo, Math.min(s.fromPos, s.toPos)),
1368
+ to: Math.min(hi, Math.max(s.fromPos, s.toPos))
1369
+ })).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);
1370
+ const merged = [];
1371
+ for (const b of blocks) {
1372
+ const last = merged[merged.length - 1];
1373
+ if (last && b.from <= last.to + FLEX_EPS) last.to = Math.max(last.to, b.to);
1374
+ else merged.push({ ...b });
1375
+ }
1376
+ const gaps = [];
1377
+ let cursor = lo;
1378
+ for (const b of merged) {
1379
+ if (b.from - cursor > FLEX_EPS) gaps.push({ from: cursor, to: b.from });
1380
+ cursor = Math.max(cursor, b.to);
1381
+ }
1382
+ if (hi - cursor > FLEX_EPS) gaps.push({ from: cursor, to: hi });
1383
+ const authored = input.cuts != null;
1384
+ const out = [];
1385
+ for (const g of gaps) {
1386
+ let inner;
1387
+ if (authored) {
1388
+ inner = [...new Set(input.cuts)].filter((c) => c > g.from + FLEX_EPS && c < g.to - FLEX_EPS).sort((a, b) => a - b);
1389
+ } else {
1390
+ inner = [];
1391
+ for (let at = g.from + max; at < g.to - FLEX_EPS; at += max) inner.push(at);
1392
+ const tail = g.to - (inner[inner.length - 1] ?? g.from);
1393
+ if (inner.length && tail < FLEX_MIN_PIECE_INCHES) {
1394
+ const start = inner.length >= 2 ? inner[inner.length - 2] : g.from;
1395
+ inner[inner.length - 1] = start + (g.to - start) / 2;
1396
+ }
1397
+ }
1398
+ const bounds = [g.from, ...inner, g.to];
1399
+ for (let i = 0; i < bounds.length - 1; i++) {
1400
+ const from = bounds[i];
1401
+ const to = bounds[i + 1];
1402
+ out.push({
1403
+ index: out.length,
1404
+ fromPos: from,
1405
+ toPos: to,
1406
+ lengthInches: to - from,
1407
+ overlong: to - from > max + FLEX_EPS,
1408
+ // The run's own ends are runEnd; anything else is a part body, except
1409
+ // where a cut put a neighbouring piece there.
1410
+ fromEnd: i > 0 ? "piece" : from <= lo + FLEX_EPS ? "runEnd" : "part",
1411
+ toEnd: i < bounds.length - 2 ? "piece" : to >= hi - FLEX_EPS ? "runEnd" : "part"
1412
+ });
1413
+ }
1414
+ }
1415
+ return out;
1416
+ }
1417
+ function turnoutFacing(input) {
1418
+ const far = input.divergeFarPos;
1419
+ const geometric = typeof far === "number" && Number.isFinite(far) ? Math.sign(far - input.pos) || 1 : 1;
1420
+ return input.flipped ? -geometric : geometric;
1421
+ }
1422
+ function turnoutOccupiedSpan(input) {
1423
+ const e = input.extent;
1424
+ if (!e) return null;
1425
+ const a = input.pos - input.facing * e.behindPoints;
1426
+ const b = input.pos + input.facing * e.aheadOfPoints;
1427
+ return { fromPos: Math.min(a, b), toPos: Math.max(a, b) };
1428
+ }
1429
+ function resizeFlexPiece(pieces, index, nextLengthInches) {
1430
+ const piece = pieces[index];
1431
+ const next = pieces[index + 1];
1432
+ if (!piece || !next) return null;
1433
+ if (piece.toEnd !== "piece") return null;
1434
+ if (!Number.isFinite(nextLengthInches)) return null;
1435
+ const pair = piece.lengthInches + next.lengthInches;
1436
+ if (pair < 2 * FLEX_MIN_PIECE_INCHES) return null;
1437
+ const want = Math.max(
1438
+ FLEX_MIN_PIECE_INCHES,
1439
+ Math.min(pair - FLEX_MIN_PIECE_INCHES, nextLengthInches)
1440
+ );
1441
+ const moved = piece.fromPos + want;
1442
+ 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);
1443
+ }
1444
+ function flexUsage(pieces) {
1445
+ return {
1446
+ pieces: pieces.length,
1447
+ totalInches: pieces.reduce((a, p) => a + p.lengthInches, 0),
1448
+ overlong: pieces.filter((p) => p.overlong).length
1449
+ };
1450
+ }
1283
1451
  var TIE_HALF_LENGTH_INCHES = 0.319;
1284
1452
  function partExtent(part) {
1285
1453
  const pts = part?.pointsOffset;
@@ -2178,8 +2346,10 @@ function poseOverridesFromDoc(doc) {
2178
2346
  exports.ATLAS_CODE55_N = ATLAS_CODE55_N;
2179
2347
  exports.BUILT_IN_TRACK_PARTS = BUILT_IN_TRACK_PARTS;
2180
2348
  exports.CODE55_RAIL_HEIGHT_INCHES = CODE55_RAIL_HEIGHT_INCHES;
2349
+ exports.DEFAULT_FLEX_PART_ID = DEFAULT_FLEX_PART_ID;
2181
2350
  exports.ENDPLATE_FASCIA_CLEAR_INCHES = ENDPLATE_FASCIA_CLEAR_INCHES;
2182
2351
  exports.ENDPLATE_LEAD_INCHES = ENDPLATE_LEAD_INCHES;
2352
+ exports.FLEX_TRACK_PARTS = FLEX_TRACK_PARTS;
2183
2353
  exports.FREEMO_ENDPLATE_TRACK_FASCIA_CLEARANCE_INCHES = FREEMO_ENDPLATE_TRACK_FASCIA_CLEARANCE_INCHES;
2184
2354
  exports.FREEMO_ENDPLATE_WIDTH_MIN_INCHES = FREEMO_ENDPLATE_WIDTH_MIN_INCHES;
2185
2355
  exports.FREEMO_ENDPLATE_WIDTH_RECOMMENDED_INCHES = FREEMO_ENDPLATE_WIDTH_RECOMMENDED_INCHES;
@@ -2209,6 +2379,10 @@ exports.endplateFaceSegments = endplateFaceSegments;
2209
2379
  exports.endplateLead = endplateLead;
2210
2380
  exports.endplateTrackOffsetInches = endplateTrackOffsetInches;
2211
2381
  exports.endplateWidthInches = endplateWidthInches;
2382
+ exports.flexPartFor = flexPartFor;
2383
+ exports.flexParts = flexParts;
2384
+ exports.flexPieces = flexPieces;
2385
+ exports.flexUsage = flexUsage;
2212
2386
  exports.frogCasting = frogCasting;
2213
2387
  exports.frogNumberFromName = frogNumberFromName;
2214
2388
  exports.fromSectionRelative = fromSectionRelative;
@@ -2219,6 +2393,7 @@ exports.inchesToScaleFeet = inchesToScaleFeet;
2219
2393
  exports.isLoopDoc = isLoopDoc;
2220
2394
  exports.isTransitionTurnout = isTransitionTurnout;
2221
2395
  exports.leadInchesForSize = leadInchesForSize;
2396
+ exports.maxFlexPieceInches = maxFlexPieceInches;
2222
2397
  exports.mergeImportedParts = mergeImportedParts;
2223
2398
  exports.mergeStoredParts = mergeStoredParts;
2224
2399
  exports.moduleCenterline = moduleCenterline;
@@ -2236,6 +2411,7 @@ exports.pathLengthInches = pathLengthInches;
2236
2411
  exports.poseNeedsManual = poseNeedsManual;
2237
2412
  exports.poseOverridesFromDoc = poseOverridesFromDoc;
2238
2413
  exports.remapPos = remapPos;
2414
+ exports.resizeFlexPiece = resizeFlexPiece;
2239
2415
  exports.returnLoop = returnLoop;
2240
2416
  exports.sampleBenchworkOutline = sampleBenchworkOutline;
2241
2417
  exports.samplePartSegments = samplePartSegments;
@@ -2252,6 +2428,7 @@ exports.sectionSpansOrWhole = sectionSpansOrWhole;
2252
2428
  exports.sectionedCenterline = sectionedCenterline;
2253
2429
  exports.sectionedEndPose = sectionedEndPose;
2254
2430
  exports.sliceCenterline = sliceCenterline;
2431
+ exports.spanOverhang = spanOverhang;
2255
2432
  exports.stateToDoc = stateToDoc;
2256
2433
  exports.storedPartToTrackPart = storedPartToTrackPart;
2257
2434
  exports.toSectionRelative = toSectionRelative;
@@ -2259,6 +2436,8 @@ exports.trackMeetsEndplateIssues = trackMeetsEndplateIssues;
2259
2436
  exports.trackPart = trackPart;
2260
2437
  exports.trackPath = trackPath;
2261
2438
  exports.turnoutClosure = turnoutClosure;
2439
+ exports.turnoutFacing = turnoutFacing;
2440
+ exports.turnoutOccupiedSpan = turnoutOccupiedSpan;
2262
2441
  exports.turnoutPartForSize = turnoutPartForSize;
2263
2442
  //# sourceMappingURL=index.cjs.map
2264
2443
  //# sourceMappingURL=index.cjs.map