@willcgage/module-schematic 0.84.0 → 0.86.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)),
@@ -2229,6 +2245,197 @@ function partsPlaceable(library = BUILT_IN_TRACK_PARTS) {
2229
2245
  }
2230
2246
  return { placeable, blocked };
2231
2247
  }
2248
+ var JOINT_SNAP_INCHES = 0.01;
2249
+ function placedJoints(pieces, library = BUILT_IN_TRACK_PARTS) {
2250
+ const out = [];
2251
+ const RAD = Math.PI / 180;
2252
+ for (const p of pieces) {
2253
+ const part = library.find((x) => x.id === p.partId);
2254
+ if (!part) continue;
2255
+ const geo = partGeometry(part, library);
2256
+ if (!geo) continue;
2257
+ const c = Math.cos(p.rotationDeg * RAD);
2258
+ const s = Math.sin(p.rotationDeg * RAD);
2259
+ for (const j of geo.joints) {
2260
+ const lx = part.kind === "flex" && j.id === "b" ? p.lengthInches ?? 0 : j.x;
2261
+ const ly = p.flipped ? -j.y : j.y;
2262
+ const h = (p.flipped ? -j.angleDeg : j.angleDeg) + p.rotationDeg;
2263
+ out.push({
2264
+ key: `${p.id}.${j.id}`,
2265
+ piece: p.id,
2266
+ joint: j.id,
2267
+ role: j.role,
2268
+ x: p.x + lx * c - ly * s,
2269
+ y: p.y + lx * s + ly * c,
2270
+ headingDeg: norm360(h)
2271
+ });
2272
+ }
2273
+ }
2274
+ return out;
2275
+ }
2276
+ function buildTrackGraph(pieces, library = BUILT_IN_TRACK_PARTS, snapInches = JOINT_SNAP_INCHES) {
2277
+ const joints = placedJoints(pieces, library);
2278
+ const unplaceable = [];
2279
+ for (const p of pieces) {
2280
+ const part = library.find((x) => x.id === p.partId);
2281
+ if (!part) {
2282
+ unplaceable.push({ piece: p.id, partId: p.partId, why: "no such part in the library" });
2283
+ continue;
2284
+ }
2285
+ const why = partGeometryGap(part);
2286
+ if (why) unplaceable.push({ piece: p.id, partId: p.partId, why });
2287
+ }
2288
+ const groups = [];
2289
+ const taken = /* @__PURE__ */ new Set();
2290
+ for (const j of joints) {
2291
+ if (taken.has(j.key)) continue;
2292
+ const g = [j];
2293
+ taken.add(j.key);
2294
+ for (const k of joints) {
2295
+ if (taken.has(k.key) || k.piece === j.piece) continue;
2296
+ if (Math.hypot(k.x - j.x, k.y - j.y) <= snapInches) {
2297
+ g.push(k);
2298
+ taken.add(k.key);
2299
+ }
2300
+ }
2301
+ groups.push(g);
2302
+ }
2303
+ const connections = [];
2304
+ const open = [];
2305
+ const conflicts = [];
2306
+ for (const g of groups) {
2307
+ if (g.length === 1) open.push(g[0].key);
2308
+ else if (g.length === 2) connections.push({ a: g[0].key, b: g[1].key });
2309
+ else {
2310
+ conflicts.push({
2311
+ x: g[0].x,
2312
+ y: g[0].y,
2313
+ joints: g.map((j) => j.key),
2314
+ reason: `${g.length} track ends are stacked in one place. Rail has two ends, so a junction of three is a turnout, not a joint \u2014 none of these are joined until one is moved.`
2315
+ });
2316
+ for (const j of g) open.push(j.key);
2317
+ }
2318
+ }
2319
+ return { joints, connections, open, conflicts, unplaceable };
2320
+ }
2321
+ function walkTrackGraph(graph, pieces, startAt, library = BUILT_IN_TRACK_PARTS) {
2322
+ const byKey = new Map(graph.joints.map((j) => [j.key, j]));
2323
+ const pieceById = new Map(pieces.map((p) => [p.id, p]));
2324
+ const link = /* @__PURE__ */ new Map();
2325
+ for (const c of graph.connections) {
2326
+ link.set(c.a, c.b);
2327
+ link.set(c.b, c.a);
2328
+ }
2329
+ const partOf = (pid) => {
2330
+ const p = pieceById.get(pid);
2331
+ return p ? library.find((x) => x.id === p.partId) : void 0;
2332
+ };
2333
+ const geoOf = (pid) => {
2334
+ const part = partOf(pid);
2335
+ return part ? partGeometry(part, library) : null;
2336
+ };
2337
+ const gap = (a, b) => a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0;
2338
+ const routes = [];
2339
+ const turnouts = [];
2340
+ const warnings = [];
2341
+ const queued = /* @__PURE__ */ new Set();
2342
+ const pending = [];
2343
+ const walk = (startKey, startPos, id, bornAt) => {
2344
+ const route = {
2345
+ id,
2346
+ fromPos: startPos,
2347
+ toPos: startPos,
2348
+ bornAt,
2349
+ endsAt: null,
2350
+ pieces: [],
2351
+ lateral: 0
2352
+ };
2353
+ let cur = startKey;
2354
+ let pos = startPos;
2355
+ const guard = /* @__PURE__ */ new Set();
2356
+ while (cur && !guard.has(cur)) {
2357
+ guard.add(cur);
2358
+ const here = byKey.get(cur);
2359
+ if (!here) break;
2360
+ const geo = geoOf(here.piece);
2361
+ if (!geo) break;
2362
+ const opts = geo.routes.filter((r) => r.includes(here.joint));
2363
+ if (!opts.length) break;
2364
+ const pick = opts.find((r) => r.includes("through")) ?? opts[0];
2365
+ const exitJoint = pick[0] === here.joint ? pick[1] : pick[0];
2366
+ const exit = byKey.get(`${here.piece}.${exitJoint}`);
2367
+ const part = partOf(here.piece);
2368
+ if (part && (part.kind === "turnout" || part.kind === "wye")) {
2369
+ const throat = byKey.get(`${here.piece}.throat`);
2370
+ const through = byKey.get(`${here.piece}.through`) ?? byKey.get(`${here.piece}.legA`);
2371
+ const body = gap(throat, through);
2372
+ const lead = part.lead?.inches ?? body / 2;
2373
+ const dvj = byKey.get(`${here.piece}.diverge`) ?? byKey.get(`${here.piece}.legB`);
2374
+ const frogPt = throat && body > 0 && dvj ? {
2375
+ x: throat.x + (dvj.x - throat.x) * lead / body,
2376
+ y: throat.y + (dvj.y - throat.y) * lead / body
2377
+ } : null;
2378
+ const toFrog = here.joint === "throat" ? lead : here.joint === "diverge" || here.joint === "legB" ? frogPt && dvj ? Math.hypot(dvj.x - frogPt.x, dvj.y - frogPt.y) : Math.max(0, body - lead) : Math.max(0, body - lead);
2379
+ const frogPos = pos + toFrog;
2380
+ if (here.joint === "diverge" || here.joint === "legB") {
2381
+ route.endsAt = here.piece;
2382
+ const known = turnouts.find((t) => t.id === here.piece);
2383
+ route.toPos = known ? known.pos : frogPos;
2384
+ break;
2385
+ }
2386
+ turnouts.push({ id: here.piece, pos: frogPos, onRoute: id, divergeRoute: null });
2387
+ for (const dj of ["diverge", "legB"]) {
2388
+ const dk = `${here.piece}.${dj}`;
2389
+ const d = byKey.get(dk);
2390
+ if (!d || queued.has(dk)) continue;
2391
+ queued.add(dk);
2392
+ const fp = throat && body > 0 ? {
2393
+ x: throat.x + (d.x - throat.x) * lead / body,
2394
+ y: throat.y + (d.y - throat.y) * lead / body
2395
+ } : null;
2396
+ pending.push({
2397
+ from: here.piece,
2398
+ at: frogPos,
2399
+ joint: dk,
2400
+ skew: fp ? Math.hypot(d.x - fp.x, d.y - fp.y) : 0
2401
+ });
2402
+ }
2403
+ }
2404
+ route.pieces.push(here.piece);
2405
+ for (const j of graph.joints)
2406
+ if (j.piece === here.piece && Math.abs(j.y) > Math.abs(route.lateral))
2407
+ route.lateral = j.y;
2408
+ pos += gap(here, exit);
2409
+ route.toPos = pos;
2410
+ const next = exit ? link.get(exit.key) : void 0;
2411
+ if (!next) break;
2412
+ cur = next;
2413
+ }
2414
+ return route;
2415
+ };
2416
+ routes.push(walk(`${startAt.piece}.${startAt.joint}`, 0, "main", null));
2417
+ let n = 0;
2418
+ while (pending.length) {
2419
+ const b = pending.shift();
2420
+ const start = link.get(b.joint);
2421
+ if (!start) {
2422
+ warnings.push(`the route diverging at ${b.from} is not connected to anything`);
2423
+ continue;
2424
+ }
2425
+ n += 1;
2426
+ const r = walk(start, b.at + b.skew, `route${n}`, b.from);
2427
+ r.fromPos = b.at;
2428
+ routes.push(r);
2429
+ const sw = turnouts.find((t) => t.id === b.from);
2430
+ if (sw && !sw.divergeRoute) sw.divergeRoute = r.id;
2431
+ }
2432
+ const reached = new Set(routes.flatMap((r) => r.pieces));
2433
+ for (const p of pieces)
2434
+ if (!reached.has(p.id) && !graph.unplaceable.some((u) => u.piece === p.id))
2435
+ warnings.push(`${p.id} is not reachable from the endplate \u2014 nothing connects it`);
2436
+ for (const c of graph.conflicts) warnings.push(c.reason);
2437
+ return { routes, turnouts, warnings };
2438
+ }
2232
2439
  function frogCasting(cl, opts = {}) {
2233
2440
  const g = opts.gaugeInches ?? RAIL_GAUGE_INCHES;
2234
2441
  const w = opts.reachInches ?? g * 1.5;
@@ -2534,6 +2741,45 @@ function moduleFeatures(doc) {
2534
2741
  laneMax: Math.max(...allLanes)
2535
2742
  };
2536
2743
  }
