@willcgage/module-schematic 0.108.0 → 0.110.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
@@ -2929,27 +2929,49 @@ function graphToDoc(pieces, input) {
2929
2929
  const epA = endplates[0];
2930
2930
  const epB = endplates[1];
2931
2931
  const main = walk.routes.find((r) => r.id === "main");
2932
- const walk2 = input.start2 ? walkTrackGraph(graph, pieces, input.start2, library) : null;
2933
- const main2 = walk2?.routes.find((r) => r.id === "main") ?? null;
2932
+ const start2Key = input.start2 ? `${input.start2.piece}.${input.start2.joint}` : null;
2933
+ const start2IsOpenEnd = !!start2Key && graph.open.includes(start2Key);
2934
+ const walk2 = input.start2 && start2IsOpenEnd ? walkTrackGraph(graph, pieces, input.start2, library) : null;
2935
+ const main2FromBranch = (() => {
2936
+ if (!start2Key || start2IsOpenEnd) return null;
2937
+ const conn = graph.connections.find((c) => c.a === start2Key || c.b === start2Key);
2938
+ if (!conn) return null;
2939
+ const otherKey = conn.a === start2Key ? conn.b : conn.a;
2940
+ const piece = graph.joints.find((j) => j.key === otherKey)?.piece;
2941
+ return walk.routes.find((r) => r.id !== "main" && r.pieces[0] === piece) ?? null;
2942
+ })();
2943
+ const main2 = walk2?.routes.find((r) => r.id === "main") ?? main2FromBranch;
2934
2944
  const sharedByDesign = (id) => {
2935
2945
  const piece = pieces.find((p) => p.id === id);
2936
2946
  return library.find((p) => p.id === piece?.partId)?.kind === "crossover";
2937
2947
  };
2938
- const claimed = new Set(walk.routes.flatMap((r) => r.pieces).filter((p) => !sharedByDesign(p)));
2939
- if (main2 && main2.pieces.some((p) => claimed.has(p))) {
2948
+ const mainPieces = new Set(main.pieces.filter((p) => !sharedByDesign(p)));
2949
+ if (!main2FromBranch && main2 && main2.pieces.some((p) => mainPieces.has(p))) {
2940
2950
  warnings.push(
2941
2951
  "Main 2 starts on track that Main 1 already runs along \u2014 they are one run, not two"
2942
2952
  );
2943
2953
  }
2944
- const twoMains = !!main2 && !main2.pieces.some((p) => claimed.has(p));
2945
- const main2Lane = twoMains ? (graph.joints.find((j) => j.key === `${input.start2.piece}.${input.start2.joint}`)?.y ?? 1) >= 0 ? 1 : -1 : 0;
2954
+ const twoMains = !!main2 && (!!main2FromBranch || !main2.pieces.some((p) => mainPieces.has(p)));
2955
+ const main2AsBranch = walk2 && main2 && twoMains ? walk.routes.find(
2956
+ (r) => r.id !== "main" && r.pieces.some((p) => main2.pieces.includes(p))
2957
+ ) ?? null : null;
2958
+ const main2Lane = twoMains ? (
2959
+ // A branch-born main reaches its lane after an easement, so its SIDE is
2960
+ // how far the run gets laterally, not where its first joint sits.
2961
+ (main2FromBranch ? main2FromBranch.lateral : graph.joints.find((j) => j.key === start2Key)?.y ?? 1) >= 0 ? 1 : -1
2962
+ ) : 0;
2946
2963
  const lengthInches = round(Math.max(main.toPos, twoMains ? main2.toPos : 0));
2947
2964
  const branches = [
2948
- ...walk.routes.filter((r) => r.id !== "main" && r.pieces.length),
2949
- ...twoMains ? walk2.routes.filter((r) => r.id !== "main" && r.pieces.length).map((r) => ({ ...r, id: `main2:${r.id}`, bornAt: r.bornAt })) : []
2965
+ ...walk.routes.filter(
2966
+ (r) => r.id !== "main" && r.pieces.length && r.id !== main2FromBranch?.id && r.id !== main2AsBranch?.id
2967
+ ),
2968
+ ...walk2 ? walk2.routes.filter((r) => r.id !== "main" && r.pieces.length).map((r) => ({ ...r, id: `main2:${r.id}`, bornAt: r.bornAt })) : []
2950
2969
  ];
2951
2970
  const trackIdOf = /* @__PURE__ */ new Map([["main", MAIN_TRACK_ID]]);
2952
- if (twoMains) trackIdOf.set("main2:main", MAIN2_TRACK_ID);
2971
+ if (twoMains) {
2972
+ trackIdOf.set(main2FromBranch ? main2FromBranch.id : "main2:main", MAIN2_TRACK_ID);
2973
+ if (main2AsBranch) trackIdOf.set(main2AsBranch.id, MAIN2_TRACK_ID);
2974
+ }
2953
2975
  for (const r of branches) trackIdOf.set(r.id, r.pieces[0]);
2954
2976
  const emitted = /* @__PURE__ */ new Set();
2955
2977
  const uniqueBranches = branches.filter((r) => {
@@ -2980,8 +3002,11 @@ function graphToDoc(pieces, input) {
2980
3002
  id: MAIN2_TRACK_ID,
2981
3003
  role: "main",
2982
3004
  lane: main2Lane,
2983
- from: epA?.id ?? "A",
2984
- ...epB ? { to: epB.id } : {},
3005
+ // ⚠️ Only a main that CROSSES the endplates says so. A transition
3006
+ // module's second main begins at a turnout partway along and ends
3007
+ // at one endplate or neither; claiming A→B would tell the catalogue
3008
+ // it presents two tracks at an end where it presents one.
3009
+ ...main2FromBranch ? {} : { from: epA?.id ?? "A", ...epB ? { to: epB.id } : {} },
2985
3010
  // ⚠️ Its own extent, because a second main need not run the whole
2986
3011
  // module: on a transition module it starts or stops partway.
2987
3012
  fromPos: round(Math.min(main2.fromPos, main2.toPos)),
@@ -3026,7 +3051,7 @@ function graphToDoc(pieces, input) {
3026
3051
  const turnouts = [];
3027
3052
  const allTurnouts = [
3028
3053
  ...walk.turnouts,
3029
- ...twoMains ? walk2.turnouts.map((t) => ({
3054
+ ...walk2 ? walk2.turnouts.map((t) => ({
3030
3055
  ...t,
3031
3056
  onRoute: t.onRoute === "main" ? "main2:main" : `main2:${t.onRoute}`,
3032
3057
  divergeRoute: t.divergeRoute ? `main2:${t.divergeRoute}` : null
@@ -3056,7 +3081,7 @@ function graphToDoc(pieces, input) {
3056
3081
  const found = [];
3057
3082
  const routesAll = [
3058
3083
  ...walk.routes.map((r) => ({ r, prefix: "" })),
3059
- ...twoMains ? walk2.routes.map((r) => ({ r, prefix: "main2:" })) : []
3084
+ ...walk2 ? walk2.routes.map((r) => ({ r, prefix: "main2:" })) : []
3060
3085
  ];
3061
3086
  const seen = /* @__PURE__ */ new Map();
3062
3087
  for (const { r, prefix } of routesAll) {
@@ -3124,8 +3149,12 @@ function graphToDoc(pieces, input) {
3124
3149
  for (const t of tracks) {
3125
3150
  if (t.role === "main") continue;
3126
3151
  const ends = turnouts.filter((sw) => sw.divergeTrack === t.id);
3127
- if (ends.length >= 2 && new Set(ends.map((sw) => sw.onTrack)).size >= 2)
3128
- t.role = "crossover";
3152
+ if (!(ends.length >= 2 && new Set(ends.map((sw) => sw.onTrack)).size >= 2)) continue;
3153
+ t.role = "crossover";
3154
+ const at = ends.map((sw) => sw.pos);
3155
+ t.fromPos = round(Math.min(...at));
3156
+ t.toPos = round(Math.max(...at));
3157
+ t.lane = main2Lane || 1;
3129
3158
  }
3130
3159
  const place = (a, what) => {
3131
3160
  if (!a) return null;
@@ -3281,15 +3310,6 @@ function moduleConversionReport(doc, library = BUILT_IN_TRACK_PARTS) {
3281
3310
  candidates
3282
3311
  };
3283
3312
  });
3284
- const mainIds = new Set((doc.tracks ?? []).filter((t) => t.role === "main").map((t) => t.id));
3285
- for (const t of doc.turnouts ?? []) {
3286
- if (!mainIds.has(t.divergeTrack)) continue;
3287
- blockers.push({
3288
- kind: "turnout-opens-a-main",
3289
- ref: t.id,
3290
- why: `${t.name || t.id} opens ${t.divergeTrack}, which is a main \u2014 a main that begins at a turnout cannot be laid as pieces yet, and converting anyway would drop both it and the turnout`
3291
- });
3292
- }
3293
3313
  for (const c of doc.crossings ?? [])
3294
3314
  blockers.push({
3295
3315
  kind: "crossing",
@@ -3401,7 +3421,12 @@ function docToGraph(doc, answers = {}, library = BUILT_IN_TRACK_PARTS) {
3401
3421
  const ends = [branch?.fromPos, branch?.toPos].filter(
3402
3422
  (n) => typeof n === "number" && Number.isFinite(n)
3403
3423
  );
3404
- const divergeFarPos = ends.length ? ends.reduce((a, b) => Math.abs(b - t.pos) > Math.abs(a - t.pos) ? b : a) : void 0;
3424
+ const divergeFarPos = (
3425
+ // ⭐ A crossover half diverges TOWARD its partner. Its diverging track is a
3426
+ // MAIN, whose ends are the whole module, so the ordinary rule has nothing
3427
+ // useful to sign and would point it away from the turnout it feeds.
3428
+ divergeFarOverride.get(t.id) ?? (ends.length ? ends.reduce((a, b) => Math.abs(b - t.pos) > Math.abs(a - t.pos) ? b : a) : void 0)
3429
+ );
3405
3430
  const facing = turnoutFacing({
3406
3431
  pos: t.pos,
3407
3432
  divergeFarPos,
@@ -3518,18 +3543,69 @@ function docToGraph(doc, answers = {}, library = BUILT_IN_TRACK_PARTS) {
3518
3543
  fromPos: a.x0,
3519
3544
  toPos: a.x0 + a.geom.lengthInches
3520
3545
  }));
3546
+ const mainIdsAll = new Set(mains.map((m) => m.id));
3547
+ const attachment = /* @__PURE__ */ new Map();
3548
+ for (const t of turnouts) {
3549
+ const m = mains.find((x) => x.id === t.divergeTrack && x.id !== main.id);
3550
+ if (!m) continue;
3551
+ const from = m.fromPos ?? 0;
3552
+ const to = m.toPos ?? mainLen;
3553
+ if (from > 0 && Math.abs(t.pos - from) < 0.01) attachment.set(m.id, { sw: t, at: "from" });
3554
+ else if (Math.abs(t.pos - to) < 0.01) attachment.set(m.id, { sw: t, at: "to" });
3555
+ }
3556
+ const mainToMainPairs = [];
3557
+ {
3558
+ const isMain = (id) => mainIdsAll.has(id);
3559
+ const used = /* @__PURE__ */ new Set();
3560
+ for (const t of turnouts) {
3561
+ if (used.has(t.id) || !isMain(t.onTrack) || !isMain(t.divergeTrack)) continue;
3562
+ if (t.onTrack === t.divergeTrack) continue;
3563
+ const partner = turnouts.filter(
3564
+ (u) => !used.has(u.id) && u.id !== t.id && u.onTrack === t.divergeTrack && u.divergeTrack === t.onTrack
3565
+ ).sort((x, y) => Math.abs(x.pos - t.pos) - Math.abs(y.pos - t.pos))[0];
3566
+ if (!partner) continue;
3567
+ used.add(t.id);
3568
+ used.add(partner.id);
3569
+ mainToMainPairs.push({ a: t, b: partner });
3570
+ }
3571
+ }
3572
+ const divergeFarOverride = /* @__PURE__ */ new Map();
3573
+ for (const { a, b } of mainToMainPairs) {
3574
+ divergeFarOverride.set(a.id, b.pos);
3575
+ divergeFarOverride.set(b.id, a.pos);
3576
+ }
3521
3577
  for (const m of mains) {
3578
+ if (attachment.get(m.id)?.at === "from") continue;
3522
3579
  const y = laneOffsetAt(m.lane ?? 0, 0);
3523
3580
  const on = turnoutsOn(m.id, y);
3524
3581
  for (const l of on) {
3525
3582
  pieces.push(l.piece);
3526
3583
  laidTurnouts.set(l.t.id, l);
3527
3584
  }
3585
+ let endPos = m.toPos ?? mainLen;
3586
+ const closeAt = attachment.get(m.id);
3587
+ if (closeAt?.at === "to") {
3588
+ const l = laidTurnouts.get(closeAt.sw.id);
3589
+ const fj = l ? jointAt(l.piece, l.divergeId) : null;
3590
+ if (fj) {
3591
+ const closing = transition(
3592
+ `${m.id}-close`,
3593
+ { x: fj.x, y: fj.y, headingDeg: fj.headingDeg },
3594
+ y,
3595
+ -1
3596
+ );
3597
+ if (closing) {
3598
+ pieces.push(closing);
3599
+ const e = jointAt(closing, "b");
3600
+ if (e) endPos = e.x;
3601
+ } else endPos = fj.x;
3602
+ }
3603
+ }
3528
3604
  const laidFlex = layFlex(
3529
3605
  m.id,
3530
3606
  y,
3531
3607
  m.fromPos ?? 0,
3532
- m.toPos ?? mainLen,
3608
+ endPos,
3533
3609
  [...on.map((l) => l.span), ...assemblySpans],
3534
3610
  m.flexCuts
3535
3611
  );
@@ -3551,6 +3627,35 @@ function docToGraph(doc, answers = {}, library = BUILT_IN_TRACK_PARTS) {
3551
3627
  done.add(m.id);
3552
3628
  }
3553
3629
  for (const id of assemblyTracks) done.add(id);
3630
+ for (const [i, pair] of mainToMainPairs.entries()) {
3631
+ const la = laidTurnouts.get(pair.a.id);
3632
+ const lb = laidTurnouts.get(pair.b.id);
3633
+ if (!la || !lb) continue;
3634
+ const ja = jointAt(la.piece, la.divergeId);
3635
+ const jb = jointAt(lb.piece, lb.divergeId);
3636
+ if (!ja || !jb) continue;
3637
+ const dx = jb.x - ja.x;
3638
+ const dy = jb.y - ja.y;
3639
+ const len = Math.hypot(dx, dy);
3640
+ if (!(len > 1e-6)) {
3641
+ notLaid.push({
3642
+ id: pair.a.divergeTrack,
3643
+ why: `${pair.a.name || pair.a.id} and ${pair.b.name || pair.b.id} meet at the same point, so there is no track between them to lay`
3644
+ });
3645
+ continue;
3646
+ }
3647
+ pieces.push({
3648
+ id: `xc-${i}`,
3649
+ partId: flexId,
3650
+ x: ja.x,
3651
+ y: ja.y,
3652
+ rotationDeg: Math.atan2(dy, dx) * 180 / Math.PI,
3653
+ lengthInches: r3(len)
3654
+ });
3655
+ warnings.push(
3656
+ `${pair.a.name || pair.a.id} and ${pair.b.name || pair.b.id} are laid as two separate turnouts with a ${len.toFixed(1)}\u2033 piece of track between them, which is what the document describes. If they are really one crossover assembly, name the product on a connector track and it will be laid as the single piece it is.`
3657
+ );
3658
+ }
3554
3659
  let progressed = true;
3555
3660
  while (progressed) {
3556
3661
  progressed = false;
@@ -3663,13 +3768,19 @@ function docToGraph(doc, answers = {}, library = BUILT_IN_TRACK_PARTS) {
3663
3768
  };
3664
3769
  const startAt = startOf(main);
3665
3770
  if (!startAt) return refuse("nothing was laid on the mainline, so the module has no starting point");
3666
- const second = mains[1] ? startOf(mains[1]) : null;
3667
- return {
3668
- graph: { pieces, startAt, ...second ? { start2: second } : {} },
3669
- refused: null,
3670
- notLaid,
3671
- warnings
3672
- };
3771
+ const second = mains[1] ? attachment.get(mains[1].id)?.at === "from" ? (() => {
3772
+ const l = laidTurnouts.get(attachment.get(mains[1].id).sw.id);
3773
+ return l ? { piece: l.piece.id, joint: l.divergeId } : null;
3774
+ })() : startOf(mains[1]) : null;
3775
+ const graph = { pieces, startAt, ...second ? { start2: second } : {} };
3776
+ const derived = graphToDoc(pieces, {
3777
+ startAt,
3778
+ start2: second ?? null,
3779
+ base: doc,
3780
+ library
3781
+ });
3782
+ warnings.push(...derived.warnings);
3783
+ return { graph, refused: null, notLaid, warnings };
3673
3784
  }
3674
3785
  function frogCasting(cl, opts = {}) {
3675
3786
  const g = opts.gaugeInches ?? RAIL_GAUGE_INCHES;