@willcgage/module-schematic 0.83.2 → 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)),
@@ -2128,6 +2144,107 @@ function turnoutClosure(size, opts = {}) {
2128
2144
  }
2129
2145
  };
2130
2146
  }
2147
+ function partGeometryGap(part) {
2148
+ if (part.kind === "flex") return null;
2149
+ if (part.kind === "crossover")
2150
+ return "a crossover fixture builds one HALF of the assembly (piecesPerAssembly), so its published lengths describe a piece, not the finished part \u2014 the geometry of the whole crossover is not yet derivable from them";
2151
+ if (part.kind === "crossing") return "crossing geometry is not modelled yet";
2152
+ if (part.kind === "curved-turnout")
2153
+ return "a curved turnout needs both radii AND its points/frog landmarks; only the radii are published";
2154
+ if (!part.pointsOffset)
2155
+ return "no points offset \u2014 without it there is nowhere for the diverging route to begin";
2156
+ if (!part.overallLength) return "no overall length \u2014 the part has no end to put a joint on";
2157
+ if (part.frogNumber == null) return "no frog number \u2014 the diverging angle is unknown";
2158
+ return null;
2159
+ }
2160
+ function partGeometry(part, library = BUILT_IN_TRACK_PARTS) {
2161
+ if (partGeometryGap(part)) return null;
2162
+ if (part.kind === "flex") {
2163
+ return {
2164
+ joints: [
2165
+ { id: "a", role: "throat", x: 0, y: 0, angleDeg: 180 },
2166
+ { id: "b", role: "through", x: 0, y: 0, angleDeg: 0 }
2167
+ ],
2168
+ routes: [["a", "b"]],
2169
+ source: "derived",
2170
+ divergingEndMeasured: false
2171
+ };
2172
+ }
2173
+ const N = part.frogNumber;
2174
+ const points = part.pointsOffset;
2175
+ const overall = part.overallLength;
2176
+ const frog = part.frogOffset;
2177
+ const lead = part.lead?.inches ?? (frog ? frog.inches - points.inches : void 0);
2178
+ if (lead == null || !(lead > 0)) return null;
2179
+ const isWye = part.kind === "wye";
2180
+ const effN = isWye ? N * 2 : N;
2181
+ const closure = turnoutClosure(effN, { leadInches: lead });
2182
+ const divergingEnd = () => {
2183
+ const frogX = frog ? frog.inches : points.inches + lead;
2184
+ const slope = closure.frogSlope;
2185
+ const dir = 1 / Math.hypot(1, slope);
2186
+ if (part.divergingLength) {
2187
+ const L = part.divergingLength.inches;
2188
+ return {
2189
+ x: frogX + L * dir,
2190
+ y: closure.offsetAt(frogX - points.inches) + L * slope * dir,
2191
+ measured: true
2192
+ };
2193
+ }
2194
+ return {
2195
+ x: overall.inches,
2196
+ y: closure.offsetAt(overall.inches - points.inches),
2197
+ measured: false
2198
+ };
2199
+ };
2200
+ const weakest = (...ds) => {
2201
+ const rank = ["measured", "manufacturer", "derived", "unverified"];
2202
+ let worst = 0;
2203
+ for (const d of ds) if (d) worst = Math.max(worst, rank.indexOf(d.source));
2204
+ return rank[worst];
2205
+ };
2206
+ const de = divergingEnd();
2207
+ const legAngle = Math.atan(closure.frogSlope) * 180 / Math.PI;
2208
+ if (isWye) {
2209
+ return {
2210
+ joints: [
2211
+ { id: "throat", role: "throat", x: 0, y: 0, angleDeg: 180 },
2212
+ { id: "legA", role: "diverge", x: de.x, y: de.y, angleDeg: legAngle },
2213
+ { id: "legB", role: "divergeB", x: de.x, y: -de.y, angleDeg: -legAngle }
2214
+ ],
2215
+ routes: [
2216
+ ["throat", "legA"],
2217
+ ["throat", "legB"]
2218
+ ],
2219
+ source: weakest(points, overall, frog, part.divergingLength),
2220
+ divergingEndMeasured: de.measured
2221
+ };
2222
+ }
2223
+ return {
2224
+ joints: [
2225
+ { id: "throat", role: "throat", x: 0, y: 0, angleDeg: 180 },
2226
+ { id: "through", role: "through", x: overall.inches, y: 0, angleDeg: 0 },
2227
+ { id: "diverge", role: "diverge", x: de.x, y: de.y, angleDeg: legAngle }
2228
+ ],
2229
+ routes: [
2230
+ ["throat", "through"],
2231
+ ["throat", "diverge"]
2232
+ ],
2233
+ source: weakest(points, overall, frog, part.divergingLength),
2234
+ divergingEndMeasured: de.measured
2235
+ };
2236
+ }
2237
+ function partsPlaceable(library = BUILT_IN_TRACK_PARTS) {
2238
+ const placeable = [];
2239
+ const blocked = [];
2240
+ for (const p of library) {
2241
+ const why = partGeometryGap(p);
2242
+ if (why) blocked.push({ part: p, why });
2243
+ else if (partGeometry(p, library)) placeable.push(p);
2244
+ else blocked.push({ part: p, why: "dimensions present but inconsistent" });
2245
+ }
2246
+ return { placeable, blocked };
2247
+ }
2131
2248
  function frogCasting(cl, opts = {}) {
2132
2249
  const g = opts.gaugeInches ?? RAIL_GAUGE_INCHES;
2133
2250
  const w = opts.reachInches ?? g * 1.5;
@@ -2433,6 +2550,45 @@ function moduleFeatures(doc) {
2433
2550
  laneMax: Math.max(...allLanes)
2434
2551
  };
2435
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
+ }
2436
2592
  function returnLoop(shape, opts) {
2437
2593
  const L = Math.max(1, opts.leadInches);
2438
2594
  const R = Math.max(1, opts.radius);
@@ -2536,6 +2692,21 @@ function deriveEndplatePoses(geo) {
2536
2692
  const half = geo.trackHalfSpacingInches ?? 1;
2537
2693
  const cfg = (i) => geo.endplateConfigs?.[i] === "double" ? "double" : "single";
2538
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
+ }
2539
2710
  const o = geo.poseOverrides?.[p.id];
2540
2711
  return o ? { ...p, x: o.x, y: o.y, heading: norm360(o.heading), manual: true } : p;
2541
2712
  };
