@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.js CHANGED
@@ -575,6 +575,20 @@ function scaleFeetToInches(feet, ratio = N_SCALE_RATIO) {
575
575
  return feet * 12 / ratio;
576
576
  }
577
577
  var N_CAR_LENGTH_INCHES = 3.3;
578
+ function spanOverhang(input) {
579
+ const lo = Math.min(input.fromPos, input.toPos);
580
+ const hi = Math.max(input.fromPos, input.toPos);
581
+ const tLo = Math.min(input.trackFromPos, input.trackToPos);
582
+ const tHi = Math.max(input.trackFromPos, input.trackToPos);
583
+ const before = Math.max(0, tLo - lo);
584
+ const after = Math.max(0, hi - tHi);
585
+ return {
586
+ beforeInches: before,
587
+ afterInches: after,
588
+ onTrackInches: Math.max(0, Math.min(hi, tHi) - Math.max(lo, tLo)),
589
+ overhangInches: before + after
590
+ };
591
+ }
578
592
  function carCapacity(fromPos, toPos, carLengthInches = N_CAR_LENGTH_INCHES) {
579
593
  if (!(carLengthInches > 0)) return 0;
580
594
  return Math.max(0, Math.floor(Math.abs(toPos - fromPos) / carLengthInches));
@@ -610,6 +624,7 @@ function emptyEditorState(lengthInches) {
610
624
  crossings: [],
611
625
  branches: [],
612
626
  poseOverrides: {},
627
+ flexByTrack: {},
613
628
  endplateWidths: {},
614
629
  endplateTrackOffsets: {},
615
630
  outline: [],
@@ -711,6 +726,18 @@ function withWidths(endplates, widths, offsets = {}) {
711
726
  return out;
712
727
  });
713
728
  }
