@willcgage/module-schematic 0.84.0 → 0.85.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
@@ -871,6 +871,10 @@ function withPoses(endplates, overrides) {
871
871
  (e) => overrides[e.id] ? { ...e, pose: overrides[e.id], poseAuthored: true } : e
872
872
  );
873
873
  }
874
+ function withEdges(endplates, edges) {
875
+ if (!edges) return endplates;
876
+ return endplates.map((e) => edges[e.id] ? { ...e, edge: edges[e.id] } : e);
877
+ }
874
878
  function withWidths(endplates, widths, offsets = {}) {
875
879
  return endplates.map((e) => {
876
880
  const w = widths[e.id];
@@ -902,46 +906,49 @@ function stateToDoc(state, recordNumber) {
902
906
  ...state.loop && state.loopReturn === "main2" ? { loopReturn: "main2" } : {},
903
907
  ...state.mainsSwapped ? { mainsSwapped: true } : {},
904
908
  endplates: withWidths(
905
- withPoses(
906
- [
907
- ...state.loop ? (
908
- // Balloon loop: A is the entry. A standard endplate B on the balloon
909
- // makes it an INTERCHANGE (second route connects at the loop, e.g.
910
- // Seaford); configB "none" makes it a pure turnback.
911
- [
912
- { id: "A", label: "Entry", tracks: [{ trackId: MAIN_TRACK_ID, lane: 0, config: state.configA }] },
913
- ...state.configB !== "none" ? [{ id: "B", label: "Interchange", tracks: [{ trackId: MAIN_TRACK_ID, lane: 0, config: state.configB }] }] : []
914
- ]
915
- ) : [
916
- { id: "A", label: "West", tracks: [{ trackId: MAIN_TRACK_ID, lane: 0, config: state.configA }] },
917
- // ⚠️ `configB: "none"` means the module HAS NO FAR ENDPLATE, and
918
- // that is not loop-only (#184). An *end of the line* or a *pocket*
919
- // presents one conforming face and the track simply stops. The
920
- // standard governs the ends a module offers for joining; it never
921
- // required a module to offer two. This used to coerce "none" to
922
- // "single" and emit a B regardless, so a single-ended module was
923
- // impossible to author.
924
- ...state.configB !== "none" ? [
925
- {
926
- id: "B",
927
- label: "East",
928
- tracks: [{ trackId: MAIN_TRACK_ID, lane: 0, config: state.configB }]
929
- }
930
- ] : []
909
+ withEdges(
910
+ withPoses(
911
+ [
912
+ ...state.loop ? (
913
+ // Balloon loop: A is the entry. A standard endplate B on the balloon
914
+ // makes it an INTERCHANGE (second route connects at the loop, e.g.
915
+ // Seaford); configB "none" makes it a pure turnback.
916
+ [
917
+ { id: "A", label: "Entry", tracks: [{ trackId: MAIN_TRACK_ID, lane: 0, config: state.configA }] },
918
+ ...state.configB !== "none" ? [{ id: "B", label: "Interchange", tracks: [{ trackId: MAIN_TRACK_ID, lane: 0, config: state.configB }] }] : []
919
+ ]
920
+ ) : [
921
+ { id: "A", label: "West", tracks: [{ trackId: MAIN_TRACK_ID, lane: 0, config: state.configA }] },
922
+ // ⚠️ `configB: "none"` means the module HAS NO FAR ENDPLATE, and
923
+ // that is not loop-only (#184). An *end of the line* or a *pocket*
924
+ // presents one conforming face and the track simply stops. The
925
+ // standard governs the ends a module offers for joining; it never
926
+ // required a module to offer two. This used to coerce "none" to
927
+ // "single" and emit a B regardless, so a single-ended module was
928
+ // impossible to author.
929
+ ...state.configB !== "none" ? [
930
+ {
931
+ id: "B",
932
+ label: "East",
933
+ tracks: [{ trackId: MAIN_TRACK_ID, lane: 0, config: state.configB }]
934
+ }
935
+ ] : []
936
+ ],
937
+ // Branch endplates C, D, … — junction connections at pos, off one side
938
+ // (#170). A set can carry several (e.g. a second railroad through).
939
+ ...state.branches.map((b, i) => ({
940
+ id: String.fromCharCode(67 + i),
941
+ // C, D, E…
942
+ label: b.label || `Branch ${i + 1}`,
943
+ tracks: [{ trackId: MAIN_TRACK_ID, lane: 0, config: b.config }],
944
+ at: { pos: b.pos, side: b.side },
945
+ kind: b.kind ?? "branch",
946
+ ...b.trackId ? { trackId: b.trackId } : {}
947
+ }))
931
948
  ],
932
- // Branch endplates C, D, … — junction connections at pos, off one side
933
- // (#170). A set can carry several (e.g. a second railroad through).
934
- ...state.branches.map((b, i) => ({
935
- id: String.fromCharCode(67 + i),
936
- // C, D, E…
937
- label: b.label || `Branch ${i + 1}`,
938
- tracks: [{ trackId: MAIN_TRACK_ID, lane: 0, config: b.config }],
939
- at: { pos: b.pos, side: b.side },
940
- kind: b.kind ?? "branch",
941
- ...b.trackId ? { trackId: b.trackId } : {}
942
- }))
943
- ],
944
- state.poseOverrides
949
+ state.poseOverrides
950
+ ),
951
+ state.endplateEdges
945
952
  ),
946
953
  state.endplateWidths,
947
954
  state.endplateTrackOffsets
@@ -1137,7 +1144,15 @@ function docToState(doc, fallbackLength, moduleTracks = []) {
1137
1144
  const poseOverrides = poseOverridesFromDoc(d);
1138
1145
  const endplateWidths = {};
1139
1146
  const endplateTrackOffsets = {};
1147
+ const endplateEdges = {};
1140
1148
  for (const e of d.endplates ?? []) {
1149
+ if (e.edge && Number.isFinite(e.edge.index) && e.edge.index >= 0)
1150
+ endplateEdges[e.id] = {
1151
+ index: Math.trunc(e.edge.index),
1152
+ ...e.edge.section ? { section: e.edge.section } : {},
1153
+ ...Number.isFinite(e.edge.fromT) ? { fromT: e.edge.fromT } : {},
1154
+ ...Number.isFinite(e.edge.toT) ? { toT: e.edge.toT } : {}
1155
+ };
1141
1156
  if (typeof e.widthInches === "number" && e.widthInches > 0)
1142
1157
  endplateWidths[e.id] = e.widthInches;
1143
1158
  if (typeof e.trackOffsetInches === "number" && Number.isFinite(e.trackOffsetInches))
@@ -1188,6 +1203,7 @@ function docToState(doc, fallbackLength, moduleTracks = []) {
1188
1203
  flexByTrack,
1189
1204
  endplateWidths,
1190
1205
  endplateTrackOffsets,
1206
+ endplateEdges,
1191
1207
  outline,
1192
1208
  outlineInner,
1193
1209
  sectionBreaks: (d.sectionBreaks ?? []).filter((n) => Number.isFinite(n)).map((n) => sc(n)),
@@ -2534,6 +2550,45 @@ function moduleFeatures(doc) {
2534
2550
  laneMax: Math.max(...allLanes)
2535
2551
  };
2536
2552
  }
2553
+ function endplateEdgePose(outline, edge) {
2554
+ if (!outline || outline.length < 3) return null;
2555
+ const n = outline.length;
2556
+ const i = Math.trunc(edge.index);
2557
+ if (!Number.isFinite(i) || i < 0 || i >= n) return null;
2558
+ const p0 = outline[i];
2559
+ const p1 = outline[(i + 1) % n];
2560
+ if (p0.bulge) return null;
2561
+ const t0 = Math.max(0, Math.min(1, edge.fromT ?? 0));
2562
+ const t1 = Math.max(0, Math.min(1, edge.toT ?? 1));
2563
+ const a = { x: p0.x + (p1.x - p0.x) * Math.min(t0, t1), y: p0.y + (p1.y - p0.y) * Math.min(t0, t1) };
2564
+ const b = { x: p0.x + (p1.x - p0.x) * Math.max(t0, t1), y: p0.y + (p1.y - p0.y) * Math.max(t0, t1) };
2565
+ const w = Math.hypot(b.x - a.x, b.y - a.y);
2566
+ if (!(w > 0)) return null;
2567
+ let cx = 0;
2568
+ let cy = 0;
2569
+ for (const v of outline) {
2570
+ cx += v.x;
2571
+ cy += v.y;
2572
+ }
2573
+ cx /= n;
2574
+ cy /= n;
2575
+ const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
2576
+ const ex = (b.x - a.x) / w;
2577
+ const ey = (b.y - a.y) / w;
2578
+ let nx = ey;
2579
+ let ny = -ex;
2580
+ if ((mid.x - cx) * nx + (mid.y - cy) * ny < 0) {
2581
+ nx = -nx;
2582
+ ny = -ny;
2583
+ }
2584
+ return {
2585
+ x: mid.x,
2586
+ y: mid.y,
2587
+ heading: norm360(Math.atan2(ny, nx) * 180 / Math.PI),
2588
+ widthInches: w,
2589
+ face: [a, b]
2590
+ };
2591
+ }
2537
2592
  function returnLoop(shape, opts) {
2538
2593
  const L = Math.max(1, opts.leadInches);
2539
2594
  const R = Math.max(1, opts.radius);
@@ -2637,6 +2692,21 @@ function deriveEndplatePoses(geo) {
2637
2692
  const half = geo.trackHalfSpacingInches ?? 1;
2638
2693
  const cfg = (i) => geo.endplateConfigs?.[i] === "double" ? "double" : "single";
2639
2694
  const withOverride = (p) => {
2695
+ const bound = geo.endplateEdges?.[p.id];
2696
+ if (bound) {
2697
+ const outline = (bound.section ? geo.sections?.find((s) => s.id === bound.section)?.outline : geo.outline) ?? geo.outline;
2698
+ const e = endplateEdgePose(outline, bound);
2699
+ if (e)
2700
+ return {
2701
+ ...p,
2702
+ x: e.x,
2703
+ y: e.y,
2704
+ heading: e.heading,
2705
+ widthInches: e.widthInches,
2706
+ face: e.face,
2707
+ boundToEdge: true
2708
+ };
2709
+ }
2640
2710
  const o = geo.poseOverrides?.[p.id];
2641
2711
  return o ? { ...p, x: o.x, y: o.y, heading: norm360(o.heading), manual: true } : p;
2642
2712
  };
@@ -2848,6 +2918,7 @@ exports.divergeSideForHand = divergeSideForHand;
2848
2918
  exports.docToState = docToState;
2849
2919
  exports.emptyEditorState = emptyEditorState;
2850
2920
  exports.endplateCentreOffsetInches = endplateCentreOffsetInches;
2921
+ exports.endplateEdgePose = endplateEdgePose;
2851
2922
  exports.endplateFaceSegments = endplateFaceSegments;
2852
2923
  exports.endplateLead = endplateLead;
2853
2924
  exports.endplateTrackOffsetInches = endplateTrackOffsetInches;