2744
+ function endplateEdgePose(outline, edge) {
2745
+ if (!outline || outline.length < 3) return null;
2746
+ const n = outline.length;
2747
+ const i = Math.trunc(edge.index);
2748
+ if (!Number.isFinite(i) || i < 0 || i >= n) return null;
2749
+ const p0 = outline[i];
2750
+ const p1 = outline[(i + 1) % n];
2751
+ if (p0.bulge) return null;
2752
+ const t0 = Math.max(0, Math.min(1, edge.fromT ?? 0));
2753
+ const t1 = Math.max(0, Math.min(1, edge.toT ?? 1));
2754
+ const a = { x: p0.x + (p1.x - p0.x) * Math.min(t0, t1), y: p0.y + (p1.y - p0.y) * Math.min(t0, t1) };
2755
+ const b = { x: p0.x + (p1.x - p0.x) * Math.max(t0, t1), y: p0.y + (p1.y - p0.y) * Math.max(t0, t1) };
2756
+ const w = Math.hypot(b.x - a.x, b.y - a.y);
2757
+ if (!(w > 0)) return null;
2758
+ let cx = 0;
2759
+ let cy = 0;
2760
+ for (const v of outline) {
2761
+ cx += v.x;
2762
+ cy += v.y;
2763
+ }
2764
+ cx /= n;
2765
+ cy /= n;
2766
+ const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
2767
+ const ex = (b.x - a.x) / w;
2768
+ const ey = (b.y - a.y) / w;
2769
+ let nx = ey;
2770
+ let ny = -ex;
2771
+ if ((mid.x - cx) * nx + (mid.y - cy) * ny < 0) {
2772
+ nx = -nx;
2773
+ ny = -ny;
2774
+ }
2775
+ return {
2776
+ x: mid.x,
2777
+ y: mid.y,
2778
+ heading: norm360(Math.atan2(ny, nx) * 180 / Math.PI),
2779
+ widthInches: w,
2780
+ face: [a, b]
2781
+ };
2782
+ }
2537
2783
  function returnLoop(shape, opts) {
2538
2784
  const L = Math.max(1, opts.leadInches);
2539
2785
  const R = Math.max(1, opts.radius);
@@ -2637,6 +2883,21 @@ function deriveEndplatePoses(geo) {
2637
2883
  const half = geo.trackHalfSpacingInches ?? 1;
2638
2884
  const cfg = (i) => geo.endplateConfigs?.[i] === "double" ? "double" : "single";
2639
2885
  const withOverride = (p) => {
2886
+ const bound = geo.endplateEdges?.[p.id];
2887
+ if (bound) {
2888
+ const outline = (bound.section ? geo.sections?.find((s) => s.id === bound.section)?.outline : geo.outline) ?? geo.outline;
2889
+ const e = endplateEdgePose(outline, bound);
2890
+ if (e)
2891
+ return {
2892
+ ...p,
2893
+ x: e.x,
2894
+ y: e.y,
2895
+ heading: e.heading,
2896
+ widthInches: e.widthInches,
2897
+ face: e.face,
2898
+ boundToEdge: true
2899
+ };
2900
+ }
2640
2901
  const o = geo.poseOverrides?.[p.id];
2641
2902
  return o ? { ...p, x: o.x, y: o.y, heading: norm360(o.heading), manual: true } : p;
2642
2903
  };
@@ -2822,6 +3083,7 @@ exports.FREEMO_ENDPLATE_TRACK_FASCIA_CLEARANCE_INCHES = FREEMO_ENDPLATE_TRACK_FA
2822
3083
  exports.FREEMO_ENDPLATE_WIDTH_MIN_INCHES = FREEMO_ENDPLATE_WIDTH_MIN_INCHES;
2823
3084
  exports.FREEMO_ENDPLATE_WIDTH_RECOMMENDED_INCHES = FREEMO_ENDPLATE_WIDTH_RECOMMENDED_INCHES;
2824
3085
  exports.FREEMO_TRACK_SPACING_INCHES = FREEMO_TRACK_SPACING_INCHES;
3086
+ exports.JOINT_SNAP_INCHES = JOINT_SNAP_INCHES;
2825
3087
  exports.MAIN2_TRACK_ID = MAIN2_TRACK_ID;
2826
3088
  exports.MAIN_TRACK_ID = MAIN_TRACK_ID;
2827
3089
  exports.N_CAR_LENGTH_INCHES = N_CAR_LENGTH_INCHES;
@@ -2838,6 +3100,7 @@ exports.benchworkBand = benchworkBand;
2838
3100
  exports.benchworkOutline = benchworkOutline;
2839
3101
  exports.buildCrossover = buildCrossover;
2840
3102
  exports.buildPassingSiding = buildPassingSiding;
3103
+ exports.buildTrackGraph = buildTrackGraph;
2841
3104
  exports.buildTransition = buildTransition;
2842
3105
  exports.carCapacity = carCapacity;
2843
3106
  exports.checkEndplateWidth = checkEndplateWidth;
@@ -2848,6 +3111,7 @@ exports.divergeSideForHand = divergeSideForHand;
2848
3111
  exports.docToState = docToState;
2849
3112
  exports.emptyEditorState = emptyEditorState;
2850
3113
  exports.endplateCentreOffsetInches = endplateCentreOffsetInches;
3114
+ exports.endplateEdgePose = endplateEdgePose;
2851
3115
  exports.endplateFaceSegments = endplateFaceSegments;
2852
3116
  exports.endplateLead = endplateLead;
2853
3117
  exports.endplateTrackOffsetInches = endplateTrackOffsetInches;
@@ -2885,6 +3149,7 @@ exports.partOutlineAtFrog = partOutlineAtFrog;
2885
3149
  exports.partsPlaceable = partsPlaceable;
2886
3150
  exports.pastFrogInchesForSize = pastFrogInchesForSize;
2887
3151
  exports.pathLengthInches = pathLengthInches;
3152
+ exports.placedJoints = placedJoints;
2888
3153
  exports.poseNeedsManual = poseNeedsManual;
2889
3154
  exports.poseOverridesFromDoc = poseOverridesFromDoc;
2890
3155
  exports.remapPos = remapPos;
@@ -2917,5 +3182,6 @@ exports.turnoutFacing = turnoutFacing;
2917
3182
  exports.turnoutOccupiedSpan = turnoutOccupiedSpan;
2918
3183
  exports.turnoutPartForSize = turnoutPartForSize;
2919
3184
  exports.usableCapacity = usableCapacity;
3185
+ exports.walkTrackGraph = walkTrackGraph;
2920
3186
  //# sourceMappingURL=index.cjs.map
2921
3187
  //# sourceMappingURL=index.cjs.map