@willcgage/module-schematic 0.97.1 → 0.101.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 +409 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +220 -1
- package/dist/index.d.ts +220 -1
- package/dist/index.js +407 -10
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -229,7 +229,7 @@ function moduleCenterline(input) {
|
|
|
229
229
|
if (drawn) return samplePath(drawn);
|
|
230
230
|
const chained = sectionedCenterline(input);
|
|
231
231
|
if (chained.length >= 2) return chained;
|
|
232
|
-
if (!input.geometryType) return [];
|
|
232
|
+
if (!input.geometryType && !input.hasPlacedTrack) return [];
|
|
233
233
|
const L = input.lengthInches > 0 ? input.lengthInches : 24;
|
|
234
234
|
const gt = input.geometryType;
|
|
235
235
|
if (gt === "dead_end") return [{ x: 0, y: 0 }];
|
|
@@ -1799,8 +1799,8 @@ function turnoutFacing(input) {
|
|
|
1799
1799
|
function turnoutOccupiedSpan(input) {
|
|
1800
1800
|
const e = input.extent;
|
|
1801
1801
|
if (!e) return null;
|
|
1802
|
-
const a = input.pos - input.facing * e.
|
|
1803
|
-
const b = input.pos + input.facing * e.
|
|
1802
|
+
const a = input.pos - input.facing * e.behindFrog;
|
|
1803
|
+
const b = input.pos + input.facing * e.pastFrog;
|
|
1804
1804
|
return { fromPos: Math.min(a, b), toPos: Math.max(a, b) };
|
|
1805
1805
|
}
|
|
1806
1806
|
function resizeFlexPiece(pieces, index, nextLengthInches) {
|
|
@@ -1833,10 +1833,12 @@ function partExtent(part) {
|
|
|
1833
1833
|
if (pts.source !== "measured" || overall.source !== "measured") return null;
|
|
1834
1834
|
const frog = part?.frogOffset;
|
|
1835
1835
|
const aheadOfPoints = overall.inches - pts.inches;
|
|
1836
|
+
const frogMeasured = frog && frog.source === "measured";
|
|
1836
1837
|
return {
|
|
1837
1838
|
behindPoints: pts.inches,
|
|
1838
1839
|
aheadOfPoints,
|
|
1839
|
-
pastFrog:
|
|
1840
|
+
pastFrog: frogMeasured ? overall.inches - frog.inches : aheadOfPoints,
|
|
1841
|
+
behindFrog: frogMeasured ? frog.inches : pts.inches
|
|
1840
1842
|
};
|
|
1841
1843
|
}
|
|
1842
1844
|
function partExtentForSize(size, library = BUILT_IN_TRACK_PARTS) {
|
|
@@ -2553,6 +2555,83 @@ function fitFlexBetween(piece, others, library = BUILT_IN_TRACK_PARTS, withinInc
|
|
|
2553
2555
|
lengthInches: Math.round(len * 1e3) / 1e3
|
|
2554
2556
|
};
|
|
2555
2557
|
}
|
|
2558
|
+
function insertIntoRun(pieces, fresh, at, library = BUILT_IN_TRACK_PARTS, withinInches = 1) {
|
|
2559
|
+
let host = null;
|
|
2560
|
+
let bestD = Infinity;
|
|
2561
|
+
for (const piece of pieces) {
|
|
2562
|
+
const part = library.find((x) => x.id === piece.partId);
|
|
2563
|
+
if (part?.kind !== "flex") continue;
|
|
2564
|
+
for (const { points } of pieceRoutePaths(piece, library))
|
|
2565
|
+
for (let i = 1; i < points.length; i++) {
|
|
2566
|
+
const d = distanceToSegment(at, points[i - 1], points[i]);
|
|
2567
|
+
if (d < bestD) {
|
|
2568
|
+
bestD = d;
|
|
2569
|
+
host = piece;
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
if (!host || bestD > withinInches) return null;
|
|
2574
|
+
const L = host.lengthInches ?? 0;
|
|
2575
|
+
const R = host.radiusInches;
|
|
2576
|
+
const steps = 200;
|
|
2577
|
+
let s = 0;
|
|
2578
|
+
let closest = Infinity;
|
|
2579
|
+
for (let i = 0; i <= steps; i++) {
|
|
2580
|
+
const t = L * i / steps;
|
|
2581
|
+
const local = flexRunEnd(t, R);
|
|
2582
|
+
const world = placeLocal(host, local.x, local.y);
|
|
2583
|
+
const d = Math.hypot(world.x - at.x, world.y - at.y);
|
|
2584
|
+
if (d < closest) {
|
|
2585
|
+
closest = d;
|
|
2586
|
+
s = t;
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
const cutLocal = flexRunEnd(s, R);
|
|
2590
|
+
const cut = placeLocal(host, cutLocal.x, cutLocal.y);
|
|
2591
|
+
const heading = norm360(host.rotationDeg + (host.flipped ? -cutLocal.headingDeg : cutLocal.headingDeg));
|
|
2592
|
+
const placed = { ...fresh, x: cut.x, y: cut.y, rotationDeg: heading };
|
|
2593
|
+
const js = placedJoints([placed], library);
|
|
2594
|
+
const entry = js.find((j) => j.joint === "throat" || j.joint === "a");
|
|
2595
|
+
const exit = js.find((j) => j.joint === "through" || j.joint === "b");
|
|
2596
|
+
if (!entry || !exit) return null;
|
|
2597
|
+
const body = Math.hypot(exit.x - entry.x, exit.y - entry.y);
|
|
2598
|
+
const rest = L - s - body;
|
|
2599
|
+
if (s < 0 || rest < 0) return null;
|
|
2600
|
+
const ids = new Set(pieces.map((p) => p.id));
|
|
2601
|
+
let n = 1;
|
|
2602
|
+
while (ids.has(`${host.id}b${n}`)) n += 1;
|
|
2603
|
+
const tail = {
|
|
2604
|
+
...host,
|
|
2605
|
+
id: `${host.id}b${n}`,
|
|
2606
|
+
x: exit.x,
|
|
2607
|
+
y: exit.y,
|
|
2608
|
+
rotationDeg: exit.headingDeg,
|
|
2609
|
+
lengthInches: rest
|
|
2610
|
+
};
|
|
2611
|
+
const out = pieces.map((p) => p.id === host.id ? { ...p, lengthInches: s } : p);
|
|
2612
|
+
const kept = out.filter((p) => p.id !== host.id || s > 0);
|
|
2613
|
+
return {
|
|
2614
|
+
pieces: [...kept, placed, ...rest > 0 ? [tail] : []],
|
|
2615
|
+
hostId: host.id,
|
|
2616
|
+
insertedId: placed.id
|
|
2617
|
+
};
|
|
2618
|
+
}
|
|
2619
|
+
function placeLocal(piece, x, y) {
|
|
2620
|
+
const rad = piece.rotationDeg * Math.PI / 180;
|
|
2621
|
+
const ly = piece.flipped ? -y : y;
|
|
2622
|
+
return {
|
|
2623
|
+
x: piece.x + x * Math.cos(rad) - ly * Math.sin(rad),
|
|
2624
|
+
y: piece.y + x * Math.sin(rad) + ly * Math.cos(rad)
|
|
2625
|
+
};
|
|
2626
|
+
}
|
|
2627
|
+
function distanceToSegment(p, a, b) {
|
|
2628
|
+
const vx = b.x - a.x;
|
|
2629
|
+
const vy = b.y - a.y;
|
|
2630
|
+
const len2 = vx * vx + vy * vy;
|
|
2631
|
+
if (len2 === 0) return Math.hypot(p.x - a.x, p.y - a.y);
|
|
2632
|
+
const t = Math.max(0, Math.min(1, ((p.x - a.x) * vx + (p.y - a.y) * vy) / len2));
|
|
2633
|
+
return Math.hypot(p.x - (a.x + t * vx), p.y - (a.y + t * vy));
|
|
2634
|
+
}
|
|
2556
2635
|
var danglingDivergeWarning = (id) => `the route diverging at ${id} is not connected to anything`;
|
|
2557
2636
|
var unreachedWarning = (id) => `${id} is not reachable from the endplate \u2014 nothing connects it`;
|
|
2558
2637
|
function walkTrackGraph(graph, pieces, startAt, library = BUILT_IN_TRACK_PARTS) {
|
|
@@ -2623,12 +2702,13 @@ function walkTrackGraph(graph, pieces, startAt, library = BUILT_IN_TRACK_PARTS)
|
|
|
2623
2702
|
const through = byKey.get(`${here.piece}.through`) ?? byKey.get(`${here.piece}.legA`);
|
|
2624
2703
|
const body = gap(throat, through);
|
|
2625
2704
|
const lead = part.lead?.inches ?? body / 2;
|
|
2705
|
+
const frogAxial = part.frogOffset?.inches ?? (part.pointsOffset?.inches ?? 0) + lead;
|
|
2626
2706
|
const dvj = byKey.get(`${here.piece}.diverge`) ?? byKey.get(`${here.piece}.legB`);
|
|
2627
2707
|
const frogPt = throat && body > 0 && dvj ? {
|
|
2628
|
-
x: throat.x + (dvj.x - throat.x) *
|
|
2629
|
-
y: throat.y + (dvj.y - throat.y) *
|
|
2708
|
+
x: throat.x + (dvj.x - throat.x) * frogAxial / body,
|
|
2709
|
+
y: throat.y + (dvj.y - throat.y) * frogAxial / body
|
|
2630
2710
|
} : null;
|
|
2631
|
-
const toFrog = here.joint === "throat" ?
|
|
2711
|
+
const toFrog = here.joint === "throat" ? frogAxial : here.joint === "diverge" || here.joint === "legB" ? frogPt && dvj ? Math.hypot(dvj.x - frogPt.x, dvj.y - frogPt.y) : Math.max(0, body - frogAxial) : Math.max(0, body - frogAxial);
|
|
2632
2712
|
const frogPos = pos + toFrog;
|
|
2633
2713
|
if (here.joint === "diverge" || here.joint === "legB") {
|
|
2634
2714
|
route.endsAt = here.piece;
|
|
@@ -2643,8 +2723,8 @@ function walkTrackGraph(graph, pieces, startAt, library = BUILT_IN_TRACK_PARTS)
|
|
|
2643
2723
|
if (!d || queued.has(dk)) continue;
|
|
2644
2724
|
queued.add(dk);
|
|
2645
2725
|
const fp = throat && body > 0 ? {
|
|
2646
|
-
x: throat.x + (d.x - throat.x) *
|
|
2647
|
-
y: throat.y + (d.y - throat.y) *
|
|
2726
|
+
x: throat.x + (d.x - throat.x) * frogAxial / body,
|
|
2727
|
+
y: throat.y + (d.y - throat.y) * frogAxial / body
|
|
2648
2728
|
} : null;
|
|
2649
2729
|
pending.push({
|
|
2650
2730
|
from: here.piece,
|
|
@@ -2930,6 +3010,323 @@ function deriveGraphDoc(doc, library = BUILT_IN_TRACK_PARTS) {
|
|
|
2930
3010
|
});
|
|
2931
3011
|
return { doc: out.doc, warnings: out.warnings };
|
|
2932
3012
|
}
|
|
3013
|
+
function placeableTurnoutParts(library, kind) {
|
|
3014
|
+
return library.filter((p) => kind.includes(p.kind) && partGeometryGap(p) == null);
|
|
3015
|
+
}
|
|
3016
|
+
function moduleConversionReport(doc, library = BUILT_IN_TRACK_PARTS) {
|
|
3017
|
+
if (doc.graph?.pieces?.length) {
|
|
3018
|
+
return {
|
|
3019
|
+
alreadyGraph: true,
|
|
3020
|
+
offerable: false,
|
|
3021
|
+
readyWithoutAsking: false,
|
|
3022
|
+
turnouts: [],
|
|
3023
|
+
unanswered: [],
|
|
3024
|
+
orphanTracks: [],
|
|
3025
|
+
blockers: []
|
|
3026
|
+
};
|
|
3027
|
+
}
|
|
3028
|
+
const straight = placeableTurnoutParts(library, ["turnout"]);
|
|
3029
|
+
const curved = placeableTurnoutParts(library, ["curved-turnout"]);
|
|
3030
|
+
const blockers = [];
|
|
3031
|
+
const turnouts = (doc.turnouts ?? []).map((t) => {
|
|
3032
|
+
const wantCurved = t.curved === true;
|
|
3033
|
+
const pool = wantCurved ? curved : straight;
|
|
3034
|
+
const base = {
|
|
3035
|
+
id: t.id,
|
|
3036
|
+
name: t.name,
|
|
3037
|
+
pos: t.pos,
|
|
3038
|
+
size: t.size,
|
|
3039
|
+
statedPartId: t.partId
|
|
3040
|
+
};
|
|
3041
|
+
const exact = t.size == null ? [] : pool.filter((p) => p.frogNumber === t.size);
|
|
3042
|
+
const candidates = [...exact, ...pool.filter((p) => !exact.includes(p))].map((p) => p.id);
|
|
3043
|
+
const resolve = (part, from) => ({
|
|
3044
|
+
...base,
|
|
3045
|
+
partId: part.id,
|
|
3046
|
+
from,
|
|
3047
|
+
source: partGeometry(part, library)?.source ?? null,
|
|
3048
|
+
why: null,
|
|
3049
|
+
candidates
|
|
3050
|
+
});
|
|
3051
|
+
if (t.partId) {
|
|
3052
|
+
const named = library.find((p) => p.id === t.partId);
|
|
3053
|
+
if (named && partGeometryGap(named) == null) return resolve(named, "named");
|
|
3054
|
+
return {
|
|
3055
|
+
...base,
|
|
3056
|
+
partId: null,
|
|
3057
|
+
from: "unresolved",
|
|
3058
|
+
source: null,
|
|
3059
|
+
why: named ? `this turnout names ${named.id}, which cannot be placed yet \u2014 ${partGeometryGap(named)}` : `this turnout names a part the library does not have (${t.partId})`,
|
|
3060
|
+
candidates
|
|
3061
|
+
};
|
|
3062
|
+
}
|
|
3063
|
+
if (t.size != null && exact.length) return resolve(exact[0], "frog-number");
|
|
3064
|
+
return {
|
|
3065
|
+
...base,
|
|
3066
|
+
partId: null,
|
|
3067
|
+
from: "unresolved",
|
|
3068
|
+
source: null,
|
|
3069
|
+
why: t.size == null ? "the document never says what this turnout is \u2014 no part, no frog number" : wantCurved ? `no curved turnout is placeable yet, so a curved #${t.size} cannot be converted` : `no measured #${t.size} in the parts library yet`,
|
|
3070
|
+
candidates
|
|
3071
|
+
};
|
|
3072
|
+
});
|
|
3073
|
+
for (const c of doc.crossings ?? [])
|
|
3074
|
+
blockers.push({
|
|
3075
|
+
kind: "crossing",
|
|
3076
|
+
ref: c.id,
|
|
3077
|
+
why: "crossing geometry is not modelled yet, so a diamond has no piece to become"
|
|
3078
|
+
});
|
|
3079
|
+
if (doc.loop)
|
|
3080
|
+
blockers.push({
|
|
3081
|
+
kind: "loop",
|
|
3082
|
+
why: "a balloon's curve radii were never recorded, and laying the loop would mean inventing them \u2014 the one thing ADR 0001 forbids"
|
|
3083
|
+
});
|
|
3084
|
+
const reached = new Set((doc.turnouts ?? []).map((t) => t.divergeTrack));
|
|
3085
|
+
const orphanTracks = (doc.tracks ?? []).filter((t) => t.role !== "main" && !reached.has(t.id)).map((t) => ({
|
|
3086
|
+
id: t.id,
|
|
3087
|
+
...t.trackName ? { trackName: t.trackName } : {},
|
|
3088
|
+
role: t.role,
|
|
3089
|
+
why: "no turnout in the document diverges onto this track, so there is nothing to join it to"
|
|
3090
|
+
}));
|
|
3091
|
+
const unanswered = turnouts.filter((t) => !t.partId).map((t) => t.id);
|
|
3092
|
+
return {
|
|
3093
|
+
alreadyGraph: false,
|
|
3094
|
+
offerable: blockers.length === 0,
|
|
3095
|
+
readyWithoutAsking: blockers.length === 0 && unanswered.length === 0 && orphanTracks.length === 0,
|
|
3096
|
+
turnouts,
|
|
3097
|
+
unanswered,
|
|
3098
|
+
orphanTracks,
|
|
3099
|
+
blockers
|
|
3100
|
+
};
|
|
3101
|
+
}
|
|
3102
|
+
var CONVERSION_TIGHT_RADIUS_INCHES = 12;
|
|
3103
|
+
function docToGraph(doc, answers = {}, library = BUILT_IN_TRACK_PARTS) {
|
|
3104
|
+
const refuse = (why) => ({
|
|
3105
|
+
graph: null,
|
|
3106
|
+
refused: why,
|
|
3107
|
+
notLaid: [],
|
|
3108
|
+
warnings: []
|
|
3109
|
+
});
|
|
3110
|
+
const report = moduleConversionReport(doc, library);
|
|
3111
|
+
if (report.alreadyGraph) return refuse("this module is already authored as pieces");
|
|
3112
|
+
if (!report.offerable) return refuse(report.blockers.map((b) => b.why).join("; "));
|
|
3113
|
+
if ((doc.mainPath?.length ?? 0) > 2)
|
|
3114
|
+
return refuse(
|
|
3115
|
+
"this module's mainline is drawn with bends, and laying it as pieces would need each bend's radius \u2014 convert it once curved runs are supported"
|
|
3116
|
+
);
|
|
3117
|
+
const partById = new Map(library.map((p) => [p.id, p]));
|
|
3118
|
+
const chosen = /* @__PURE__ */ new Map();
|
|
3119
|
+
const unknown = [];
|
|
3120
|
+
for (const t of report.turnouts) {
|
|
3121
|
+
const id = answers.overrides?.[t.id] ?? (t.partId ? t.partId : answers.turnoutPartId) ?? null;
|
|
3122
|
+
const part = id ? partById.get(id) : void 0;
|
|
3123
|
+
if (!part || partGeometryGap(part)) unknown.push(t.id);
|
|
3124
|
+
else chosen.set(t.id, part);
|
|
3125
|
+
}
|
|
3126
|
+
if (unknown.length)
|
|
3127
|
+
return refuse(
|
|
3128
|
+
`${unknown.length} turnout${unknown.length === 1 ? "" : "s"} still need identifying: ${unknown.join(", ")}`
|
|
3129
|
+
);
|
|
3130
|
+
const flexId = DEFAULT_FLEX_PART_ID;
|
|
3131
|
+
if (!partById.get(flexId)) return refuse("the parts library has no flex track to lay plain track with");
|
|
3132
|
+
const maxPiece = maxFlexPieceInches(flexId, library);
|
|
3133
|
+
const tracks = doc.tracks ?? [];
|
|
3134
|
+
const trackById = new Map(tracks.map((t) => [t.id, t]));
|
|
3135
|
+
const turnouts = doc.turnouts ?? [];
|
|
3136
|
+
const mainLen = doc.lengthInches ?? Math.max(0, ...tracks.map((t) => t.toPos ?? 0), ...turnouts.map((t) => t.pos));
|
|
3137
|
+
if (!(mainLen > 0)) return refuse("this module has no length to lay track along");
|
|
3138
|
+
const pieces = [];
|
|
3139
|
+
const notLaid = [];
|
|
3140
|
+
const warnings = [];
|
|
3141
|
+
const rad = (d) => d * Math.PI / 180;
|
|
3142
|
+
const norm180 = (d) => ((d + 180) % 360 + 360) % 360 - 180;
|
|
3143
|
+
const r3 = (n) => Math.round(n * 1e3) / 1e3;
|
|
3144
|
+
const jointsOf = (p) => placedJoints([p], library);
|
|
3145
|
+
const jointAt = (p, id) => jointsOf(p).find((j) => j.joint === id) ?? null;
|
|
3146
|
+
const transition = (id, from, toY, dirSign = 1) => {
|
|
3147
|
+
const theta = rad(norm180(from.headingDeg - (dirSign > 0 ? 0 : 180)));
|
|
3148
|
+
const dy = (toY - from.y) * dirSign;
|
|
3149
|
+
if (Math.abs(theta) < 1e-9 || Math.abs(dy) < 1e-9) return null;
|
|
3150
|
+
const R = dy / (1 - Math.cos(theta));
|
|
3151
|
+
if (!Number.isFinite(R) || R === 0) return null;
|
|
3152
|
+
const mag = Math.abs(R);
|
|
3153
|
+
if (mag < CONVERSION_TIGHT_RADIUS_INCHES)
|
|
3154
|
+
warnings.push(
|
|
3155
|
+
`the curve bringing ${id} back parallel works out at ${mag.toFixed(1)}\u2033 radius \u2014 tighter than anyone lays by hand, which usually means the document's lane and its turnout disagree`
|
|
3156
|
+
);
|
|
3157
|
+
return {
|
|
3158
|
+
id,
|
|
3159
|
+
partId: flexId,
|
|
3160
|
+
x: from.x,
|
|
3161
|
+
y: from.y,
|
|
3162
|
+
rotationDeg: from.headingDeg,
|
|
3163
|
+
// Curving BACK toward the host: opposite the way we are already heading.
|
|
3164
|
+
// `radiusInches` is signed and the sign IS the side.
|
|
3165
|
+
radiusInches: r3(-Math.sign(theta) * mag),
|
|
3166
|
+
lengthInches: r3(mag * Math.abs(theta))
|
|
3167
|
+
};
|
|
3168
|
+
};
|
|
3169
|
+
const layTurnout = (t, hostY) => {
|
|
3170
|
+
const part = chosen.get(t.id);
|
|
3171
|
+
const geom = partGeometry(part, library);
|
|
3172
|
+
const branch = trackById.get(t.divergeTrack);
|
|
3173
|
+
const ends = [branch?.fromPos, branch?.toPos].filter(
|
|
3174
|
+
(n) => typeof n === "number" && Number.isFinite(n)
|
|
3175
|
+
);
|
|
3176
|
+
const divergeFarPos = ends.length ? ends.reduce((a, b) => Math.abs(b - t.pos) > Math.abs(a - t.pos) ? b : a) : void 0;
|
|
3177
|
+
const facing = turnoutFacing({
|
|
3178
|
+
pos: t.pos,
|
|
3179
|
+
divergeFarPos,
|
|
3180
|
+
flipped: t.flipped ?? false
|
|
3181
|
+
});
|
|
3182
|
+
const body = part.overallLength.inches;
|
|
3183
|
+
const lead = part.lead?.inches ?? body / 2;
|
|
3184
|
+
const frogAxial = part.frogOffset?.inches ?? (part.pointsOffset?.inches ?? 0) + lead;
|
|
3185
|
+
const throatPos = facing > 0 ? t.pos - frogAxial : t.pos + frogAxial;
|
|
3186
|
+
const hostLane = trackById.get(t.onTrack)?.lane ?? 0;
|
|
3187
|
+
const want = Math.sign((branch?.lane ?? hostLane) - hostLane) || 1;
|
|
3188
|
+
const unflipped = facing > 0 ? 1 : -1;
|
|
3189
|
+
const piece = {
|
|
3190
|
+
id: `t-${t.id}`,
|
|
3191
|
+
partId: part.id,
|
|
3192
|
+
x: throatPos,
|
|
3193
|
+
y: hostY,
|
|
3194
|
+
rotationDeg: facing > 0 ? 0 : 180,
|
|
3195
|
+
...unflipped !== want ? { flipped: true } : {},
|
|
3196
|
+
...t.name ? { name: t.name } : {}
|
|
3197
|
+
};
|
|
3198
|
+
const bodyEnds = [throatPos, facing > 0 ? throatPos + body : throatPos - body];
|
|
3199
|
+
const span = { fromPos: Math.min(...bodyEnds), toPos: Math.max(...bodyEnds) };
|
|
3200
|
+
const divergeId = geom.joints.find((j) => j.role === "diverge")?.id ?? "diverge";
|
|
3201
|
+
return { t, piece, span, divergeId };
|
|
3202
|
+
};
|
|
3203
|
+
const layFlex = (label, hostY, fromPos, toPos, occupied, cuts) => {
|
|
3204
|
+
const out = flexPieces({ fromPos, toPos, maxPieceInches: maxPiece, occupied, cuts: cuts ?? null });
|
|
3205
|
+
out.forEach(
|
|
3206
|
+
(f, i) => pieces.push({
|
|
3207
|
+
id: `f-${label}-${i}`,
|
|
3208
|
+
partId: flexId,
|
|
3209
|
+
x: f.fromPos,
|
|
3210
|
+
y: hostY,
|
|
3211
|
+
rotationDeg: 0,
|
|
3212
|
+
lengthInches: r3(f.lengthInches)
|
|
3213
|
+
})
|
|
3214
|
+
);
|
|
3215
|
+
return out;
|
|
3216
|
+
};
|
|
3217
|
+
const turnoutsOn = (trackId, hostY) => turnouts.filter((t) => t.onTrack === trackId && chosen.has(t.id)).sort((a, b) => a.pos - b.pos).map((t) => layTurnout(t, hostY));
|
|
3218
|
+
const main = trackById.get(MAIN_TRACK_ID) ?? tracks.find((t) => t.role === "main");
|
|
3219
|
+
if (!main) return refuse("this module's document has no mainline to convert");
|
|
3220
|
+
const mains = [main, ...tracks.filter((t) => t.role === "main" && t.id !== main.id)];
|
|
3221
|
+
const laidTurnouts = /* @__PURE__ */ new Map();
|
|
3222
|
+
const done = /* @__PURE__ */ new Set();
|
|
3223
|
+
for (const m of mains) {
|
|
3224
|
+
const y = laneOffsetAt(m.lane ?? 0, 0);
|
|
3225
|
+
const on = turnoutsOn(m.id, y);
|
|
3226
|
+
for (const l of on) {
|
|
3227
|
+
pieces.push(l.piece);
|
|
3228
|
+
laidTurnouts.set(l.t.id, l);
|
|
3229
|
+
}
|
|
3230
|
+
layFlex(m.id, y, m.fromPos ?? 0, m.toPos ?? mainLen, on.map((l) => l.span), m.flexCuts);
|
|
3231
|
+
done.add(m.id);
|
|
3232
|
+
}
|
|
3233
|
+
let progressed = true;
|
|
3234
|
+
while (progressed) {
|
|
3235
|
+
progressed = false;
|
|
3236
|
+
for (const t of turnouts) {
|
|
3237
|
+
if (!done.has(t.onTrack)) continue;
|
|
3238
|
+
const branch = trackById.get(t.divergeTrack);
|
|
3239
|
+
if (!branch || done.has(branch.id)) continue;
|
|
3240
|
+
const near = laidTurnouts.get(t.id);
|
|
3241
|
+
if (!near) continue;
|
|
3242
|
+
done.add(branch.id);
|
|
3243
|
+
progressed = true;
|
|
3244
|
+
const dj = jointAt(near.piece, near.divergeId);
|
|
3245
|
+
if (!dj) {
|
|
3246
|
+
notLaid.push({ id: branch.id, why: `turnout ${t.id} has no diverging end to leave from` });
|
|
3247
|
+
continue;
|
|
3248
|
+
}
|
|
3249
|
+
const branchY = laneOffsetAt(branch.lane ?? 0, 0);
|
|
3250
|
+
const curve = transition(branch.id, { x: dj.x, y: dj.y, headingDeg: dj.headingDeg }, branchY);
|
|
3251
|
+
let start = { x: dj.x, y: dj.y, headingDeg: dj.headingDeg };
|
|
3252
|
+
if (curve) {
|
|
3253
|
+
pieces.push(curve);
|
|
3254
|
+
const e = jointAt(curve, "b");
|
|
3255
|
+
if (e) start = { x: e.x, y: e.y, headingDeg: e.headingDeg };
|
|
3256
|
+
}
|
|
3257
|
+
const farSw = turnouts.find(
|
|
3258
|
+
(o) => o.id !== t.id && o.divergeTrack === branch.id && laidTurnouts.has(o.id)
|
|
3259
|
+
);
|
|
3260
|
+
const farJoint = farSw ? (() => {
|
|
3261
|
+
const f = laidTurnouts.get(farSw.id);
|
|
3262
|
+
return jointAt(f.piece, f.divergeId);
|
|
3263
|
+
})() : null;
|
|
3264
|
+
let endX = farJoint != null ? farJoint.x : Math.max(branch.fromPos ?? t.pos, branch.toPos ?? t.pos);
|
|
3265
|
+
let farCurve = null;
|
|
3266
|
+
if (farJoint) {
|
|
3267
|
+
farCurve = transition(
|
|
3268
|
+
`${branch.id}-far`,
|
|
3269
|
+
{ x: farJoint.x, y: farJoint.y, headingDeg: farJoint.headingDeg },
|
|
3270
|
+
branchY,
|
|
3271
|
+
-1
|
|
3272
|
+
);
|
|
3273
|
+
if (farCurve) {
|
|
3274
|
+
pieces.push(farCurve);
|
|
3275
|
+
const e = jointAt(farCurve, "b");
|
|
3276
|
+
if (e) endX = e.x;
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
3279
|
+
if (endX - start.x <= 1e-6) {
|
|
3280
|
+
notLaid.push({
|
|
3281
|
+
id: branch.id,
|
|
3282
|
+
why: `the turnout and the curve bringing this track parallel already reach ${start.x.toFixed(1)}\u2033, past where it has to end at ${endX.toFixed(1)}\u2033 \u2014 with the turnout chosen there is no room for this track where the document places it`
|
|
3283
|
+
});
|
|
3284
|
+
continue;
|
|
3285
|
+
}
|
|
3286
|
+
const on = turnoutsOn(branch.id, branchY).filter((l) => {
|
|
3287
|
+
if (l.span.fromPos >= start.x - 1e-6) return true;
|
|
3288
|
+
notLaid.push({
|
|
3289
|
+
id: l.t.divergeTrack,
|
|
3290
|
+
why: `turnout ${l.t.id} sits at ${l.t.pos}\u2033 but this track has only reached ${start.x.toFixed(1)}\u2033 by then \u2014 the ladder is pitched tighter than the chosen turnout allows`
|
|
3291
|
+
});
|
|
3292
|
+
return false;
|
|
3293
|
+
});
|
|
3294
|
+
for (const l of on) {
|
|
3295
|
+
pieces.push(l.piece);
|
|
3296
|
+
laidTurnouts.set(l.t.id, l);
|
|
3297
|
+
}
|
|
3298
|
+
layFlex(branch.id, branchY, start.x, endX, on.map((l) => l.span), branch.flexCuts);
|
|
3299
|
+
}
|
|
3300
|
+
}
|
|
3301
|
+
const orphanWhy = new Map(report.orphanTracks.map((o) => [o.id, o.why]));
|
|
3302
|
+
const alreadySaid = new Set(notLaid.map((n) => n.id));
|
|
3303
|
+
for (const t of tracks) {
|
|
3304
|
+
if (t.role === "main" || done.has(t.id) || alreadySaid.has(t.id)) continue;
|
|
3305
|
+
notLaid.push({
|
|
3306
|
+
id: t.id,
|
|
3307
|
+
why: orphanWhy.get(t.id) ?? "the track it branches from could not be laid, so there is nothing to join it to"
|
|
3308
|
+
});
|
|
3309
|
+
}
|
|
3310
|
+
const startOf = (t) => {
|
|
3311
|
+
const y = laneOffsetAt(t.lane ?? 0, 0);
|
|
3312
|
+
let best = null;
|
|
3313
|
+
for (const p of pieces)
|
|
3314
|
+
for (const j of jointsOf(p)) {
|
|
3315
|
+
if (Math.abs(j.y - y) > 0.05) continue;
|
|
3316
|
+
if (!best || j.x < best.x) best = { piece: p.id, joint: j.joint, x: j.x };
|
|
3317
|
+
}
|
|
3318
|
+
return best ? { piece: best.piece, joint: best.joint } : null;
|
|
3319
|
+
};
|
|
3320
|
+
const startAt = startOf(main);
|
|
3321
|
+
if (!startAt) return refuse("nothing was laid on the mainline, so the module has no starting point");
|
|
3322
|
+
const second = mains[1] ? startOf(mains[1]) : null;
|
|
3323
|
+
return {
|
|
3324
|
+
graph: { pieces, startAt, ...second ? { start2: second } : {} },
|
|
3325
|
+
refused: null,
|
|
3326
|
+
notLaid,
|
|
3327
|
+
warnings
|
|
3328
|
+
};
|
|
3329
|
+
}
|
|
2933
3330
|
function frogCasting(cl, opts = {}) {
|
|
2934
3331
|
const g = opts.gaugeInches ?? RAIL_GAUGE_INCHES;
|
|
2935
3332
|
const w = opts.reachInches ?? g * 1.5;
|
|
@@ -3608,6 +4005,7 @@ exports.crossoverPinches = crossoverPinches;
|
|
|
3608
4005
|
exports.deriveEndplatePoses = deriveEndplatePoses;
|
|
3609
4006
|
exports.deriveGraphDoc = deriveGraphDoc;
|
|
3610
4007
|
exports.divergeSideForHand = divergeSideForHand;
|
|
4008
|
+
exports.docToGraph = docToGraph;
|
|
3611
4009
|
exports.docToState = docToState;
|
|
3612
4010
|
exports.emptyEditorState = emptyEditorState;
|
|
3613
4011
|
exports.endplateCentreOffsetInches = endplateCentreOffsetInches;
|
|
@@ -3630,6 +4028,7 @@ exports.graphToDoc = graphToDoc;
|
|
|
3630
4028
|
exports.hasNoFarEndplate = hasNoFarEndplate;
|
|
3631
4029
|
exports.importedPartToTrackPart = importedPartToTrackPart;
|
|
3632
4030
|
exports.inchesToScaleFeet = inchesToScaleFeet;
|
|
4031
|
+
exports.insertIntoRun = insertIntoRun;
|
|
3633
4032
|
exports.isLoopDoc = isLoopDoc;
|
|
3634
4033
|
exports.isTransitionTurnout = isTransitionTurnout;
|
|
3635
4034
|
exports.laneOffsetAt = laneOffsetAt;
|
|
@@ -3638,6 +4037,7 @@ exports.maxFlexPieceInches = maxFlexPieceInches;
|
|
|
3638
4037
|
exports.mergeImportedParts = mergeImportedParts;
|
|
3639
4038
|
exports.mergeStoredParts = mergeStoredParts;
|
|
3640
4039
|
exports.moduleCenterline = moduleCenterline;
|
|
4040
|
+
exports.moduleConversionReport = moduleConversionReport;
|
|
3641
4041
|
exports.moduleFeatures = moduleFeatures;
|
|
3642
4042
|
exports.moduleFootprint = moduleFootprint;
|
|
3643
4043
|
exports.moduleLengthFromSections = moduleLengthFromSections;
|