@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.js CHANGED
@@ -869,6 +869,10 @@ function withPoses(endplates, overrides) {
869
869
  (e) => overrides[e.id] ? { ...e, pose: overrides[e.id], poseAuthored: true } : e
870
870
  );
871
871
  }
872
+ function withEdges(endplates, edges) {
873
+ if (!edges) return endplates;
874
+ return endplates.map((e) => edges[e.id] ? { ...e, edge: edges[e.id] } : e);
875
+ }
872
876
  function withWidths(endplates, widths, offsets = {}) {
873
877
  return endplates.map((e) => {
874
878
  const w = widths[e.id];
@@ -900,46 +904,49 @@ function stateToDoc(state, recordNumber) {
900
904
  ...state.loop && state.loopReturn === "main2" ? { loopReturn: "main2" } : {},
901
905
  ...state.mainsSwapped ? { mainsSwapped: true } : {},
902
906
  endplates: withWidths(
903
- withPoses(
904
- [
905
- ...state.loop ? (
906
- // Balloon loop: A is the entry. A standard endplate B on the balloon
907
- // makes it an INTERCHANGE (second route connects at the loop, e.g.
908
- // Seaford); configB "none" makes it a pure turnback.
909
- [
910
- { id: "A", label: "Entry", tracks: [{ trackId: MAIN_TRACK_ID, lane: 0, config: state.configA }] },
911
- ...state.configB !== "none" ? [{ id: "B", label: "Interchange", tracks: [{ trackId: MAIN_TRACK_ID, lane: 0, config: state.configB }] }] : []
912
- ]
913
- ) : [
914
- { id: "A", label: "West", tracks: [{ trackId: MAIN_TRACK_ID, lane: 0, config: state.configA }] },
915
- // ⚠️ `configB: "none"` means the module HAS NO FAR ENDPLATE, and
916
- // that is not loop-only (#184). An *end of the line* or a *pocket*
917
- // presents one conforming face and the track simply stops. The
918
- // standard governs the ends a module offers for joining; it never
919
- // required a module to offer two. This used to coerce "none" to
920
- // "single" and emit a B regardless, so a single-ended module was
921
- // impossible to author.
922
- ...state.configB !== "none" ? [
923
- {
924
- id: "B",
925
- label: "East",
926
- tracks: [{ trackId: MAIN_TRACK_ID, lane: 0, config: state.configB }]
927
- }
928
- ] : []
907
+ withEdges(
908
+ withPoses(
909
+ [
910
+ ...state.loop ? (
911
+ // Balloon loop: A is the entry. A standard endplate B on the balloon
912
+ // makes it an INTERCHANGE (second route connects at the loop, e.g.
913
+ // Seaford); configB "none" makes it a pure turnback.
914
+ [
915
+ { id: "A", label: "Entry", tracks: [{ trackId: MAIN_TRACK_ID, lane: 0, config: state.configA }] },
916
+ ...state.configB !== "none" ? [{ id: "B", label: "Interchange", tracks: [{ trackId: MAIN_TRACK_ID, lane: 0, config: state.configB }] }] : []
917
+ ]
918
+ ) : [
919
+ { id: "A", label: "West", tracks: [{ trackId: MAIN_TRACK_ID, lane: 0, config: state.configA }] },
920
+ // ⚠️ `configB: "none"` means the module HAS NO FAR ENDPLATE, and
921
+ // that is not loop-only (#184). An *end of the line* or a *pocket*
922
+ // presents one conforming face and the track simply stops. The
923
+ // standard governs the ends a module offers for joining; it never
924
+ // required a module to offer two. This used to coerce "none" to
925
+ // "single" and emit a B regardless, so a single-ended module was
926
+ // impossible to author.
927
+ ...state.configB !== "none" ? [
928
+ {
929
+ id: "B",
930
+ label: "East",
931
+ tracks: [{ trackId: MAIN_TRACK_ID, lane: 0, config: state.configB }]
932
+ }
933
+ ] : []
934
+ ],
935
+ // Branch endplates C, D, … — junction connections at pos, off one side
936
+ // (#170). A set can carry several (e.g. a second railroad through).
937
+ ...state.branches.map((b, i) => ({
938
+ id: String.fromCharCode(67 + i),
939
+ // C, D, E…
940
+ label: b.label || `Branch ${i + 1}`,
941
+ tracks: [{ trackId: MAIN_TRACK_ID, lane: 0, config: b.config }],
942
+ at: { pos: b.pos, side: b.side },
943
+ kind: b.kind ?? "branch",
944
+ ...b.trackId ? { trackId: b.trackId } : {}
945
+ }))
929
946
  ],
930
- // Branch endplates C, D, … — junction connections at pos, off one side
931
- // (#170). A set can carry several (e.g. a second railroad through).
932
- ...state.branches.map((b, i) => ({
933
- id: String.fromCharCode(67 + i),
934
- // C, D, E…
935
- label: b.label || `Branch ${i + 1}`,
936
- tracks: [{ trackId: MAIN_TRACK_ID, lane: 0, config: b.config }],
937
- at: { pos: b.pos, side: b.side },
938
- kind: b.kind ?? "branch",
939
- ...b.trackId ? { trackId: b.trackId } : {}
940
- }))
941
- ],
942
- state.poseOverrides
947
+ state.poseOverrides
948
+ ),
949
+ state.endplateEdges
943
950
  ),
944
951
  state.endplateWidths,
945
952
  state.endplateTrackOffsets
@@ -1135,7 +1142,15 @@ function docToState(doc, fallbackLength, moduleTracks = []) {
1135
1142
  const poseOverrides = poseOverridesFromDoc(d);
1136
1143
  const endplateWidths = {};
1137
1144
  const endplateTrackOffsets = {};
1145
+ const endplateEdges = {};
1138
1146
  for (const e of d.endplates ?? []) {
1147
+ if (e.edge && Number.isFinite(e.edge.index) && e.edge.index >= 0)
1148
+ endplateEdges[e.id] = {
1149
+ index: Math.trunc(e.edge.index),
1150
+ ...e.edge.section ? { section: e.edge.section } : {},
1151
+ ...Number.isFinite(e.edge.fromT) ? { fromT: e.edge.fromT } : {},
1152
+ ...Number.isFinite(e.edge.toT) ? { toT: e.edge.toT } : {}
1153
+ };
1139
1154
  if (typeof e.widthInches === "number" && e.widthInches > 0)
1140
1155
  endplateWidths[e.id] = e.widthInches;
1141
1156
  if (typeof e.trackOffsetInches === "number" && Number.isFinite(e.trackOffsetInches))
@@ -1186,6 +1201,7 @@ function docToState(doc, fallbackLength, moduleTracks = []) {
1186
1201
  flexByTrack,
1187
1202
  endplateWidths,
1188
1203
  endplateTrackOffsets,
1204
+ endplateEdges,
1189
1205
  outline,
1190
1206
  outlineInner,
1191
1207
  sectionBreaks: (d.sectionBreaks ?? []).filter((n) => Number.isFinite(n)).map((n) => sc(n)),
@@ -2126,6 +2142,107 @@ function turnoutClosure(size, opts = {}) {
2126
2142
  }
2127
2143
  };
2128
2144
  }
2145
+ function partGeometryGap(part) {
2146
+ if (part.kind === "flex") return null;
2147
+ if (part.kind === "crossover")
2148
+ 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";
2149
+ if (part.kind === "crossing") return "crossing geometry is not modelled yet";
2150
+ if (part.kind === "curved-turnout")
2151
+ return "a curved turnout needs both radii AND its points/frog landmarks; only the radii are published";
2152
+ if (!part.pointsOffset)
2153
+ return "no points offset \u2014 without it there is nowhere for the diverging route to begin";
2154
+ if (!part.overallLength) return "no overall length \u2014 the part has no end to put a joint on";
2155
+ if (part.frogNumber == null) return "no frog number \u2014 the diverging angle is unknown";
2156
+ return null;
2157
+ }
2158
+ function partGeometry(part, library = BUILT_IN_TRACK_PARTS) {
2159
+ if (partGeometryGap(part)) return null;
2160
+ if (part.kind === "flex") {
2161
+ return {
2162
+ joints: [
2163
+ { id: "a", role: "throat", x: 0, y: 0, angleDeg: 180 },
2164
+ { id: "b", role: "through", x: 0, y: 0, angleDeg: 0 }
2165
+ ],
2166
+ routes: [["a", "b"]],
2167
+ source: "derived",
2168
+ divergingEndMeasured: false
2169
+ };
2170
+ }
2171
+ const N = part.frogNumber;
2172
+ const points = part.pointsOffset;
2173
+ const overall = part.overallLength;
2174
+ const frog = part.frogOffset;
2175
+ const lead = part.lead?.inches ?? (frog ? frog.inches - points.inches : void 0);
2176
+ if (lead == null || !(lead > 0)) return null;
2177
+ const isWye = part.kind === "wye";
2178
+ const effN = isWye ? N * 2 : N;
2179
+ const closure = turnoutClosure(effN, { leadInches: lead });
2180
+ const divergingEnd = () => {
2181
+ const frogX = frog ? frog.inches : points.inches + lead;
2182
+ const slope = closure.frogSlope;
2183
+ const dir = 1 / Math.hypot(1, slope);
2184
+ if (part.divergingLength) {
2185
+ const L = part.divergingLength.inches;
2186
+ return {
2187
+ x: frogX + L * dir,
2188
+ y: closure.offsetAt(frogX - points.inches) + L * slope * dir,
2189
+ measured: true
2190
+ };
2191
+ }
2192
+ return {
2193
+ x: overall.inches,
2194
+ y: closure.offsetAt(overall.inches - points.inches),
2195
+ measured: false
2196
+ };
2197
+ };
2198
+ const weakest = (...ds) => {
2199
+ const rank = ["measured", "manufacturer", "derived", "unverified"];
2200
+ let worst = 0;
2201
+ for (const d of ds) if (d) worst = Math.max(worst, rank.indexOf(d.source));
2202
+ return rank[worst];
2203
+ };
2204
+ const de = divergingEnd();
2205
+ const legAngle = Math.atan(closure.frogSlope) * 180 / Math.PI;
2206
+ if (isWye) {
2207
+ return {
2208
+ joints: [
2209
+ { id: "throat", role: "throat", x: 0, y: 0, angleDeg: 180 },
2210
+ { id: "legA", role: "diverge", x: de.x, y: de.y, angleDeg: legAngle },
2211
+ { id: "legB", role: "divergeB", x: de.x, y: -de.y, angleDeg: -legAngle }
2212
+ ],
2213
+ routes: [
2214
+ ["throat", "legA"],
2215
+ ["throat", "legB"]
2216
+ ],
2217
+ source: weakest(points, overall, frog, part.divergingLength),
2218
+ divergingEndMeasured: de.measured
2219
+ };
2220
+ }
2221
+ return {
2222
+ joints: [
2223
+ { id: "throat", role: "throat", x: 0, y: 0, angleDeg: 180 },
2224
+ { id: "through", role: "through", x: overall.inches, y: 0, angleDeg: 0 },
2225
+ { id: "diverge", role: "diverge", x: de.x, y: de.y, angleDeg: legAngle }
2226
+ ],
2227
+ routes: [
2228
+ ["throat", "through"],
2229
+ ["throat", "diverge"]
2230
+ ],
2231
+ source: weakest(points, overall, frog, part.divergingLength),
2232
+ divergingEndMeasured: de.measured
2233
+ };
2234
+ }
2235
+ function partsPlaceable(library = BUILT_IN_TRACK_PARTS) {
2236
+ const placeable = [];
2237
+ const blocked = [];
2238
+ for (const p of library) {
2239
+ const why = partGeometryGap(p);
2240
+ if (why) blocked.push({ part: p, why });
2241
+ else if (partGeometry(p, library)) placeable.push(p);
2242
+ else blocked.push({ part: p, why: "dimensions present but inconsistent" });
2243
+ }
2244
+ return { placeable, blocked };
2245
+ }
2129
2246
  function frogCasting(cl, opts = {}) {
2130
2247
  const g = opts.gaugeInches ?? RAIL_GAUGE_INCHES;
2131
2248
  const w = opts.reachInches ?? g * 1.5;
@@ -2431,6 +2548,45 @@ function moduleFeatures(doc) {
2431
2548
  laneMax: Math.max(...allLanes)
2432
2549
  };
2433
2550
  }
2551
+ function endplateEdgePose(outline, edge) {
2552
+ if (!outline || outline.length < 3) return null;
2553
+ const n = outline.length;
2554
+ const i = Math.trunc(edge.index);
2555
+ if (!Number.isFinite(i) || i < 0 || i >= n) return null;
2556
+ const p0 = outline[i];
2557
+ const p1 = outline[(i + 1) % n];
2558
+ if (p0.bulge) return null;
2559
+ const t0 = Math.max(0, Math.min(1, edge.fromT ?? 0));
2560
+ const t1 = Math.max(0, Math.min(1, edge.toT ?? 1));
2561
+ const a = { x: p0.x + (p1.x - p0.x) * Math.min(t0, t1), y: p0.y + (p1.y - p0.y) * Math.min(t0, t1) };
2562
+ const b = { x: p0.x + (p1.x - p0.x) * Math.max(t0, t1), y: p0.y + (p1.y - p0.y) * Math.max(t0, t1) };
2563
+ const w = Math.hypot(b.x - a.x, b.y - a.y);
2564
+ if (!(w > 0)) return null;
2565
+ let cx = 0;
2566
+ let cy = 0;
2567
+ for (const v of outline) {
2568
+ cx += v.x;
2569
+ cy += v.y;
2570
+ }
2571
+ cx /= n;
2572
+ cy /= n;
2573
+ const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
2574
+ const ex = (b.x - a.x) / w;
2575
+ const ey = (b.y - a.y) / w;
2576
+ let nx = ey;
2577
+ let ny = -ex;
2578
+ if ((mid.x - cx) * nx + (mid.y - cy) * ny < 0) {
2579
+ nx = -nx;
2580
+ ny = -ny;
2581
+ }
2582
+ return {
2583
+ x: mid.x,
2584
+ y: mid.y,
2585
+ heading: norm360(Math.atan2(ny, nx) * 180 / Math.PI),
2586
+ widthInches: w,
2587
+ face: [a, b]
2588
+ };
2589
+ }
2434
2590
  function returnLoop(shape, opts) {
2435
2591
  const L = Math.max(1, opts.leadInches);
2436
2592
  const R = Math.max(1, opts.radius);
@@ -2534,6 +2690,21 @@ function deriveEndplatePoses(geo) {
2534
2690
  const half = geo.trackHalfSpacingInches ?? 1;
2535
2691
  const cfg = (i) => geo.endplateConfigs?.[i] === "double" ? "double" : "single";
2536
2692
  const withOverride = (p) => {
2693
+ const bound = geo.endplateEdges?.[p.id];
2694
+ if (bound) {
2695
+ const outline = (bound.section ? geo.sections?.find((s) => s.id === bound.section)?.outline : geo.outline) ?? geo.outline;
2696
+ const e = endplateEdgePose(outline, bound);
2697
+ if (e)
2698
+ return {
2699
+ ...p,
2700
+ x: e.x,
2701
+ y: e.y,
2702
+ heading: e.heading,
2703
+ widthInches: e.widthInches,
2704
+ face: e.face,
2705
+ boundToEdge: true
2706
+ };
2707
+ }
2537
2708
  const o = geo.poseOverrides?.[p.id];
2538
2709
  return o ? { ...p, x: o.x, y: o.y, heading: norm360(o.heading), manual: true } : p;
2539
2710
  };
@@ -2705,6 +2876,6 @@ function poseOverridesFromDoc(doc) {
2705
2876
  return out;
2706
2877
  }
2707
2878
 
2708
- export { ATLAS_CODE55_N, BUILT_IN_TRACK_PARTS, CLEARANCE_SPACING_INCHES, CODE55_RAIL_HEIGHT_INCHES, DEFAULT_FLEX_PART_ID, ENDPLATE_FASCIA_CLEAR_INCHES, ENDPLATE_LEAD_INCHES, FAST_TRACKS_N_ME55, FAST_TRACKS_N_ME55_CROSSOVERS, 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, PINCH_EASE_INCHES, RAIL_GAUGE_INCHES, TIE_HALF_LENGTH_INCHES, TURNOUT_LEAD_INCHES_PER_FROG, WHOLE_MODULE_SECTION_ID, asModuleSchematic, assessSectionEnd, assessSectionJoint, benchworkBand, benchworkOutline, buildCrossover, buildPassingSiding, buildTransition, carCapacity, checkEndplateWidth, clearancePointPastFrogInches, crossoverPinches, deriveEndplatePoses, divergeSideForHand, docToState, emptyEditorState, endplateCentreOffsetInches, endplateFaceSegments, endplateLead, endplateTrackOffsetInches, endplateWidthInches, flexPartFor, flexParts, flexPieces, flexUsage, frogCasting, frogNumberFromName, fromSectionRelative, geometryTurnDegrees, hasNoFarEndplate, importedPartToTrackPart, inchesToScaleFeet, isLoopDoc, isTransitionTurnout, laneOffsetAt, 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, spanOverhang, stateToDoc, storedPartToTrackPart, toSectionRelative, trackMeetsEndplateIssues, trackPart, trackPath, turnoutClosure, turnoutFacing, turnoutOccupiedSpan, turnoutPartForSize, usableCapacity };
2879
+ export { ATLAS_CODE55_N, BUILT_IN_TRACK_PARTS, CLEARANCE_SPACING_INCHES, CODE55_RAIL_HEIGHT_INCHES, DEFAULT_FLEX_PART_ID, ENDPLATE_FASCIA_CLEAR_INCHES, ENDPLATE_LEAD_INCHES, FAST_TRACKS_N_ME55, FAST_TRACKS_N_ME55_CROSSOVERS, 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, PINCH_EASE_INCHES, RAIL_GAUGE_INCHES, TIE_HALF_LENGTH_INCHES, TURNOUT_LEAD_INCHES_PER_FROG, WHOLE_MODULE_SECTION_ID, asModuleSchematic, assessSectionEnd, assessSectionJoint, benchworkBand, benchworkOutline, buildCrossover, buildPassingSiding, buildTransition, carCapacity, checkEndplateWidth, clearancePointPastFrogInches, crossoverPinches, deriveEndplatePoses, divergeSideForHand, docToState, emptyEditorState, endplateCentreOffsetInches, endplateEdgePose, endplateFaceSegments, endplateLead, endplateTrackOffsetInches, endplateWidthInches, flexPartFor, flexParts, flexPieces, flexUsage, frogCasting, frogNumberFromName, fromSectionRelative, geometryTurnDegrees, hasNoFarEndplate, importedPartToTrackPart, inchesToScaleFeet, isLoopDoc, isTransitionTurnout, laneOffsetAt, leadInchesForSize, maxFlexPieceInches, mergeImportedParts, mergeStoredParts, moduleCenterline, moduleFeatures, moduleFootprint, moduleLengthFromSections, moduleSections, nextId, parseXtpLibrary, partExtent, partExtentForSize, partGeometry, partGeometryGap, partOutlineAtFrog, partsPlaceable, pastFrogInchesForSize, pathLengthInches, poseNeedsManual, poseOverridesFromDoc, remapPos, resizeFlexPiece, returnLoop, sampleBenchworkOutline, samplePartSegments, samplePath, scaleFeetToInches, sectionAdjacency, sectionBand, sectionBreaksFromSections, sectionComponents, sectionFootprints, sectionNeighbours, sectionSpans, sectionSpansOrWhole, sectionedCenterline, sectionedEndPose, sliceCenterline, spanOverhang, stateToDoc, storedPartToTrackPart, toSectionRelative, trackMeetsEndplateIssues, trackPart, trackPath, turnoutClosure, turnoutFacing, turnoutOccupiedSpan, turnoutPartForSize, usableCapacity };
2709
2880
  //# sourceMappingURL=index.js.map
2710
2881
  //# sourceMappingURL=index.js.map