@@ -2747,6 +2918,7 @@ exports.divergeSideForHand = divergeSideForHand;
2747
2918
  exports.docToState = docToState;
2748
2919
  exports.emptyEditorState = emptyEditorState;
2749
2920
  exports.endplateCentreOffsetInches = endplateCentreOffsetInches;
2921
+ exports.endplateEdgePose = endplateEdgePose;
2750
2922
  exports.endplateFaceSegments = endplateFaceSegments;
2751
2923
  exports.endplateLead = endplateLead;
2752
2924
  exports.endplateTrackOffsetInches = endplateTrackOffsetInches;
@@ -2778,7 +2950,10 @@ exports.nextId = nextId;
2778
2950
  exports.parseXtpLibrary = parseXtpLibrary;
2779
2951
  exports.partExtent = partExtent;
2780
2952
  exports.partExtentForSize = partExtentForSize;
2953
+ exports.partGeometry = partGeometry;
2954
+ exports.partGeometryGap = partGeometryGap;
2781
2955
  exports.partOutlineAtFrog = partOutlineAtFrog;
2956
+ exports.partsPlaceable = partsPlaceable;
2782
2957
  exports.pastFrogInchesForSize = pastFrogInchesForSize;
2783
2958
  exports.pathLengthInches = pathLengthInches;
2784
2959
  exports.poseNeedsManual = poseNeedsManual;