729
+ function withFlex(state, tracks) {
730
+ return tracks.map((t) => {
731
+ const f = state.flexByTrack?.[t.id];
732
+ if (!f) return t;
733
+ const cuts = f.cuts?.filter((c) => Number.isFinite(c)).sort((a, b) => a - b);
734
+ return {
735
+ ...t,
736
+ ...f.partId ? { flexPartId: f.partId } : {},
737
+ ...cuts ? { flexCuts: cuts } : {}
738
+ };
739
+ });
740
+ }
714
741
  function stateToDoc(state, recordNumber) {
715
742
  return {
716
743
  version: 1,
@@ -764,7 +791,10 @@ function stateToDoc(state, recordNumber) {
764
791
  state.endplateWidths,
765
792
  state.endplateTrackOffsets
766
793
  ),
767
- tracks: [
794
+ // Flex settings are attached to every track at once, at the end — the array
795
+ // below has half a dozen branches and adding two fields to each of them is
796
+ // how they'd end up disagreeing (#193).
797
+ tracks: withFlex(state, [
768
798
  state.loop ? (
769
799
  // The main runs the lead from A and turns back at the balloon.
770
800
  { id: MAIN_TRACK_ID, role: "main", lane: 0, fromPos: 0, toPos: state.lengthInches }
@@ -799,7 +829,7 @@ function stateToDoc(state, recordNumber) {
799
829
  ...state.loop && t.inLoop ? { inLoop: true } : {},
800
830
  ...t.path && t.path.length >= 2 ? { path: t.path } : {}
801
831
  }))
802
- ],
832
+ ]),
803
833
  turnouts: state.turnouts.map((t) => ({
804
834
  id: t.id,
805
835
  pos: t.pos,
@@ -941,6 +971,12 @@ function docToState(doc, fallbackLength, moduleTracks = []) {
941
971
  if (typeof e.trackOffsetInches === "number" && Number.isFinite(e.trackOffsetInches))
942
972
  endplateTrackOffsets[e.id] = e.trackOffsetInches;
943
973
  }
974
+ const flexByTrack = {};
975
+ for (const t of d.tracks ?? []) {
976
+ const partId = typeof t.flexPartId === "string" && t.flexPartId ? t.flexPartId : void 0;
977
+ const cuts = Array.isArray(t.flexCuts) ? t.flexCuts.filter((c) => Number.isFinite(c)).map(sc).sort((a, b) => a - b) : void 0;
978
+ if (partId || cuts) flexByTrack[t.id] = { ...partId ? { partId } : {}, ...cuts ? { cuts } : {} };
979
+ }
944
980
  const outline = (d.outline ?? []).filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y)).map((p) => ({
945
981
  x: p.x,
946
982
  y: p.y,
@@ -977,6 +1013,7 @@ function docToState(doc, fallbackLength, moduleTracks = []) {
977
1013
  trackId: ep.trackId ?? null
978
1014
  })),
979
1015
  poseOverrides,
1016
+ flexByTrack,
980
1017
  endplateWidths,
981
1018
  endplateTrackOffsets,
982
1019
  outline,
@@ -1274,10 +1311,141 @@ var ATLAS_CODE55_N = [
1274
1311
  innerRadius: { inches: 15, source: "manufacturer", note: "Atlas product listing" }
1275
1312
  }
1276
1313
  ];
1277
- var BUILT_IN_TRACK_PARTS = [...ATLAS_CODE55_N];
1314
+ var FLEX_TRACK_PARTS = [
1315
+ {
1316
+ id: "atlas-c55-n-flex",
1317
+ manufacturer: "Atlas",
1318
+ line: "Code 55",
1319
+ scale: "N",
1320
+ name: "Code 55 Flex Track",
1321
+ kind: "flex",
1322
+ overallLength: {
1323
+ inches: 30,
1324
+ source: "manufacturer",
1325
+ note: "nominal product length, reported by Will Gage 2026-07-26"
1326
+ }
1327
+ },
1328
+ {
1329
+ id: "me-c55-n-flex",
1330
+ manufacturer: "Micro Engineering",
1331
+ line: "Code 55",
1332
+ scale: "N",
1333
+ name: "Code 55 Flex Track",
1334
+ kind: "flex",
1335
+ overallLength: {
1336
+ inches: 36,
1337
+ source: "manufacturer",
1338
+ note: "nominal product length, reported by Will Gage 2026-07-26"
1339
+ }
1340
+ }
1341
+ ];
1342
+ var DEFAULT_FLEX_PART_ID = "atlas-c55-n-flex";
1343
+ var BUILT_IN_TRACK_PARTS = [...ATLAS_CODE55_N, ...FLEX_TRACK_PARTS];
1344
+ function flexParts(library = BUILT_IN_TRACK_PARTS) {
1345
+ return library.filter((p) => p.kind === "flex");
1346
+ }
1347
+ function flexPartFor(id, library = BUILT_IN_TRACK_PARTS) {
1348
+ const chosen = id ? library.find((p) => p.id === id && p.kind === "flex") : null;
1349
+ return chosen ?? library.find((p) => p.id === DEFAULT_FLEX_PART_ID) ?? flexParts(library)[0] ?? null;
1350
+ }
1351
+ function maxFlexPieceInches(id, library = BUILT_IN_TRACK_PARTS) {
1352
+ return flexPartFor(id, library)?.overallLength?.inches ?? 30;
1353
+ }
1278
1354
  function trackPart(id, library = BUILT_IN_TRACK_PARTS) {
1279
1355
  return library.find((p) => p.id === id) ?? null;
1280
1356
  }
1357
+ var FLEX_EPS = 1e-6;
1358
+ var FLEX_MIN_PIECE_INCHES = 1;
1359
+ function flexPieces(input) {
1360
+ const lo = Math.min(input.fromPos, input.toPos);
1361
+ const hi = Math.max(input.fromPos, input.toPos);
1362
+ if (!(hi - lo > FLEX_EPS)) return [];
1363
+ const max = input.maxPieceInches > FLEX_EPS ? input.maxPieceInches : Infinity;
1364
+ const blocks = (input.occupied ?? []).map((s) => ({
1365
+ from: Math.max(lo, Math.min(s.fromPos, s.toPos)),
1366
+ to: Math.min(hi, Math.max(s.fromPos, s.toPos))
1367
+ })).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);
1368
+ const merged = [];
1369
+ for (const b of blocks) {
1370
+ const last = merged[merged.length - 1];
1371
+ if (last && b.from <= last.to + FLEX_EPS) last.to = Math.max(last.to, b.to);
1372
+ else merged.push({ ...b });
1373
+ }
1374
+ const gaps = [];
1375
+ let cursor = lo;
1376
+ for (const b of merged) {
1377
+ if (b.from - cursor > FLEX_EPS) gaps.push({ from: cursor, to: b.from });
1378
+ cursor = Math.max(cursor, b.to);
1379
+ }
1380
+ if (hi - cursor > FLEX_EPS) gaps.push({ from: cursor, to: hi });
1381
+ const authored = input.cuts != null;
1382
+ const out = [];
1383
+ for (const g of gaps) {
1384
+ let inner;
1385
+ if (authored) {
1386
+ inner = [...new Set(input.cuts)].filter((c) => c > g.from + FLEX_EPS && c < g.to - FLEX_EPS).sort((a, b) => a - b);
1387
+ } else {
1388
+ inner = [];
1389
+ for (let at = g.from + max; at < g.to - FLEX_EPS; at += max) inner.push(at);
1390
+ const tail = g.to - (inner[inner.length - 1] ?? g.from);
1391
+ if (inner.length && tail < FLEX_MIN_PIECE_INCHES) {
1392
+ const start = inner.length >= 2 ? inner[inner.length - 2] : g.from;
1393
+ inner[inner.length - 1] = start + (g.to - start) / 2;
1394
+ }
1395
+ }
1396
+ const bounds = [g.from, ...inner, g.to];
1397
+ for (let i = 0; i < bounds.length - 1; i++) {
1398
+ const from = bounds[i];
1399
+ const to = bounds[i + 1];
1400
+ out.push({
1401
+ index: out.length,
1402
+ fromPos: from,
1403
+ toPos: to,
1404
+ lengthInches: to - from,
1405
+ overlong: to - from > max + FLEX_EPS,
1406
+ // The run's own ends are runEnd; anything else is a part body, except
1407
+ // where a cut put a neighbouring piece there.
1408
+ fromEnd: i > 0 ? "piece" : from <= lo + FLEX_EPS ? "runEnd" : "part",
1409
+ toEnd: i < bounds.length - 2 ? "piece" : to >= hi - FLEX_EPS ? "runEnd" : "part"
1410
+ });
1411
+ }
1412
+ }
1413
+ return out;
1414
+ }
1415
+ function turnoutFacing(input) {
1416
+ const far = input.divergeFarPos;
1417
+ const geometric = typeof far === "number" && Number.isFinite(far) ? Math.sign(far - input.pos) || 1 : 1;
1418
+ return input.flipped ? -geometric : geometric;
1419
+ }
1420
+ function turnoutOccupiedSpan(input) {
1421
+ const e = input.extent;
1422
+ if (!e) return null;
1423
+ const a = input.pos - input.facing * e.behindPoints;
1424
+ const b = input.pos + input.facing * e.aheadOfPoints;
1425
+ return { fromPos: Math.min(a, b), toPos: Math.max(a, b) };
1426
+ }
1427
+ function resizeFlexPiece(pieces, index, nextLengthInches) {
1428
+ const piece = pieces[index];
1429
+ const next = pieces[index + 1];
1430
+ if (!piece || !next) return null;
1431
+ if (piece.toEnd !== "piece") return null;
1432
+ if (!Number.isFinite(nextLengthInches)) return null;
1433
+ const pair = piece.lengthInches + next.lengthInches;
1434
+ if (pair < 2 * FLEX_MIN_PIECE_INCHES) return null;
1435
+ const want = Math.max(
1436
+ FLEX_MIN_PIECE_INCHES,
1437
+ Math.min(pair - FLEX_MIN_PIECE_INCHES, nextLengthInches)
1438
+ );
1439
+ const moved = piece.fromPos + want;
1440
+ 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);
1441
+ }
1442
+ function flexUsage(pieces) {
1443
+ return {
1444
+ pieces: pieces.length,
1445
+ totalInches: pieces.reduce((a, p) => a + p.lengthInches, 0),
1446
+ overlong: pieces.filter((p) => p.overlong).length
1447
+ };
1448
+ }
1281
1449
  var TIE_HALF_LENGTH_INCHES = 0.319;
