@willcgage/module-schematic 0.71.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.js CHANGED
@@ -610,6 +610,7 @@ function emptyEditorState(lengthInches) {
610
610
  crossings: [],
611
611
  branches: [],
612
612
  poseOverrides: {},
613
+ flexByTrack: {},
613
614
  endplateWidths: {},
614
615
  endplateTrackOffsets: {},
615
616
  outline: [],
@@ -711,6 +712,18 @@ function withWidths(endplates, widths, offsets = {}) {
711
712
  return out;
712
713
  });
713
714
  }
715
+ function withFlex(state, tracks) {
716
+ return tracks.map((t) => {
717
+ const f = state.flexByTrack?.[t.id];
718
+ if (!f) return t;
719
+ const cuts = f.cuts?.filter((c) => Number.isFinite(c)).sort((a, b) => a - b);
720
+ return {
721
+ ...t,
722
+ ...f.partId ? { flexPartId: f.partId } : {},
723
+ ...cuts ? { flexCuts: cuts } : {}
724
+ };
725
+ });
726
+ }
714
727
  function stateToDoc(state, recordNumber) {
715
728
  return {
716
729
  version: 1,
@@ -764,7 +777,10 @@ function stateToDoc(state, recordNumber) {
764
777
  state.endplateWidths,
765
778
  state.endplateTrackOffsets
766
779
  ),
767
- tracks: [
780
+ // Flex settings are attached to every track at once, at the end — the array
781
+ // below has half a dozen branches and adding two fields to each of them is
782
+ // how they'd end up disagreeing (#193).
783
+ tracks: withFlex(state, [
768
784
  state.loop ? (
769
785
  // The main runs the lead from A and turns back at the balloon.
770
786
  { id: MAIN_TRACK_ID, role: "main", lane: 0, fromPos: 0, toPos: state.lengthInches }
@@ -799,7 +815,7 @@ function stateToDoc(state, recordNumber) {
799
815
  ...state.loop && t.inLoop ? { inLoop: true } : {},
800
816
  ...t.path && t.path.length >= 2 ? { path: t.path } : {}
801
817
  }))
802
- ],
818
+ ]),
803
819
  turnouts: state.turnouts.map((t) => ({
804
820
  id: t.id,
805
821
  pos: t.pos,
@@ -941,6 +957,12 @@ function docToState(doc, fallbackLength, moduleTracks = []) {
941
957
  if (typeof e.trackOffsetInches === "number" && Number.isFinite(e.trackOffsetInches))
942
958
  endplateTrackOffsets[e.id] = e.trackOffsetInches;
943
959
  }
960
+ const flexByTrack = {};
961
+ for (const t of d.tracks ?? []) {
962
+ const partId = typeof t.flexPartId === "string" && t.flexPartId ? t.flexPartId : void 0;
963
+ const cuts = Array.isArray(t.flexCuts) ? t.flexCuts.filter((c) => Number.isFinite(c)).map(sc).sort((a, b) => a - b) : void 0;
964
+ if (partId || cuts) flexByTrack[t.id] = { ...partId ? { partId } : {}, ...cuts ? { cuts } : {} };
965
+ }
944
966
  const outline = (d.outline ?? []).filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y)).map((p) => ({
945
967
  x: p.x,
946
968
  y: p.y,
@@ -977,6 +999,7 @@ function docToState(doc, fallbackLength, moduleTracks = []) {
977
999
  trackId: ep.trackId ?? null
978
1000
  })),
979
1001
  poseOverrides,
1002
+ flexByTrack,
980
1003
  endplateWidths,
981
1004
  endplateTrackOffsets,
982
1005
  outline,
@@ -1274,10 +1297,141 @@ var ATLAS_CODE55_N = [
1274
1297
  innerRadius: { inches: 15, source: "manufacturer", note: "Atlas product listing" }
1275
1298
  }
1276
1299
  ];
1277
- var BUILT_IN_TRACK_PARTS = [...ATLAS_CODE55_N];
1300
+ var FLEX_TRACK_PARTS = [
1301
+ {
1302
+ id: "atlas-c55-n-flex",
1303
+ manufacturer: "Atlas",
1304
+ line: "Code 55",
1305
+ scale: "N",
1306
+ name: "Code 55 Flex Track",
1307
+ kind: "flex",
1308
+ overallLength: {
1309
+ inches: 30,
1310
+ source: "manufacturer",
1311
+ note: "nominal product length, reported by Will Gage 2026-07-26"
1312
+ }
1313
+ },
1314
+ {
1315
+ id: "me-c55-n-flex",
1316
+ manufacturer: "Micro Engineering",
1317
+ line: "Code 55",
1318
+ scale: "N",
1319
+ name: "Code 55 Flex Track",
1320
+ kind: "flex",
1321
+ overallLength: {
1322
+ inches: 36,
1323
+ source: "manufacturer",
1324
+ note: "nominal product length, reported by Will Gage 2026-07-26"
1325
+ }
1326
+ }
1327
+ ];
1328
+ var DEFAULT_FLEX_PART_ID = "atlas-c55-n-flex";
1329
+ var BUILT_IN_TRACK_PARTS = [...ATLAS_CODE55_N, ...FLEX_TRACK_PARTS];
1330
+ function flexParts(library = BUILT_IN_TRACK_PARTS) {
1331
+ return library.filter((p) => p.kind === "flex");
1332
+ }
1333
+ function flexPartFor(id, library = BUILT_IN_TRACK_PARTS) {
1334
+ const chosen = id ? library.find((p) => p.id === id && p.kind === "flex") : null;
1335
+ return chosen ?? library.find((p) => p.id === DEFAULT_FLEX_PART_ID) ?? flexParts(library)[0] ?? null;
1336
+ }
1337
+ function maxFlexPieceInches(id, library = BUILT_IN_TRACK_PARTS) {
1338
+ return flexPartFor(id, library)?.overallLength?.inches ?? 30;
1339
+ }
1278
1340
  function trackPart(id, library = BUILT_IN_TRACK_PARTS) {
1279
1341
  return library.find((p) => p.id === id) ?? null;
1280
1342
  }
1343
+ var FLEX_EPS = 1e-6;
1344
+ var FLEX_MIN_PIECE_INCHES = 1;
1345
+ function flexPieces(input) {
1346
+ const lo = Math.min(input.fromPos, input.toPos);
1347
+ const hi = Math.max(input.fromPos, input.toPos);
1348
+ if (!(hi - lo > FLEX_EPS)) return [];
1349
+ const max = input.maxPieceInches > FLEX_EPS ? input.maxPieceInches : Infinity;
1350
+ const blocks = (input.occupied ?? []).map((s) => ({
1351
+ from: Math.max(lo, Math.min(s.fromPos, s.toPos)),
1352
+ to: Math.min(hi, Math.max(s.fromPos, s.toPos))
1353
+ })).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);
1354
+ const merged = [];
1355
+ for (const b of blocks) {
1356
+ const last = merged[merged.length - 1];
1357
+ if (last && b.from <= last.to + FLEX_EPS) last.to = Math.max(last.to, b.to);
1358
+ else merged.push({ ...b });
1359
+ }
1360
+ const gaps = [];
1361
+ let cursor = lo;
1362
+ for (const b of merged) {
1363
+ if (b.from - cursor > FLEX_EPS) gaps.push({ from: cursor, to: b.from });
1364
+ cursor = Math.max(cursor, b.to);
1365
+ }
1366
+ if (hi - cursor > FLEX_EPS) gaps.push({ from: cursor, to: hi });
1367
+ const authored = input.cuts != null;
1368
+ const out = [];
1369
+ for (const g of gaps) {
1370
+ let inner;
1371
+ if (authored) {
1372
+ inner = [...new Set(input.cuts)].filter((c) => c > g.from + FLEX_EPS && c < g.to - FLEX_EPS).sort((a, b) => a - b);
1373
+ } else {
1374
+ inner = [];
1375
+ for (let at = g.from + max; at < g.to - FLEX_EPS; at += max) inner.push(at);
1376
+ const tail = g.to - (inner[inner.length - 1] ?? g.from);
1377
+ if (inner.length && tail < FLEX_MIN_PIECE_INCHES) {
1378
+ const start = inner.length >= 2 ? inner[inner.length - 2] : g.from;
1379
+ inner[inner.length - 1] = start + (g.to - start) / 2;
1380
+ }
1381
+ }
1382
+ const bounds = [g.from, ...inner, g.to];
1383
+ for (let i = 0; i < bounds.length - 1; i++) {
1384
+ const from = bounds[i];
1385
+ const to = bounds[i + 1];
1386
+ out.push({
1387
+ index: out.length,
1388
+ fromPos: from,
1389
+ toPos: to,
1390
+ lengthInches: to - from,
1391
+ overlong: to - from > max + FLEX_EPS,
1392
+ // The run's own ends are runEnd; anything else is a part body, except
1393
+ // where a cut put a neighbouring piece there.
1394
+ fromEnd: i > 0 ? "piece" : from <= lo + FLEX_EPS ? "runEnd" : "part",
1395
+ toEnd: i < bounds.length - 2 ? "piece" : to >= hi - FLEX_EPS ? "runEnd" : "part"
1396
+ });
1397
+ }
1398
+ }
1399
+ return out;
1400
+ }
1401
+ function turnoutFacing(input) {
1402
+ const far = input.divergeFarPos;
1403
+ const geometric = typeof far === "number" && Number.isFinite(far) ? Math.sign(far - input.pos) || 1 : 1;
1404
+ return input.flipped ? -geometric : geometric;
1405
+ }
1406
+ function turnoutOccupiedSpan(input) {
1407
+ const e = input.extent;
1408
+ if (!e) return null;
1409
+ const a = input.pos - input.facing * e.behindPoints;
1410
+ const b = input.pos + input.facing * e.aheadOfPoints;
1411
+ return { fromPos: Math.min(a, b), toPos: Math.max(a, b) };
1412
+ }
1413
+ function resizeFlexPiece(pieces, index, nextLengthInches) {
1414
+ const piece = pieces[index];
1415
+ const next = pieces[index + 1];
1416
+ if (!piece || !next) return null;
1417
+ if (piece.toEnd !== "piece") return null;
1418
+ if (!Number.isFinite(nextLengthInches)) return null;
1419
+ const pair = piece.lengthInches + next.lengthInches;
1420
+ if (pair < 2 * FLEX_MIN_PIECE_INCHES) return null;
1421
+ const want = Math.max(
1422
+ FLEX_MIN_PIECE_INCHES,
1423
+ Math.min(pair - FLEX_MIN_PIECE_INCHES, nextLengthInches)
1424
+ );
1425
+ const moved = piece.fromPos + want;
1426
+ 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);
1427
+ }
1428
+ function flexUsage(pieces) {
1429
+ return {
1430
+ pieces: pieces.length,
1431
+ totalInches: pieces.reduce((a, p) => a + p.lengthInches, 0),
1432
+ overlong: pieces.filter((p) => p.overlong).length
1433
+ };
1434
+ }
1281
1435
  var TIE_HALF_LENGTH_INCHES = 0.319;
1282
1436
  function partExtent(part) {
1283
1437
  const pts = part?.pointsOffset;
@@ -2173,6 +2327,6 @@ function poseOverridesFromDoc(doc) {
2173
2327
  return out;
2174
2328
  }
2175
2329
 
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 };
2330
+ 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, stateToDoc, storedPartToTrackPart, toSectionRelative, trackMeetsEndplateIssues, trackPart, trackPath, turnoutClosure, turnoutFacing, turnoutOccupiedSpan, turnoutPartForSize };
2177
2331
  //# sourceMappingURL=index.js.map
2178
2332
  //# sourceMappingURL=index.js.map