@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.js CHANGED
@@ -469,8 +469,12 @@ function moduleFootprint(input) {
469
469
  const widthA = endplateWidthFor(input.endplateWidths, "A");
470
470
  const widthB = endplateWidthFor(input.endplateWidths, "B");
471
471
  const authored = benchworkOutline(input);
472
- const offA = input.endplateTrackOffsets?.["A"] ?? 0;
473
- const offB = input.endplateTrackOffsets?.["B"] ?? 0;
472
+ const offOf = (i, id) => input.endplateTrackOffsets?.[id] ?? endplateCentreOffsetInches({
473
+ config: input.endplateConfigs?.[i],
474
+ main2Below: input.mainsSwapped === true
475
+ });
476
+ const offA = offOf(0, "A");
477
+ const offB = offOf(1, "B");
474
478
  const secs = input.sections ?? [];
475
479
  const sectionsOwnShape = secs.length > 1 || secs.some((s) => (s.outline?.length ?? 0) >= 3);
476
480
  const sectionOutlines = sectionsOwnShape ? sectionFootprints(input, {
@@ -494,13 +498,19 @@ function moduleFootprint(input) {
494
498
  sectionOutlines
495
499
  };
496
500
  }
497
- function endplateTrackOffsetFor(config, authoredTrackOffset) {
498
- const v = -endplateTrackOffsetInches(authoredTrackOffset, config);
501
+ function endplateCentreOffsetInches(input) {
502
+ const v = -endplateTrackOffsetInches(
503
+ input.authoredTrackOffsetInches,
504
+ input.config ?? void 0,
505
+ input.main2Below
506
+ );
499
507
  return v === 0 ? 0 : v;
500
508
  }
501
- function endplateTrackOffsetInches(authored, config) {
509
+ function endplateTrackOffsetInches(authored, config, main2Below = false) {
502
510
  if (typeof authored === "number" && Number.isFinite(authored)) return authored;
503
- return config === "double" ? -FREEMO_TRACK_SPACING_INCHES / 2 : 0;
511
+ if (config !== "double") return 0;
512
+ const half = FREEMO_TRACK_SPACING_INCHES / 2;
513
+ return main2Below ? half : -half;
504
514
  }
505
515
  function checkEndplateWidth(input) {
506
516
  const width = endplateWidthInches(input);
@@ -512,8 +522,13 @@ function checkEndplateWidth(input) {
512
522
  requiredInches: FREEMO_ENDPLATE_WIDTH_MIN_INCHES
513
523
  });
514
524
  }
515
- const off = endplateTrackOffsetInches(input.trackOffsetInches, input.config ?? void 0);
516
- const centres = input.config === "double" ? [off, off + FREEMO_TRACK_SPACING_INCHES] : [off];
525
+ const off = endplateTrackOffsetInches(
526
+ input.trackOffsetInches,
527
+ input.config ?? void 0,
528
+ input.main2Below
529
+ );
530
+ const second = off + (input.main2Below ? -1 : 1) * FREEMO_TRACK_SPACING_INCHES;
531
+ const centres = input.config === "double" ? [off, second] : [off];
517
532
  const worst = Math.max(...centres.map((c) => Math.abs(c)));
518
533
  const clearance = width / 2 - worst;
519
534
  if (clearance < FREEMO_ENDPLATE_TRACK_FASCIA_CLEARANCE_INCHES) {
@@ -524,6 +539,18 @@ function checkEndplateWidth(input) {
524
539
  requiredInches: required
525
540
  });
526
541
  }
542
+ if (input.config === "double") {
543
+ const mid = (off + second) / 2;
544
+ if (Math.abs(mid) > 0.01) {
545
+ issues.push({
546
+ code: "offcentre",
547
+ 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.`,
548
+ // Not a width problem — no wider plate fixes it — so hand back the
549
+ // width unchanged rather than imply one would.
550
+ requiredInches: width
551
+ });
552
+ }
553
+ }
527
554
  return issues;
528
555
  }
529
556
  var round2 = (n) => Math.round(n * 100) / 100;
@@ -583,6 +610,7 @@ function emptyEditorState(lengthInches) {
583
610
  crossings: [],
584
611
  branches: [],
585
612
  poseOverrides: {},
613
+ flexByTrack: {},
586
614
  endplateWidths: {},
587
615
  endplateTrackOffsets: {},
588
616
  outline: [],
@@ -684,6 +712,18 @@ function withWidths(endplates, widths, offsets = {}) {
684
712
  return out;
685
713
  });
686
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
+ }
687
727
  function stateToDoc(state, recordNumber) {
688
728
  return {
689
729
  version: 1,
@@ -737,7 +777,10 @@ function stateToDoc(state, recordNumber) {
737
777
  state.endplateWidths,
738
778
  state.endplateTrackOffsets
739
779
  ),
740
- 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, [
741
784
  state.loop ? (
742
785
  // The main runs the lead from A and turns back at the balloon.
743
786
  { id: MAIN_TRACK_ID, role: "main", lane: 0, fromPos: 0, toPos: state.lengthInches }
@@ -772,7 +815,7 @@ function stateToDoc(state, recordNumber) {
772
815
  ...state.loop && t.inLoop ? { inLoop: true } : {},
773
816
  ...t.path && t.path.length >= 2 ? { path: t.path } : {}
774
817
  }))
775
- ],
818
+ ]),
776
819
  turnouts: state.turnouts.map((t) => ({
777
820
  id: t.id,
778
821
  pos: t.pos,
@@ -914,6 +957,12 @@ function docToState(doc, fallbackLength, moduleTracks = []) {
914
957
  if (typeof e.trackOffsetInches === "number" && Number.isFinite(e.trackOffsetInches))
915
958
  endplateTrackOffsets[e.id] = e.trackOffsetInches;
916
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
+ }
917
966
  const outline = (d.outline ?? []).filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y)).map((p) => ({
918
967
  x: p.x,
919
968
  y: p.y,
@@ -950,6 +999,7 @@ function docToState(doc, fallbackLength, moduleTracks = []) {
950
999
  trackId: ep.trackId ?? null
951
1000
  })),
952
1001
  poseOverrides,
1002
+ flexByTrack,
953
1003
  endplateWidths,
954
1004
  endplateTrackOffsets,
955
1005
  outline,
@@ -1247,10 +1297,141 @@ var ATLAS_CODE55_N = [
1247
1297
  innerRadius: { inches: 15, source: "manufacturer", note: "Atlas product listing" }
1248
1298
  }
1249
1299
  ];
1250
- 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
+ }
1251
1340
  function trackPart(id, library = BUILT_IN_TRACK_PARTS) {
1252
1341
  return library.find((p) => p.id === id) ?? null;
1253
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
+ }
1254
1435
  var TIE_HALF_LENGTH_INCHES = 0.319;
1255
1436
  function partExtent(part) {
1256
1437
  const pts = part?.pointsOffset;
@@ -2146,6 +2327,6 @@ function poseOverridesFromDoc(doc) {
2146
2327
  return out;
2147
2328
  }
2148
2329
 
2149
- 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, endplateFaceSegments, endplateLead, endplateTrackOffsetFor, 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 };
2150
2331
  //# sourceMappingURL=index.js.map
2151
2332
  //# sourceMappingURL=index.js.map