1282
1450
  function partExtent(part) {
1283
1451
  const pts = part?.pointsOffset;
@@ -2173,6 +2341,6 @@ function poseOverridesFromDoc(doc) {
2173
2341
  return out;
2174
2342
  }
2175
2343
 
2176
- export { ATLAS_CODE55_N, BUILT_IN_TRACK_PARTS, CODE55_RAIL_HEIGHT_INCHES, ENDPLATE_FASCIA_CLEAR_INCHES, ENDPLATE_LEAD_INCHES, FREEMO_ENDPLATE_TRACK_FASCIA_CLEARANCE_INCHES, FREEMO_ENDPLATE_WIDTH_MIN_INCHES, FREEMO_ENDPLATE_WIDTH_RECOMMENDED_INCHES, FREEMO_TRACK_SPACING_INCHES, MAIN2_TRACK_ID, MAIN_TRACK_ID, N_CAR_LENGTH_INCHES, N_SCALE_RATIO, RAIL_GAUGE_INCHES, TIE_HALF_LENGTH_INCHES, TURNOUT_LEAD_INCHES_PER_FROG, WHOLE_MODULE_SECTION_ID, asModuleSchematic, benchworkBand, benchworkOutline, buildCrossover, buildPassingSiding, buildTransition, carCapacity, checkEndplateWidth, deriveEndplatePoses, divergeSideForHand, docToState, emptyEditorState, endplateCentreOffsetInches, endplateFaceSegments, endplateLead, endplateTrackOffsetInches, endplateWidthInches, frogCasting, frogNumberFromName, fromSectionRelative, geometryTurnDegrees, hasNoFarEndplate, importedPartToTrackPart, inchesToScaleFeet, isLoopDoc, isTransitionTurnout, leadInchesForSize, mergeImportedParts, mergeStoredParts, moduleCenterline, moduleFeatures, moduleFootprint, moduleLengthFromSections, moduleSections, nextId, parseXtpLibrary, partExtent, partExtentForSize, partOutlineAtFrog, pastFrogInchesForSize, pathLengthInches, poseNeedsManual, poseOverridesFromDoc, remapPos, returnLoop, sampleBenchworkOutline, samplePartSegments, samplePath, scaleFeetToInches, sectionAdjacency, sectionBand, sectionBreaksFromSections, sectionComponents, sectionFootprints, sectionNeighbours, sectionSpans, sectionSpansOrWhole, sectionedCenterline, sectionedEndPose, sliceCenterline, stateToDoc, storedPartToTrackPart, toSectionRelative, trackMeetsEndplateIssues, trackPart, trackPath, turnoutClosure, turnoutPartForSize };
2344
+ export { ATLAS_CODE55_N, BUILT_IN_TRACK_PARTS, CODE55_RAIL_HEIGHT_INCHES, DEFAULT_FLEX_PART_ID, ENDPLATE_FASCIA_CLEAR_INCHES, ENDPLATE_LEAD_INCHES, FLEX_TRACK_PARTS, FREEMO_ENDPLATE_TRACK_FASCIA_CLEARANCE_INCHES, FREEMO_ENDPLATE_WIDTH_MIN_INCHES, FREEMO_ENDPLATE_WIDTH_RECOMMENDED_INCHES, FREEMO_TRACK_SPACING_INCHES, MAIN2_TRACK_ID, MAIN_TRACK_ID, N_CAR_LENGTH_INCHES, N_SCALE_RATIO, RAIL_GAUGE_INCHES, TIE_HALF_LENGTH_INCHES, TURNOUT_LEAD_INCHES_PER_FROG, WHOLE_MODULE_SECTION_ID, asModuleSchematic, benchworkBand, benchworkOutline, buildCrossover, buildPassingSiding, buildTransition, carCapacity, checkEndplateWidth, deriveEndplatePoses, divergeSideForHand, docToState, emptyEditorState, endplateCentreOffsetInches, endplateFaceSegments, endplateLead, endplateTrackOffsetInches, endplateWidthInches, flexPartFor, flexParts, flexPieces, flexUsage, frogCasting, frogNumberFromName, fromSectionRelative, geometryTurnDegrees, hasNoFarEndplate, importedPartToTrackPart, inchesToScaleFeet, isLoopDoc, isTransitionTurnout, leadInchesForSize, maxFlexPieceInches, mergeImportedParts, mergeStoredParts, moduleCenterline, moduleFeatures, moduleFootprint, moduleLengthFromSections, moduleSections, nextId, parseXtpLibrary, partExtent, partExtentForSize, partOutlineAtFrog, pastFrogInchesForSize, pathLengthInches, poseNeedsManual, poseOverridesFromDoc, remapPos, resizeFlexPiece, returnLoop, sampleBenchworkOutline, samplePartSegments, samplePath, scaleFeetToInches, sectionAdjacency, sectionBand, sectionBreaksFromSections, sectionComponents, sectionFootprints, sectionNeighbours, sectionSpans, sectionSpansOrWhole, sectionedCenterline, sectionedEndPose, sliceCenterline, spanOverhang, stateToDoc, storedPartToTrackPart, toSectionRelative, trackMeetsEndplateIssues, trackPart, trackPath, turnoutClosure, turnoutFacing, turnoutOccupiedSpan, turnoutPartForSize };
2177
2345
  //# sourceMappingURL=index.js.map
2178
2346
  //# sourceMappingURL=index.js.map