@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 +214 -39
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +143 -1
- package/dist/index.d.ts +143 -1
- package/dist/index.js +211 -40
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
-
|
|
933
|
-
|
|
934
|
-
|
|
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;
|