From 817e8c7ff2b10ec78c813c9b49896d2088b19104 Mon Sep 17 00:00:00 2001 From: kevin Date: Wed, 14 Oct 2026 20:45:07 +0200 Subject: [PATCH 01/15] Document which ECC plugin skills apply to this repo The ecc@ecc plugin ships 408 skills, 71 agents and 97 commands for every stack, and a plugin install is all-or-nothing: plugin.json declares skills: ["./skills/"], so there is no per-skill enable. The --modules machinery under manifests/ belongs to the standalone `ecc setup` installer, and the lean@1 context profile says of itself that it does not activate a harness. Selection therefore has to happen at use time, which is what this section records. Lists the nine skills and agents that fit a .NET 10 / Uno / Clean Architecture repo, the two that need a caveat, and the gaps: - csharp-testing is xUnit-based but prescribes FluentAssertions, NSubstitute and Testcontainers, none of which this repo uses; tests here are plain xUnit with hand-written doubles under Test/Trackify.Tests/Fakes/. - mcp-server-patterns is Node/TypeScript only with no C# content, so it is not a reference for the ModelContextProtocol work in Infrastructure/Mcp/. - ECC has no C#/.NET build resolver, so /ecc:build-fix has nothing to delegate to for a failed dotnet build. - ECC has no Uno/XAML/MAUI/WinUI skill at all, leaving the whole app head uncovered. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index a806992..6ace52a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,3 +108,53 @@ CLI deployment (Pi publish — no build flags; `trains.json`/store schema, syste - Mobile safe-area (status bar / gesture nav / notch) is handled with the Uno `Toolkit` feature: `utu:SafeArea.Insets` on the page header (`Top`) and content (`Bottom`). Adding a mobile screen? Apply the same so system bars don't overlap. - **BlueZ (Pi) discovery/connect must mirror the mobile stack or hubs never appear** (`BlueZPoweredUpBluetoothAdapter`): (1) **power the radio on first** — a soft-`rfkill`/`Powered=false` adapter silently scans/connects nothing; `GetReadyAdapterAsync` calls `SetPoweredAsync(true)` and otherwise throws an actionable message. (2) **Scan on the LE transport** (`SetDiscoveryFilterAsync{Transport=le}`) — BlueZ's default "auto" (BR/EDR+LE) routinely misses BLE-only hubs and their manufacturer data. (3) **Also enumerate `GetDevicesAsync()`** at scan start — a fresh `StartDiscovery` never re-fires `DeviceFound` for devices BlueZ already cached (mobile sees them from live advertisements). (4) After connect, **wait for `ServicesResolved=true`, not just `Connected`** (`BlueZDevice`) — GATT lookups race and return null otherwise. - **`Discover()` is fire-and-forget** (`void`, `_ = DiscoverLoopAsync(...)` per SharpBrick's `IPoweredUpBluetoothAdapter`) so exceptions inside it are swallowed — a radio-off error can't surface through it. `BlueZLegoService.DiscoverAsync` therefore awaits `adapter.EnsureReadyAsync()` **before** starting the scan. That preflight is why the service depends on the concrete `BlueZPoweredUpBluetoothAdapter` (a method not on SharpBrick's interface), wired via a **forwarding singleton registration** (concrete singleton + `IPoweredUpBluetoothAdapter` factory → same instance) so the SharpBrick host and the service share one radio. + +## ECC plugin — what applies to this repo + +`ecc@ecc` (v2.2.3, user scope) ships **408 skills, 71 agents, 97 commands** covering every stack under +the sun. A *plugin* install is **all-or-nothing** — `plugin.json` declares `skills: ["./skills/"]`, so +there is no per-skill enable and nothing to "install" selectively. (The `--modules` / install-profile +machinery in `manifests/` belongs to the standalone `ecc setup` installer, a different install path; +the `lean@1` context profile is marked "proposed" and does not activate anything.) The useful act is +therefore **knowing which handful applies here**. Everything not listed below — Swift/Apple, Flutter, +Go, Rust, Python/Django/FastAPI, React/Vue/Next, Kotlin, healthcare, supply-chain, prediction-market, +social/media — is noise for Trackify. + +**Use these** + +| ECC piece | Why it fits Trackify | +| --- | --- | +| `ecc:dotnet-patterns` (skill) | Idiomatic C#: DI, async/await, Result pattern, **Repository + EF Core** — maps onto `BaseRepository` / `IDbContextFactory` | +| `ecc:csharp-reviewer` (agent) | The only C# reviewer ECC has — .NET conventions, async, nullable reference types | +| `ecc:hexagonal-architecture` (skill) | Ports/adapters *is* the `ILegoService` seam and the layer rule `Architecture/LayerDependencyTests.cs` enforces | +| `ecc:error-handling` (skill) + `ecc:silent-failure-hunter` (agent) | Matches the standing rule: no silent empty catches — log + return failure, or rethrow | +| `ecc:architecture-decision-records` (skill) | Feeds `docs/arc42/09-architecture-decisions.md` | +| `ecc:database-migrations` (skill) | The `EnsureCreated()`-instead-of-migrations debt (arc42 §11) | +| `ecc:docker-patterns` (skill) | `Source/Trackify.Cli/Dockerfile` + root `docker-compose.yml` | +| `ecc:type-design-analyzer`, `ecc:comment-analyzer` (agents) | Read-only and language-agnostic, so they work here unchanged | +| `/ecc:code-review`, `/ecc:test-coverage`, `/ecc:security-scan`, `/ecc:quality-gate` | Stack-agnostic commands | + +**Use with a caveat** + +- **`ecc:csharp-testing`** — right framework (xUnit) but it prescribes **FluentAssertions, NSubstitute + and Testcontainers**, none of which this repo uses. Tests here are plain xUnit with hand-written + doubles in `Test/Trackify.Tests/Fakes/`. Take its structure advice (AAA, `[Theory]`, data builders); + **do not add those packages** to `Directory.Packages.props`. +- **`ecc:mcp-server-patterns`** — **Node/TypeScript only, zero C# content.** For the + `ModelContextProtocol` 2.2.0 work in `Trackify.Infrastructure/Mcp/` it is worth reading on transport + choice (stdio vs Streamable HTTP) and tool shape, but it has no C# SDK API. Do not treat it as a + reference for `[McpServerTool]` / `AddMcpServer`. + +**Gaps — ECC has nothing for these** + +- **No C#/.NET build resolver.** `/ecc:build-fix` delegates to the cpp/dart/django/go/java/kotlin/ + pytorch/react/rust/swift resolvers; for a failed `dotnet build` there is nothing to delegate to + (`ecc:build-error-resolver` is the TypeScript one). Use this file's own build-and-verify loop. +- **No Uno / XAML / MAUI / WinUI skill at all.** The entire app head — `Presentation/`, + Components/Widgets, converters, design tokens, the German UI — is uncovered. + `ecc:windows-desktop-e2e` is Windows-app E2E, not Uno. + +**Hooks.** Installed at `hook_profile=standard`, so ECC's `GateGuard` demands a stated reason before +the first Bash call of a session, and other hooks fire on tool use. Lighten it permanently by +reinstalling with `--config hook_profile=minimal`, or per session with `ECC_GATEGUARD=off` / +`ECC_DISABLED_HOOKS=pre:bash:gateguard-fact-force`. From 8299cb64d3c0ef0ef183501e42642eea1483d2da Mon Sep 17 00:00:00 2001 From: kevin Date: Wed, 14 Oct 2026 21:14:42 +0200 Subject: [PATCH 02/15] Model LEGO track geometry as a port-connected graph The Streckenplaner's geometry was invented: 90-unit straights, radius-70 curves, 90-degree turns, 30-degree branch peel-off. None of those numbers exist in the LEGO system, so a plan built with them cannot be plugged together out of real track. Replace it with the real dimensions, in studs (1 stud = 8 mm): straight 16 studs (53401) curve R40, 22.5 degrees, 16 to the full circle (53400) point 32 studs main route, 22.5 degree diverging route (53407/53404) crossing 16x16 studs, 90 degrees (9V 32087) The important consequence is that positions cannot be snapped to a grid. A curve ends at 40*sin(22.5 degrees) = 15.3073... studs, so after a few parts nothing sits on a round number; rounding would produce a plan that looks tidy on screen and falls apart in plastic. The only safe invariant is that every part is plugged into a port of another part, so a plan is a graph of parts and port connections and world coordinates are derived by walking it -- never stored, never entered. Headings, by contrast, are exact: always a multiple of 22.5 degrees, so a heading is an integer step 0..15 and compares exactly. Positions compare against a tolerance. Catalogue port poses are derived from the drawn spans rather than typed in, so geometry and connection points cannot drift apart. The catalogue holds only parts LEGO actually makes; the community radii R56..R104 are each one more entry, not a code change. The walker reports connections the geometry contradicts instead of letting the last write win -- that is a loop the user drew which does not really close, and saying "one curve short" is the most useful thing the planner can tell them. Verified by 77 tests, including: 16 R40 curves return to the start pose, a stadium oval of 8 curves + straight + 8 curves + straight closes, and a 15-curve loop is diagnosed as one 22.5 degree step and one chord short. Co-Authored-By: Claude Opus 5 --- Source/Trackify.Domain/Enums/TrackPartKind.cs | 24 +++ Source/Trackify.Domain/Tracks/StudPose.cs | 99 +++++++++ .../Tracks/TrackJoinMismatch.cs | 37 ++++ Source/Trackify.Domain/Tracks/TrackMetrics.cs | 46 ++++ Source/Trackify.Domain/Tracks/TrackOpenEnd.cs | 7 + .../Tracks/TrackPartCatalog.cs | 195 +++++++++++++++++ .../Tracks/TrackPartPlacement.cs | 26 +++ .../Trackify.Domain/Tracks/TrackPartSpec.cs | 57 +++++ .../Trackify.Domain/Tracks/TrackPlanLayout.cs | 28 +++ .../Trackify.Domain/Tracks/TrackPlanLink.cs | 16 ++ .../Trackify.Domain/Tracks/TrackPlanPart.cs | 13 ++ .../Tracks/TrackPlanTopology.cs | 65 ++++++ .../Trackify.Domain/Tracks/TrackPlanWalker.cs | 196 +++++++++++++++++ Source/Trackify.Domain/Tracks/TrackPort.cs | 8 + .../Trackify.Domain/Tracks/TrackPortNames.cs | 36 ++++ Source/Trackify.Domain/Tracks/TrackRoute.cs | 8 + Source/Trackify.Domain/Tracks/TrackSpan.cs | 63 ++++++ Test/Trackify.Tests/Domain/StudPoseTests.cs | 90 ++++++++ .../Domain/TrackPartCatalogTests.cs | 166 +++++++++++++++ .../Domain/TrackPlanWalkerTests.cs | 199 ++++++++++++++++++ 20 files changed, 1379 insertions(+) create mode 100644 Source/Trackify.Domain/Enums/TrackPartKind.cs create mode 100644 Source/Trackify.Domain/Tracks/StudPose.cs create mode 100644 Source/Trackify.Domain/Tracks/TrackJoinMismatch.cs create mode 100644 Source/Trackify.Domain/Tracks/TrackMetrics.cs create mode 100644 Source/Trackify.Domain/Tracks/TrackOpenEnd.cs create mode 100644 Source/Trackify.Domain/Tracks/TrackPartCatalog.cs create mode 100644 Source/Trackify.Domain/Tracks/TrackPartPlacement.cs create mode 100644 Source/Trackify.Domain/Tracks/TrackPartSpec.cs create mode 100644 Source/Trackify.Domain/Tracks/TrackPlanLayout.cs create mode 100644 Source/Trackify.Domain/Tracks/TrackPlanLink.cs create mode 100644 Source/Trackify.Domain/Tracks/TrackPlanPart.cs create mode 100644 Source/Trackify.Domain/Tracks/TrackPlanTopology.cs create mode 100644 Source/Trackify.Domain/Tracks/TrackPlanWalker.cs create mode 100644 Source/Trackify.Domain/Tracks/TrackPort.cs create mode 100644 Source/Trackify.Domain/Tracks/TrackPortNames.cs create mode 100644 Source/Trackify.Domain/Tracks/TrackRoute.cs create mode 100644 Source/Trackify.Domain/Tracks/TrackSpan.cs create mode 100644 Test/Trackify.Tests/Domain/StudPoseTests.cs create mode 100644 Test/Trackify.Tests/Domain/TrackPartCatalogTests.cs create mode 100644 Test/Trackify.Tests/Domain/TrackPlanWalkerTests.cs diff --git a/Source/Trackify.Domain/Enums/TrackPartKind.cs b/Source/Trackify.Domain/Enums/TrackPartKind.cs new file mode 100644 index 0000000..39b5758 --- /dev/null +++ b/Source/Trackify.Domain/Enums/TrackPartKind.cs @@ -0,0 +1,24 @@ +namespace Trackify.Domain.Enums; + +/// +/// A physical LEGO track part the planner can lay. Distinct from , which +/// describes a segment's *role* in the old strand chain — this names the actual moulded piece, so +/// its geometry (length, radius, sweep, number of ports) is fixed by LEGO and not by us. +/// +public enum TrackPartKind +{ + /// Straight, 16 studs (part 53401). + Straight, + + /// R40 curve, 22.5 degrees, 16 to the full circle (part 53400). + Curve, + + /// Point/turnout, 32 studs on the main route, 22.5 degree diverging route (53407 / 53404). + Switch, + + /// 90 degree crossing, 16x16 studs (9V part 32087; no current plastic equivalent). + Crossing, + + /// A straight used as a stop: same geometry, different meaning. + Station, +} diff --git a/Source/Trackify.Domain/Tracks/StudPose.cs b/Source/Trackify.Domain/Tracks/StudPose.cs new file mode 100644 index 0000000..0d8a040 --- /dev/null +++ b/Source/Trackify.Domain/Tracks/StudPose.cs @@ -0,0 +1,99 @@ +namespace Trackify.Domain.Tracks; + +/// +/// A position and facing on the track plan, in studs. is a 22.5 degree +/// step 0..15 (see ), not an angle: LEGO track can only ever +/// point in those 16 directions, so keeping it an integer makes a heading comparison exact. The +/// position cannot be made exact the same way — a curve's end lands on +/// 40*sin(22.5 degrees) = 15.3073... studs — which is why positions are compared with +/// and never rounded to a grid. +/// +/// X runs right, Y runs down (matching the canvas), so an increasing heading turns clockwise on +/// screen and is a positive sweep. +/// +public readonly record struct StudPose(double X, double Y, int Heading) +{ + /// The plan's anchor: origin, pointing along +X. + public static StudPose Origin => new(0, 0, 0); + + /// This heading as an angle in radians. + public double HeadingRadians => Heading * TrackMetrics.RadiansPerHeadingStep; + + /// Unit vector along the heading. + public double DirectionX => Math.Cos(HeadingRadians); + + /// + public double DirectionY => Math.Sin(HeadingRadians); + + /// + /// Reads this pose as a coordinate frame and maps into it — the one + /// operation that places a part: a part's ports and spans are authored in its own local frame, + /// and composing them with the part's world anchor yields world geometry. + /// + public StudPose Compose(in StudPose local) + { + var cos = Math.Cos(HeadingRadians); + var sin = Math.Sin(HeadingRadians); + + return new StudPose( + X + (local.X * cos) - (local.Y * sin), + Y + (local.X * sin) + (local.Y * cos), + TrackMetrics.NormalizeHeading(Heading + local.Heading)); + } + + /// + /// The frame that undoes this one: pose.Compose(pose.Inverse()) is the origin. Used to + /// place a part by one of its ports — given where the port must end up, the part's + /// anchor is that target composed with the port's inverse local pose. + /// + public StudPose Inverse() + { + var cos = Math.Cos(HeadingRadians); + var sin = Math.Sin(HeadingRadians); + + return new StudPose( + -((X * cos) + (Y * sin)), + (X * sin) - (Y * cos), + TrackMetrics.NormalizeHeading(-Heading)); + } + + /// + /// Same point, opposite facing. Ports face outward from their part, so two ports are + /// plugged together exactly when one's pose is the other's . + /// + public StudPose Reversed() => this with + { + Heading = TrackMetrics.NormalizeHeading(Heading + (TrackMetrics.HeadingCount / 2)), + }; + + /// Moves along the heading, keeping the facing. + public StudPose Advance(double studs) => this with + { + X = X + (DirectionX * studs), + Y = Y + (DirectionY * studs), + }; + + /// Distance between the two positions, ignoring heading. + public double DistanceTo(in StudPose other) => Math.Sqrt(DistanceSquaredTo(other)); + + /// Squared distance — for comparisons and inner loops, where the square root is waste. + public double DistanceSquaredTo(in StudPose other) + { + var dx = X - other.X; + var dy = Y - other.Y; + + return (dx * dx) + (dy * dy); + } + + /// + /// Whether a port at this pose plugs into a port at : positions within + /// and headings exactly opposite. + /// + public bool Joins(in StudPose other, double toleranceStuds = TrackMetrics.JoinToleranceStuds) + => Heading == TrackMetrics.NormalizeHeading(other.Heading + (TrackMetrics.HeadingCount / 2)) + && DistanceSquaredTo(other) <= toleranceStuds * toleranceStuds; + + /// Whether both poses describe the same place and facing, within tolerance. + public bool Matches(in StudPose other, double toleranceStuds = TrackMetrics.JoinToleranceStuds) + => Heading == other.Heading && DistanceSquaredTo(other) <= toleranceStuds * toleranceStuds; +} diff --git a/Source/Trackify.Domain/Tracks/TrackJoinMismatch.cs b/Source/Trackify.Domain/Tracks/TrackJoinMismatch.cs new file mode 100644 index 0000000..31ea447 --- /dev/null +++ b/Source/Trackify.Domain/Tracks/TrackJoinMismatch.cs @@ -0,0 +1,37 @@ +namespace Trackify.Domain.Tracks; + +/// +/// A connection the plan claims but the geometry refuses: the walk reached the same part by two +/// different routes and they disagree. In practice this is a loop the user drew that does not +/// actually close with real parts — the most useful thing the planner can tell them, and the reason +/// the walker reports mismatches instead of letting the last write win. +/// +public sealed record TrackJoinMismatch +{ + /// The connection that could not be honoured. + public required TrackPlanLink Link { get; init; } + + /// Where the already-placed part puts the joint. + public required StudPose Expected { get; init; } + + /// Where the other side's own placement puts it. + public required StudPose Actual { get; init; } + + /// How far apart the two are, in studs. + public double GapStuds => Expected.DistanceTo(Actual); + + /// + /// How many 22.5 degree steps the two facings differ by, as the shortest signed turn (-8..7). + /// Signed and shortest because the useful message is "your loop is one curve short", which a + /// plain 0..15 difference would report as 15. + /// + public int HeadingStepsOff + { + get + { + var difference = TrackMetrics.NormalizeHeading(Expected.Heading - Actual.Heading); + + return difference > TrackMetrics.HeadingCount / 2 ? difference - TrackMetrics.HeadingCount : difference; + } + } +} diff --git a/Source/Trackify.Domain/Tracks/TrackMetrics.cs b/Source/Trackify.Domain/Tracks/TrackMetrics.cs new file mode 100644 index 0000000..e83a71a --- /dev/null +++ b/Source/Trackify.Domain/Tracks/TrackMetrics.cs @@ -0,0 +1,46 @@ +namespace Trackify.Domain.Tracks; + +/// +/// The real LEGO track dimensions, in studs. Every number here is a physical property of the moulded +/// parts rather than a drawing choice — the sources are recorded in arc42 section 9. +/// +public static class TrackMetrics +{ + /// One stud is 8 mm; the standard gauge is 5 studs (40 mm). + public const double MillimetresPerStud = 8.0; + + /// + /// LEGO curves sweep 22.5 degrees, so exactly 16 of them close a circle. Every heading a track + /// can point in is therefore a multiple of 22.5 degrees — which is why a heading is stored as an + /// integer step 0..15 and compared exactly, never as a float. + /// + public const int HeadingCount = 16; + + /// 22.5 degrees. + public const double DegreesPerHeadingStep = 360.0 / HeadingCount; + + /// 22.5 degrees in radians. + public const double RadiansPerHeadingStep = Math.PI * 2 / HeadingCount; + + /// R40 — LEGO's one and only curve radius since 1966, measured to the track centreline. + public const double CurveRadiusStuds = 40.0; + + /// A full straight: 16 studs / 128 mm. + public const double StraightStuds = 16.0; + + /// A point is 32 studs long on its main route — exactly two straights. + public const double SwitchStuds = 32.0; + + /// The 90 degree crossing is a 16x16 stud square. + public const double CrossingStuds = 16.0; + + /// + /// How close two port positions must be to count as plugged together. Positions accumulate + /// through sines of 22.5 degrees and are therefore irrational, so they are compared with a + /// tolerance — while headings, being integers, are compared exactly. + /// + public const double JoinToleranceStuds = 0.3; + + /// Normalises a heading step into 0..15, so -1 reads as 15 and 16 as 0. + public static int NormalizeHeading(int heading) => ((heading % HeadingCount) + HeadingCount) % HeadingCount; +} diff --git a/Source/Trackify.Domain/Tracks/TrackOpenEnd.cs b/Source/Trackify.Domain/Tracks/TrackOpenEnd.cs new file mode 100644 index 0000000..2cc8f20 --- /dev/null +++ b/Source/Trackify.Domain/Tracks/TrackOpenEnd.cs @@ -0,0 +1,7 @@ +namespace Trackify.Domain.Tracks; + +/// +/// A port with nothing plugged into it — where the plan can still grow. These are what the editor +/// draws as the big tappable "Andockpunkte" and what a drag starts from. +/// +public readonly record struct TrackOpenEnd(Guid PartId, string PortName, StudPose WorldPose); diff --git a/Source/Trackify.Domain/Tracks/TrackPartCatalog.cs b/Source/Trackify.Domain/Tracks/TrackPartCatalog.cs new file mode 100644 index 0000000..6b335f6 --- /dev/null +++ b/Source/Trackify.Domain/Tracks/TrackPartCatalog.cs @@ -0,0 +1,195 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Trackify.Domain.Tracks; + +/// +/// Every track part the planner knows, with LEGO's real dimensions. Deliberately limited to parts +/// LEGO itself makes, because that is what someone actually owns: the 16-stud straight (53401), the +/// R40 / 22.5 degree curve (53400), the left and right point (53407 / 53404), the 90 degree crossing +/// (9V 32087) and a station, which is a straight with a different meaning. +/// +/// Community radii (R56..R104 from ME Models, BrickTracks, 4DBrix) and part-length straights are +/// deliberately absent, not unsupported — each would be one more here. +/// +/// Port poses are derived from the spans rather than typed in, so the two can never drift +/// apart: a curve's end is wherever its arc actually finishes. +/// +public static class TrackPartCatalog +{ + /// Straight, 16 studs (53401). + public const string StraightKey = "straight"; + + /// Straight used as a stop. + public const string StationKey = "station"; + + /// R40 curve bending left (53400, mirrored). + public const string CurveLeftKey = "curve-r40-left"; + + /// R40 curve bending right (53400). + public const string CurveRightKey = "curve-r40-right"; + + /// Point diverging left (53407). + public const string SwitchLeftKey = "switch-left"; + + /// Point diverging right (53404). + public const string SwitchRightKey = "switch-right"; + + /// 90 degree crossing, 16x16 studs (9V 32087). + public const string CrossingKey = "crossing-x90"; + + // Initialised in textual order: Specs first, then the two views over it. + private static readonly Dictionary Specs = BuildSpecs(); + + private static readonly TrackPartSpec[] AllSpecs = [.. Specs.Values]; + + private static readonly TrackPartSpec[] Drawable = + [ + Specs[StraightKey], + Specs[CurveLeftKey], + Specs[CurveRightKey], + ]; + + /// Every known part. + public static IReadOnlyList All => AllSpecs; + + /// + /// The parts the drawing snapper may lay while following a drag: a straight and the two curves. + /// Points, crossings and stations are placed deliberately, never guessed from a gesture. + /// + public static IReadOnlyList DrawablePieces => Drawable; + + /// The spec for . Throws when the key is unknown. + public static TrackPartSpec Get(string key) + => Specs.TryGetValue(key, out var spec) + ? spec + : throw new ArgumentOutOfRangeException(nameof(key), key, "Unknown track part key."); + + /// The spec for , or false when it is not a known part. + public static bool TryGet(string key, [MaybeNullWhen(false)] out TrackPartSpec spec) + => Specs.TryGetValue(key, out spec); + + /// The catalogue key for a curve bending . + public static string CurveKey(CurveDirection hand) => hand == CurveDirection.Left ? CurveLeftKey : CurveRightKey; + + /// The catalogue key for a point diverging . + public static string SwitchKey(CurveDirection hand) => hand == CurveDirection.Left ? SwitchLeftKey : SwitchRightKey; + + private static Dictionary BuildSpecs() + { + var specs = new[] + { + RunOfRail(StraightKey, TrackPartKind.Straight, TrackMetrics.StraightStuds), + RunOfRail(StationKey, TrackPartKind.Station, TrackMetrics.StraightStuds), + Curve(CurveLeftKey, CurveDirection.Left), + Curve(CurveRightKey, CurveDirection.Right), + Point(SwitchLeftKey, CurveDirection.Left), + Point(SwitchRightKey, CurveDirection.Right), + Crossing(), + }; + + return specs.ToDictionary(spec => spec.Key, StringComparer.Ordinal); + } + + // A plain piece of rail: enters at the origin, leaves `lengthStuds` later on the same heading. + private static TrackPartSpec RunOfRail(string key, TrackPartKind kind, double lengthStuds) + { + var span = TrackSpan.Straight(StudPose.Origin, lengthStuds); + + return new TrackPartSpec + { + Key = key, + Kind = kind, + Ports = + [ + new TrackPort(TrackPortNames.Start, StudPose.Origin.Reversed()), + new TrackPort(TrackPortNames.End, span.End), + ], + Spans = [span], + Routes = [new TrackRoute(TrackPortNames.Main, TrackPortNames.Start, TrackPortNames.End)], + }; + } + + // R40, 22.5 degrees — one sixteenth of a circle, which is why 16 of these close a loop. + private static TrackPartSpec Curve(string key, CurveDirection hand) + { + var span = TrackSpan.Arc(StudPose.Origin, TrackMetrics.CurveRadiusStuds, SweepOf(hand)); + + return new TrackPartSpec + { + Key = key, + Kind = TrackPartKind.Curve, + Hand = hand, + Ports = + [ + new TrackPort(TrackPortNames.Start, StudPose.Origin.Reversed()), + new TrackPort(TrackPortNames.End, span.End), + ], + Spans = [span], + Routes = [new TrackRoute(TrackPortNames.Main, TrackPortNames.Start, TrackPortNames.End)], + }; + } + + // A point is 32 studs long on the straight route (exactly two straights) and diverges by 22.5 + // degrees — the same sweep as an R40 curve. The diverging route is therefore modelled as half the + // body straight followed by one R40 curve, which is the equivalence every LEGO track planner + // uses. It is the one number in this file worth checking against a physical point. + private static TrackPartSpec Point(string key, CurveDirection hand) + { + var mainSpan = TrackSpan.Straight(StudPose.Origin, TrackMetrics.SwitchStuds); + var branchLead = StudPose.Origin.Advance(TrackMetrics.SwitchStuds / 2); + var branchSpan = TrackSpan.Arc(branchLead, TrackMetrics.CurveRadiusStuds, SweepOf(hand)); + + return new TrackPartSpec + { + Key = key, + Kind = TrackPartKind.Switch, + Hand = hand, + Ports = + [ + new TrackPort(TrackPortNames.Toe, StudPose.Origin.Reversed()), + new TrackPort(TrackPortNames.Main, mainSpan.End), + new TrackPort(TrackPortNames.Branch, branchSpan.End), + ], + // Both spans are drawn: the full 32-stud body plus the curve peeling off its midpoint. + // The diverging route shares the body's first half, so there is nothing to draw twice. + Spans = [mainSpan, branchSpan], + Routes = + [ + new TrackRoute(TrackPortNames.Main, TrackPortNames.Toe, TrackPortNames.Main), + new TrackRoute(TrackPortNames.Branch, TrackPortNames.Toe, TrackPortNames.Branch), + ], + }; + } + + // 16x16 studs, two straight routes meeting at the centre and never joining. + private static TrackPartSpec Crossing() + { + var half = TrackMetrics.CrossingStuds / 2; + var acrossSpan = TrackSpan.Straight(StudPose.Origin, TrackMetrics.CrossingStuds); + var downSpan = TrackSpan.Straight( + new StudPose(half, -half, TrackMetrics.HeadingCount / 4), + TrackMetrics.CrossingStuds); + + return new TrackPartSpec + { + Key = CrossingKey, + Kind = TrackPartKind.Crossing, + Ports = + [ + new TrackPort(TrackPortNames.CrossA1, StudPose.Origin.Reversed()), + new TrackPort(TrackPortNames.CrossA2, acrossSpan.End), + new TrackPort(TrackPortNames.CrossB1, downSpan.Start.Reversed()), + new TrackPort(TrackPortNames.CrossB2, downSpan.End), + ], + Spans = [acrossSpan, downSpan], + Routes = + [ + new TrackRoute(TrackPortNames.CrossA1, TrackPortNames.CrossA1, TrackPortNames.CrossA2), + new TrackRoute(TrackPortNames.CrossB1, TrackPortNames.CrossB1, TrackPortNames.CrossB2), + ], + }; + } + + // Right bends clockwise on screen, which is a positive heading step (Y runs down). + private static int SweepOf(CurveDirection hand) => hand == CurveDirection.Left ? -1 : 1; +} diff --git a/Source/Trackify.Domain/Tracks/TrackPartPlacement.cs b/Source/Trackify.Domain/Tracks/TrackPartPlacement.cs new file mode 100644 index 0000000..052313d --- /dev/null +++ b/Source/Trackify.Domain/Tracks/TrackPartPlacement.cs @@ -0,0 +1,26 @@ +namespace Trackify.Domain.Tracks; + +/// +/// Where a part actually ended up after the walk: its world frame plus its rail in world +/// coordinates, ready to be turned into path data by whoever is drawing. +/// +public sealed record TrackPartPlacement +{ + /// The plan part this places. + public required Guid PartId { get; init; } + + /// Which catalogue part it is. + public required string SpecKey { get; init; } + + /// The part's local frame expressed in world studs. + public required StudPose Anchor { get; init; } + + /// The part's rail, in world studs. + public required IReadOnlyList Spans { get; init; } + + /// The catalogue geometry behind this placement. + public TrackPartSpec Spec => TrackPartCatalog.Get(SpecKey); + + /// The world pose of on this part. + public StudPose PortPose(string portName) => Anchor.Compose(Spec.PortPose(portName)); +} diff --git a/Source/Trackify.Domain/Tracks/TrackPartSpec.cs b/Source/Trackify.Domain/Tracks/TrackPartSpec.cs new file mode 100644 index 0000000..22f39a5 --- /dev/null +++ b/Source/Trackify.Domain/Tracks/TrackPartSpec.cs @@ -0,0 +1,57 @@ +namespace Trackify.Domain.Tracks; + +/// +/// The fixed geometry of one kind of LEGO track part, authored in the part's own local frame: +/// where it can be plugged in (), what it looks like (), and +/// how a train can run through it (). +/// +/// This is data, not code — adding a radius or a half-straight later is a catalogue entry in +/// , not a change to the walker, the snapper or the renderer. +/// +public sealed record TrackPartSpec +{ + /// Stable identifier, also what the exchange format writes (e.g. "curve-r40-right"). + public required string Key { get; init; } + + /// Which moulded piece this is. + public required TrackPartKind Kind { get; init; } + + /// Which way a curve bends or a point diverges; null for symmetric parts. + public CurveDirection? Hand { get; init; } + + /// Every pluggable end, with its outward-facing local pose. + public required IReadOnlyList Ports { get; init; } + + /// The rail to draw, in local coordinates. + public required IReadOnlyList Spans { get; init; } + + /// The port pairs a train can travel between. + public required IReadOnlyList Routes { get; init; } + + /// How much rail this part contributes, in studs — the straight-route length for a point. + public double TotalLengthStuds => Spans.Count == 0 ? 0 : Spans[0].LengthStuds; + + /// Whether is one of this part's ports. + public bool HasPort(string portName) => TryPortPose(portName, out _); + + /// The local pose of , or false if this part has no such port. + public bool TryPortPose(string portName, out StudPose pose) + { + foreach (var port in Ports) + { + if (!string.Equals(port.Name, portName, StringComparison.Ordinal)) continue; + + pose = port.LocalPose; + return true; + } + + pose = default; + return false; + } + + /// The local pose of . Throws when the port is unknown. + public StudPose PortPose(string portName) + => TryPortPose(portName, out var pose) + ? pose + : throw new ArgumentOutOfRangeException(nameof(portName), portName, $"Part '{Key}' has no port '{portName}'."); +} diff --git a/Source/Trackify.Domain/Tracks/TrackPlanLayout.cs b/Source/Trackify.Domain/Tracks/TrackPlanLayout.cs new file mode 100644 index 0000000..ee12f55 --- /dev/null +++ b/Source/Trackify.Domain/Tracks/TrackPlanLayout.cs @@ -0,0 +1,28 @@ +namespace Trackify.Domain.Tracks; + +/// +/// The result of walking a : where every part sits, where the plan +/// can still grow, and any connection the geometry could not honour. +/// +public sealed record TrackPlanLayout +{ + /// The layout of an empty plan. + public static TrackPlanLayout Empty { get; } = new() + { + Placements = new Dictionary(), + OpenEnds = [], + Mismatches = [], + }; + + /// Every part's world placement, by part id. + public required IReadOnlyDictionary Placements { get; init; } + + /// Ports with nothing plugged into them. + public required IReadOnlyList OpenEnds { get; init; } + + /// Connections the geometry contradicts — in practice, loops that do not really close. + public required IReadOnlyList Mismatches { get; init; } + + /// Whether this plan could be plugged together out of real parts. + public bool IsBuildable => Mismatches.Count == 0; +} diff --git a/Source/Trackify.Domain/Tracks/TrackPlanLink.cs b/Source/Trackify.Domain/Tracks/TrackPlanLink.cs new file mode 100644 index 0000000..b0a7a4f --- /dev/null +++ b/Source/Trackify.Domain/Tracks/TrackPlanLink.cs @@ -0,0 +1,16 @@ +namespace Trackify.Domain.Tracks; + +/// +/// Two parts plugged together at a named port each. This is the plan's only structural truth — +/// everything geometric follows from it. +/// +public readonly record struct TrackPlanLink(Guid FromPartId, string FromPort, Guid ToPartId, string ToPort) +{ + /// The same connection stated from the other end. + public TrackPlanLink Flipped() => new(ToPartId, ToPort, FromPartId, FromPort); + + /// Whether this link uses on . + public bool Uses(Guid partId, string portName) + => (FromPartId == partId && string.Equals(FromPort, portName, StringComparison.Ordinal)) + || (ToPartId == partId && string.Equals(ToPort, portName, StringComparison.Ordinal)); +} diff --git a/Source/Trackify.Domain/Tracks/TrackPlanPart.cs b/Source/Trackify.Domain/Tracks/TrackPlanPart.cs new file mode 100644 index 0000000..c6815a9 --- /dev/null +++ b/Source/Trackify.Domain/Tracks/TrackPlanPart.cs @@ -0,0 +1,13 @@ +namespace Trackify.Domain.Tracks; + +/// +/// One laid part in a plan: an identity plus which catalogue part it is. Deliberately carries no +/// position — where it sits is derived by walking the connections +/// (), because a stored coordinate could describe a layout that cannot +/// physically be plugged together. +/// +public readonly record struct TrackPlanPart(Guid Id, string SpecKey) +{ + /// The catalogue geometry for this part. + public TrackPartSpec Spec => TrackPartCatalog.Get(SpecKey); +} diff --git a/Source/Trackify.Domain/Tracks/TrackPlanTopology.cs b/Source/Trackify.Domain/Tracks/TrackPlanTopology.cs new file mode 100644 index 0000000..29ff479 --- /dev/null +++ b/Source/Trackify.Domain/Tracks/TrackPlanTopology.cs @@ -0,0 +1,65 @@ +namespace Trackify.Domain.Tracks; + +/// +/// The structure of a track plan: which parts are laid and which of their ports are plugged into +/// each other. Immutable — every edit returns a new topology, which is what makes undo/redo in the +/// editor a plain stack. +/// +/// There are no coordinates here on purpose. See for why. +/// +public sealed record TrackPlanTopology +{ + /// An empty plan. + public static TrackPlanTopology Empty { get; } = new() { Parts = [], Links = [] }; + + /// The laid parts. + public required IReadOnlyList Parts { get; init; } + + /// How they are plugged together. + public required IReadOnlyList Links { get; init; } + + /// Whether 's already has something in it. + public bool IsPortUsed(Guid partId, string portName) + { + foreach (var link in Links) + { + if (link.Uses(partId, portName)) return true; + } + + return false; + } + + /// The part with , or false when the plan has no such part. + public bool TryGetPart(Guid partId, out TrackPlanPart part) + { + foreach (var candidate in Parts) + { + if (candidate.Id != partId) continue; + + part = candidate; + return true; + } + + part = default; + return false; + } + + /// Adds a part, leaving it unconnected. + public TrackPlanTopology Add(TrackPlanPart part) => this with { Parts = [.. Parts, part] }; + + /// Plugs two ports together. + public TrackPlanTopology Connect(TrackPlanLink link) => this with { Links = [.. Links, link] }; + + /// Removes a part and every connection that touched it. + public TrackPlanTopology Remove(Guid partId) => this with + { + Parts = [.. Parts.Where(part => part.Id != partId)], + Links = [.. Links.Where(link => link.FromPartId != partId && link.ToPartId != partId)], + }; + + /// Unplugs one connection, leaving both parts in place. + public TrackPlanTopology Disconnect(Guid partId, string portName) => this with + { + Links = [.. Links.Where(link => !link.Uses(partId, portName))], + }; +} diff --git a/Source/Trackify.Domain/Tracks/TrackPlanWalker.cs b/Source/Trackify.Domain/Tracks/TrackPlanWalker.cs new file mode 100644 index 0000000..029bbc9 --- /dev/null +++ b/Source/Trackify.Domain/Tracks/TrackPlanWalker.cs @@ -0,0 +1,196 @@ +namespace Trackify.Domain.Tracks; + +/// +/// Turns a into real coordinates by walking the connections from a +/// root, each part's placement following from its neighbour's. +/// +/// Coordinates are derived rather than stored because they cannot be authored safely: a curve's end +/// lands on 40*sin(22.5 degrees) = 15.3073... studs, so after a handful of parts nothing sits on a +/// round number. Snapping stored positions to a grid would produce a plan that looks tidy on screen +/// and cannot be plugged together out of real track. Walking the graph instead means every layout +/// this produces is buildable by construction — and when the user's own connections contradict each +/// other, that shows up as a rather than being quietly rounded away. +/// +/// Output is in world studs. Fitting to a canvas is the caller's job. +/// +public static class TrackPlanWalker +{ + /// + /// Vertical gap between parts groups that are not connected to each other, so a plan saved + /// mid-build does not stack two disjoint pieces of track on top of each other. Three straights. + /// + private const double DisconnectedGroupGapStuds = 48.0; + + /// Places every part in and reports the open ends. + public static TrackPlanLayout Walk(TrackPlanTopology topology) + { + if (topology.Parts.Count == 0) return TrackPlanLayout.Empty; + + var specs = new Dictionary(topology.Parts.Count); + foreach (var part in topology.Parts) specs[part.Id] = part.Spec; + + var neighbours = BuildAdjacency(topology); + var anchors = new Dictionary(topology.Parts.Count); + var mismatches = new List(); + var reported = new HashSet(); + var lane = 0; + + // Each pass plants one disconnected group's root and spreads out from it; parts a plan never + // connected (a branch whose point was deleted, say) still get a place, and stay deletable. + foreach (var part in topology.Parts) + { + if (anchors.ContainsKey(part.Id)) continue; + + anchors[part.Id] = new StudPose(0, lane++ * DisconnectedGroupGapStuds, 0); + Spread(part.Id, neighbours, specs, anchors, mismatches, reported); + } + + return new TrackPlanLayout + { + Placements = BuildPlacements(topology, specs, anchors), + OpenEnds = CollectOpenEnds(topology, specs, anchors), + Mismatches = mismatches, + }; + } + + // Breadth-first from one planted root: every link out of a placed part fixes its neighbour. + private static void Spread( + Guid rootId, + IReadOnlyDictionary> neighbours, + IReadOnlyDictionary specs, + Dictionary anchors, + List mismatches, + HashSet reported) + { + var queue = new Queue(); + queue.Enqueue(rootId); + + while (queue.Count > 0) + { + var partId = queue.Dequeue(); + if (!specs.TryGetValue(partId, out var spec)) continue; + if (!neighbours.TryGetValue(partId, out var joints)) continue; + + foreach (var joint in joints) + { + if (!specs.TryGetValue(joint.OtherPartId, out var otherSpec)) continue; + if (!spec.TryPortPose(joint.OwnPort, out var ownLocal)) continue; + if (!otherSpec.TryPortPose(joint.OtherPort, out var otherLocal)) continue; + + // The neighbour's port has to end up facing ours at the same point, so its anchor is + // that target seen through the inverse of the port's own local pose. + var target = anchors[partId].Compose(ownLocal).Reversed(); + var otherAnchor = target.Compose(otherLocal.Inverse()); + + if (anchors.TryGetValue(joint.OtherPartId, out var existing)) + { + // Every connection sits in the adjacency twice, once per side, so a bad joint + // would otherwise be reported from both ends as two separate problems. + var key = JointKey.For(partId, joint.OwnPort, joint.OtherPartId, joint.OtherPort); + + if (!existing.Matches(otherAnchor) && reported.Add(key)) + { + mismatches.Add(new TrackJoinMismatch + { + Link = new TrackPlanLink(partId, joint.OwnPort, joint.OtherPartId, joint.OtherPort), + Expected = target, + Actual = existing.Compose(otherLocal), + }); + } + + continue; + } + + anchors[joint.OtherPartId] = otherAnchor; + queue.Enqueue(joint.OtherPartId); + } + } + } + + private static Dictionary> BuildAdjacency(TrackPlanTopology topology) + { + var adjacency = new Dictionary>(topology.Parts.Count); + + foreach (var link in topology.Links) + { + Append(adjacency, link.FromPartId, new Joint(link.FromPort, link.ToPartId, link.ToPort)); + Append(adjacency, link.ToPartId, new Joint(link.ToPort, link.FromPartId, link.FromPort)); + } + + return adjacency; + + static void Append(Dictionary> adjacency, Guid partId, Joint joint) + { + if (!adjacency.TryGetValue(partId, out var list)) adjacency[partId] = list = []; + + list.Add(joint); + } + } + + private static Dictionary BuildPlacements( + TrackPlanTopology topology, + IReadOnlyDictionary specs, + IReadOnlyDictionary anchors) + { + var placements = new Dictionary(topology.Parts.Count); + + foreach (var part in topology.Parts) + { + if (!anchors.TryGetValue(part.Id, out var anchor)) continue; + + var spec = specs[part.Id]; + + placements[part.Id] = new TrackPartPlacement + { + PartId = part.Id, + SpecKey = part.SpecKey, + Anchor = anchor, + Spans = [.. spec.Spans.Select(span => span.ToFrame(anchor))], + }; + } + + return placements; + } + + private static List CollectOpenEnds( + TrackPlanTopology topology, + IReadOnlyDictionary specs, + IReadOnlyDictionary anchors) + { + var openEnds = new List(); + + foreach (var part in topology.Parts) + { + if (!anchors.TryGetValue(part.Id, out var anchor)) continue; + + foreach (var port in specs[part.Id].Ports) + { + if (topology.IsPortUsed(part.Id, port.Name)) continue; + + openEnds.Add(new TrackOpenEnd(part.Id, port.Name, anchor.Compose(port.LocalPose))); + } + } + + return openEnds; + } + + /// One side of a connection as seen from a part: our port, and who is on the other end. + private readonly record struct Joint(string OwnPort, Guid OtherPartId, string OtherPort); + + /// + /// A connection identified without regard to which end you look from, so one physical joint is + /// only ever reported once. + /// + private readonly record struct JointKey(Guid FirstPartId, string FirstPort, Guid SecondPartId, string SecondPort) + { + public static JointKey For(Guid partId, string portName, Guid otherPartId, string otherPortName) + { + var order = partId.CompareTo(otherPartId); + if (order == 0) order = string.CompareOrdinal(portName, otherPortName); + + return order <= 0 + ? new JointKey(partId, portName, otherPartId, otherPortName) + : new JointKey(otherPartId, otherPortName, partId, portName); + } + } +} diff --git a/Source/Trackify.Domain/Tracks/TrackPort.cs b/Source/Trackify.Domain/Tracks/TrackPort.cs new file mode 100644 index 0000000..37c9309 --- /dev/null +++ b/Source/Trackify.Domain/Tracks/TrackPort.cs @@ -0,0 +1,8 @@ +namespace Trackify.Domain.Tracks; + +/// +/// One place a part can be plugged into another, in the part's own local frame. The pose faces +/// outward from the part, so joining two ports means one pose is the other's +/// — see . +/// +public readonly record struct TrackPort(string Name, StudPose LocalPose); diff --git a/Source/Trackify.Domain/Tracks/TrackPortNames.cs b/Source/Trackify.Domain/Tracks/TrackPortNames.cs new file mode 100644 index 0000000..fc02a4b --- /dev/null +++ b/Source/Trackify.Domain/Tracks/TrackPortNames.cs @@ -0,0 +1,36 @@ +namespace Trackify.Domain.Tracks; + +/// +/// The port names used across the part catalogue. They are plain lowercase strings because they +/// travel verbatim through the exchange format ("p1:end"), where a readable name beats an enum +/// ordinal that would silently change meaning if the enum were ever reordered. +/// +public static class TrackPortNames +{ + /// Entry end of a straight, curve or station. + public const string Start = "start"; + + /// Exit end of a straight, curve or station. + public const string End = "end"; + + /// A point's single end — where the two routes meet. + public const string Toe = "toe"; + + /// A point's straight-through end. + public const string Main = "main"; + + /// A point's diverging end. + public const string Branch = "branch"; + + /// Crossing, first route, entry end. + public const string CrossA1 = "a1"; + + /// Crossing, first route, exit end. + public const string CrossA2 = "a2"; + + /// Crossing, second route, entry end. + public const string CrossB1 = "b1"; + + /// Crossing, second route, exit end. + public const string CrossB2 = "b2"; +} diff --git a/Source/Trackify.Domain/Tracks/TrackRoute.cs b/Source/Trackify.Domain/Tracks/TrackRoute.cs new file mode 100644 index 0000000..a709d78 --- /dev/null +++ b/Source/Trackify.Domain/Tracks/TrackRoute.cs @@ -0,0 +1,8 @@ +namespace Trackify.Domain.Tracks; + +/// +/// A way a train can run through a part, from one port to another. Straights and curves have one; +/// a point has two ( and , which +/// is what its stored route setting selects); a crossing has two that never meet. +/// +public readonly record struct TrackRoute(string Name, string FromPort, string ToPort); diff --git a/Source/Trackify.Domain/Tracks/TrackSpan.cs b/Source/Trackify.Domain/Tracks/TrackSpan.cs new file mode 100644 index 0000000..fee12ed --- /dev/null +++ b/Source/Trackify.Domain/Tracks/TrackSpan.cs @@ -0,0 +1,63 @@ +namespace Trackify.Domain.Tracks; + +/// +/// One drawable stretch of rail: a straight when is 0, otherwise an arc of +/// turning heading steps. Most parts are a single +/// span; a point needs two for its diverging route (a straight then a curve), and a crossing needs +/// one per route. +/// +/// A span carries its start pose, so moving it into world coordinates is just composing that one +/// pose with the part's anchor — see . +/// +public readonly record struct TrackSpan(StudPose Start, double LengthStuds, double RadiusStuds, int SweepSteps) +{ + /// A straight span of from . + public static TrackSpan Straight(StudPose start, double lengthStuds) => new(start, lengthStuds, 0, 0); + + /// + /// An arc from of , turning + /// heading steps (positive = right/clockwise on screen). + /// + public static TrackSpan Arc(StudPose start, double radiusStuds, int sweepSteps) + => new(start, radiusStuds * Math.Abs(sweepSteps) * TrackMetrics.RadiansPerHeadingStep, radiusStuds, sweepSteps); + + /// Whether this is a curve rather than a straight. + public bool IsArc => SweepSteps != 0; + + /// +1 for a right-hand curve, -1 for left, 0 for a straight. + public int Sign => Math.Sign(SweepSteps); + + /// X of the arc's centre; meaningless for a straight. + public double CentreX => Start.X + (RadiusStuds * Math.Cos(Start.HeadingRadians + (Sign * Math.PI / 2))); + + /// + public double CentreY => Start.Y + (RadiusStuds * Math.Sin(Start.HeadingRadians + (Sign * Math.PI / 2))); + + /// Angle from the arc's centre to its start point. + public double StartAngle => Math.Atan2(Start.Y - CentreY, Start.X - CentreX); + + /// Angle from the arc's centre to its end point. + public double EndAngle => StartAngle + (SweepSteps * TrackMetrics.RadiansPerHeadingStep); + + /// Where this span leaves off — the next span's or port's pose. + public StudPose End + { + get + { + if (!IsArc) return Start.Advance(LengthStuds); + + var (centreX, centreY, endAngle) = (CentreX, CentreY, EndAngle); + + return new StudPose( + centreX + (RadiusStuds * Math.Cos(endAngle)), + centreY + (RadiusStuds * Math.Sin(endAngle)), + TrackMetrics.NormalizeHeading(Start.Heading + SweepSteps)); + } + } + + /// + /// The same span seen from — used to lift a part's locally authored + /// spans into world coordinates once the part has an anchor. + /// + public TrackSpan ToFrame(in StudPose frame) => this with { Start = frame.Compose(Start) }; +} diff --git a/Test/Trackify.Tests/Domain/StudPoseTests.cs b/Test/Trackify.Tests/Domain/StudPoseTests.cs new file mode 100644 index 0000000..c58a141 --- /dev/null +++ b/Test/Trackify.Tests/Domain/StudPoseTests.cs @@ -0,0 +1,90 @@ +using Trackify.Domain.Tracks; + +namespace Trackify.Tests.Domain; + +public class StudPoseTests +{ + [Theory] + [InlineData(0, 0)] + [InlineData(16, 0)] + [InlineData(-1, 15)] + [InlineData(17, 1)] + [InlineData(-17, 15)] + public void NormalizeHeading_wraps_into_the_sixteen_steps(int heading, int expected) + => Assert.Equal(expected, TrackMetrics.NormalizeHeading(heading)); + + [Fact] + public void Advance_moves_along_the_heading() + { + var moved = new StudPose(0, 0, 0).Advance(16); + + Assert.Equal(16, moved.X, 6); + Assert.Equal(0, moved.Y, 6); + Assert.Equal(0, moved.Heading); + } + + [Fact] + public void Advance_at_a_quarter_turn_moves_down_because_Y_runs_down() + { + // Heading 4 of 16 is 90 degrees, which is +Y on a canvas. + var moved = new StudPose(0, 0, TrackMetrics.HeadingCount / 4).Advance(16); + + Assert.Equal(0, moved.X, 6); + Assert.Equal(16, moved.Y, 6); + } + + [Fact] + public void Compose_with_the_inverse_returns_to_the_origin() + { + var pose = new StudPose(13.5, -7.25, 5); + + var round = pose.Compose(pose.Inverse()); + + Assert.Equal(0, round.X, 9); + Assert.Equal(0, round.Y, 9); + Assert.Equal(0, round.Heading); + } + + [Fact] + public void Compose_is_associative_so_parts_can_be_nested_safely() + { + var a = new StudPose(4, 9, 3); + var b = new StudPose(-2, 5, 7); + var c = new StudPose(11, -3, 2); + + var left = a.Compose(b).Compose(c); + var right = a.Compose(b.Compose(c)); + + Assert.Equal(left.X, right.X, 9); + Assert.Equal(left.Y, right.Y, 9); + Assert.Equal(left.Heading, right.Heading); + } + + [Fact] + public void Reversed_keeps_the_point_and_flips_the_facing() + { + var reversed = new StudPose(5, -3, 2).Reversed(); + + Assert.Equal(5, reversed.X, 9); + Assert.Equal(-3, reversed.Y, 9); + Assert.Equal(10, reversed.Heading); + } + + [Fact] + public void Joins_is_true_for_two_ports_facing_each_other_at_one_point() + { + var port = new StudPose(16, 0, 0); + var facing = new StudPose(16, 0, 8); + + Assert.True(port.Joins(facing)); + Assert.True(facing.Joins(port)); + } + + [Fact] + public void Joins_is_false_when_the_facings_are_not_opposite() + => Assert.False(new StudPose(16, 0, 0).Joins(new StudPose(16, 0, 7))); + + [Fact] + public void Joins_is_false_when_the_points_are_further_apart_than_the_tolerance() + => Assert.False(new StudPose(0, 0, 0).Joins(new StudPose(TrackMetrics.JoinToleranceStuds * 2, 0, 8))); +} diff --git a/Test/Trackify.Tests/Domain/TrackPartCatalogTests.cs b/Test/Trackify.Tests/Domain/TrackPartCatalogTests.cs new file mode 100644 index 0000000..bc2aa40 --- /dev/null +++ b/Test/Trackify.Tests/Domain/TrackPartCatalogTests.cs @@ -0,0 +1,166 @@ +using Trackify.Domain.Tracks; + +namespace Trackify.Tests.Domain; + +/// +/// Pins the catalogue to LEGO's real dimensions. If one of these fails, the planner has started +/// inventing track that cannot be bought. +/// +public class TrackPartCatalogTests +{ + private const double Sin22Point5 = 0.38268343236508977; + private const double Cos22Point5 = 0.92387953251128674; + + [Fact] + public void A_straight_is_sixteen_studs_long() + { + var straight = TrackPartCatalog.Get(TrackPartCatalog.StraightKey); + + var end = straight.PortPose(TrackPortNames.End); + + Assert.Equal(16, end.X, 6); + Assert.Equal(0, end.Y, 6); + Assert.Equal(0, end.Heading); + Assert.Equal(128, straight.TotalLengthStuds * TrackMetrics.MillimetresPerStud, 6); + } + + [Fact] + public void A_curve_sweeps_one_sixteenth_of_a_circle() + { + var curve = TrackPartCatalog.Get(TrackPartCatalog.CurveRightKey); + + var end = curve.PortPose(TrackPortNames.End); + + // R40 at 22.5 degrees: the irrational end position that rules out a coordinate grid. + Assert.Equal(TrackMetrics.CurveRadiusStuds * Sin22Point5, end.X, 6); + Assert.Equal(TrackMetrics.CurveRadiusStuds * (1 - Cos22Point5), end.Y, 6); + Assert.Equal(1, end.Heading); + } + + [Fact] + public void The_left_curve_mirrors_the_right_one() + { + var left = TrackPartCatalog.Get(TrackPartCatalog.CurveLeftKey).PortPose(TrackPortNames.End); + var right = TrackPartCatalog.Get(TrackPartCatalog.CurveRightKey).PortPose(TrackPortNames.End); + + Assert.Equal(right.X, left.X, 6); + Assert.Equal(-right.Y, left.Y, 6); + Assert.Equal(TrackMetrics.NormalizeHeading(-right.Heading), left.Heading); + } + + [Fact] + public void Sixteen_curve_sweeps_make_exactly_one_full_circle() + => Assert.Equal(360, 16 * TrackMetrics.DegreesPerHeadingStep, 9); + + [Fact] + public void Four_curve_sweeps_make_a_right_angle() + => Assert.Equal(90, 4 * TrackMetrics.DegreesPerHeadingStep, 9); + + [Fact] + public void A_point_is_two_straights_long_on_its_main_route() + { + var point = TrackPartCatalog.Get(TrackPartCatalog.SwitchRightKey); + + var main = point.PortPose(TrackPortNames.Main); + + Assert.Equal(2 * TrackMetrics.StraightStuds, main.X, 6); + Assert.Equal(0, main.Y, 6); + Assert.Equal(0, main.Heading); + } + + [Fact] + public void A_point_diverges_by_the_same_angle_as_a_curve() + { + var right = TrackPartCatalog.Get(TrackPartCatalog.SwitchRightKey); + var left = TrackPartCatalog.Get(TrackPartCatalog.SwitchLeftKey); + + Assert.Equal(1, right.PortPose(TrackPortNames.Branch).Heading); + Assert.Equal(TrackMetrics.HeadingCount - 1, left.PortPose(TrackPortNames.Branch).Heading); + } + + [Fact] + public void A_point_has_three_ports_and_two_routes() + { + var point = TrackPartCatalog.Get(TrackPartCatalog.SwitchLeftKey); + + Assert.Equal(3, point.Ports.Count); + Assert.Equal(2, point.Routes.Count); + Assert.True(point.HasPort(TrackPortNames.Toe)); + Assert.True(point.HasPort(TrackPortNames.Main)); + Assert.True(point.HasPort(TrackPortNames.Branch)); + } + + [Fact] + public void A_crossing_is_a_sixteen_stud_square_with_two_routes_that_never_meet() + { + var crossing = TrackPartCatalog.Get(TrackPartCatalog.CrossingKey); + + var acrossEnd = crossing.PortPose(TrackPortNames.CrossA2); + var downStart = crossing.PortPose(TrackPortNames.CrossB1); + var downEnd = crossing.PortPose(TrackPortNames.CrossB2); + + Assert.Equal(4, crossing.Ports.Count); + Assert.Equal(2, crossing.Routes.Count); + + Assert.Equal(TrackMetrics.CrossingStuds, acrossEnd.X, 6); + Assert.Equal(0, acrossEnd.Y, 6); + + // The second route runs at a right angle through the middle of the first. + Assert.Equal(TrackMetrics.CrossingStuds / 2, downStart.X, 6); + Assert.Equal(-TrackMetrics.CrossingStuds / 2, downStart.Y, 6); + Assert.Equal(TrackMetrics.CrossingStuds / 2, downEnd.X, 6); + Assert.Equal(TrackMetrics.CrossingStuds / 2, downEnd.Y, 6); + Assert.Equal(TrackMetrics.HeadingCount / 4, downEnd.Heading); + } + + [Fact] + public void Every_port_faces_outward_so_two_parts_can_be_plugged_together() + { + foreach (var spec in TrackPartCatalog.All) + { + foreach (var port in spec.Ports) + { + // A port's own reverse must join it: that is what "facing each other" means. + Assert.True( + port.LocalPose.Joins(port.LocalPose.Reversed()), + $"Port '{port.Name}' of '{spec.Key}' does not face outward."); + } + } + } + + [Fact] + public void The_catalogue_holds_only_parts_LEGO_actually_makes() + { + var keys = TrackPartCatalog.All.Select(spec => spec.Key).ToHashSet(StringComparer.Ordinal); + + Assert.Equal( + new HashSet(StringComparer.Ordinal) + { + TrackPartCatalog.StraightKey, + TrackPartCatalog.StationKey, + TrackPartCatalog.CurveLeftKey, + TrackPartCatalog.CurveRightKey, + TrackPartCatalog.SwitchLeftKey, + TrackPartCatalog.SwitchRightKey, + TrackPartCatalog.CrossingKey, + }, + keys); + } + + [Fact] + public void Only_straights_and_curves_may_be_laid_by_a_drag() + { + var kinds = TrackPartCatalog.DrawablePieces.Select(spec => spec.Kind).ToList(); + + Assert.Equal(3, kinds.Count); + Assert.DoesNotContain(TrackPartKind.Switch, kinds); + Assert.DoesNotContain(TrackPartKind.Crossing, kinds); + } + + [Fact] + public void An_unknown_part_key_is_rejected_rather_than_guessed() + { + Assert.False(TrackPartCatalog.TryGet("curve-r104-right", out _)); + Assert.Throws(() => TrackPartCatalog.Get("curve-r104-right")); + } +} diff --git a/Test/Trackify.Tests/Domain/TrackPlanWalkerTests.cs b/Test/Trackify.Tests/Domain/TrackPlanWalkerTests.cs new file mode 100644 index 0000000..dee0788 --- /dev/null +++ b/Test/Trackify.Tests/Domain/TrackPlanWalkerTests.cs @@ -0,0 +1,199 @@ +using Trackify.Domain.Tracks; + +namespace Trackify.Tests.Domain; + +/// +/// Proves the walk derives buildable geometry: parts land end to end, real LEGO loops close, and +/// loops that only look closed are reported instead of being rounded into place. +/// +public class TrackPlanWalkerTests +{ + private const string Straight = TrackPartCatalog.StraightKey; + private const string CurveRight = TrackPartCatalog.CurveRightKey; + + [Fact] + public void An_empty_plan_walks_to_an_empty_layout() + { + var layout = TrackPlanWalker.Walk(TrackPlanTopology.Empty); + + Assert.Empty(layout.Placements); + Assert.Empty(layout.OpenEnds); + Assert.True(layout.IsBuildable); + } + + [Fact] + public void Parts_are_placed_end_to_end() + { + var (topology, ids) = Chain(Straight, Straight, Straight); + + var layout = TrackPlanWalker.Walk(topology); + + Assert.Equal(0, layout.Placements[ids[0]].Anchor.X, 6); + Assert.Equal(16, layout.Placements[ids[1]].Anchor.X, 6); + Assert.Equal(32, layout.Placements[ids[2]].Anchor.X, 6); + Assert.True(layout.IsBuildable); + } + + [Fact] + public void A_chain_leaves_exactly_two_open_ends() + { + var (topology, ids) = Chain(Straight, CurveRight, Straight); + + var layout = TrackPlanWalker.Walk(topology); + + Assert.Equal(2, layout.OpenEnds.Count); + Assert.Contains(layout.OpenEnds, end => end.PartId == ids[0] && end.PortName == TrackPortNames.Start); + Assert.Contains(layout.OpenEnds, end => end.PartId == ids[2] && end.PortName == TrackPortNames.End); + } + + [Fact] + public void Sixteen_curves_bring_the_track_back_to_where_it_started() + { + var (topology, ids) = Chain(Enumerable.Repeat(CurveRight, 16).ToArray()); + + var layout = TrackPlanWalker.Walk(topology); + var firstStart = layout.Placements[ids[0]].PortPose(TrackPortNames.Start); + var lastEnd = layout.Placements[ids[^1]].PortPose(TrackPortNames.End); + + // The whole point of a 22.5 degree curve: sixteen of them are a circle. + Assert.True( + lastEnd.Joins(firstStart), + $"Sixteen R40 curves should close; the last end sits at {lastEnd} and the first start at {firstStart}."); + } + + [Fact] + public void A_closed_circle_of_sixteen_curves_is_buildable() + { + var layout = TrackPlanWalker.Walk(Closed(Enumerable.Repeat(CurveRight, 16).ToArray())); + + Assert.True(layout.IsBuildable); + Assert.Empty(layout.OpenEnds); + } + + [Fact] + public void A_stadium_oval_of_curves_and_straights_is_buildable() + { + // Eight curves is a half turn, so two half turns joined by equal straights close exactly. + string[] parts = + [ + ..Enumerable.Repeat(CurveRight, 8), Straight, + ..Enumerable.Repeat(CurveRight, 8), Straight, + ]; + + var layout = TrackPlanWalker.Walk(Closed(parts)); + + Assert.True(layout.IsBuildable); + Assert.Empty(layout.OpenEnds); + Assert.Equal(18, layout.Placements.Count); + } + + [Fact] + public void A_loop_that_does_not_really_close_is_reported_instead_of_forced() + { + // Fifteen curves fall 22.5 degrees short of a circle. The plan claims the loop is shut; the + // geometry says otherwise, and that disagreement is exactly what the user needs to see. + var layout = TrackPlanWalker.Walk(Closed(Enumerable.Repeat(CurveRight, 15).ToArray())); + + Assert.False(layout.IsBuildable); + + // One physical joint, so one problem — not one report per side of it. + var mismatch = Assert.Single(layout.Mismatches); + + Assert.Equal(1, Math.Abs(mismatch.HeadingStepsOff)); + + // And the gap is one curve's chord: 2 * 40 * sin(11.25 degrees), i.e. "one curve short". + Assert.Equal( + 2 * TrackMetrics.CurveRadiusStuds * Math.Sin(TrackMetrics.RadiansPerHeadingStep / 2), + mismatch.GapStuds, + 6); + } + + [Fact] + public void A_point_offers_all_three_of_its_ports_as_open_ends() + { + var pointId = Guid.NewGuid(); + var topology = TrackPlanTopology.Empty.Add(new TrackPlanPart(pointId, TrackPartCatalog.SwitchLeftKey)); + + var layout = TrackPlanWalker.Walk(topology); + + Assert.Equal(3, layout.OpenEnds.Count); + } + + [Fact] + public void A_branch_hanging_off_a_point_is_placed_from_the_branch_port() + { + var pointId = Guid.NewGuid(); + var branchId = Guid.NewGuid(); + var topology = TrackPlanTopology.Empty + .Add(new TrackPlanPart(pointId, TrackPartCatalog.SwitchRightKey)) + .Add(new TrackPlanPart(branchId, Straight)) + .Connect(new TrackPlanLink(pointId, TrackPortNames.Branch, branchId, TrackPortNames.Start)); + + var layout = TrackPlanWalker.Walk(topology); + var branchPort = layout.Placements[pointId].PortPose(TrackPortNames.Branch); + var branchStart = layout.Placements[branchId].PortPose(TrackPortNames.Start); + + Assert.True(layout.IsBuildable); + Assert.True(branchPort.Joins(branchStart)); + // The branch leaves on the diverging heading, not straight on. + Assert.Equal(1, layout.Placements[branchId].Anchor.Heading); + } + + [Fact] + public void Unconnected_groups_are_parked_on_separate_lanes_so_they_stay_visible() + { + var first = Guid.NewGuid(); + var second = Guid.NewGuid(); + var topology = TrackPlanTopology.Empty + .Add(new TrackPlanPart(first, Straight)) + .Add(new TrackPlanPart(second, Straight)); + + var layout = TrackPlanWalker.Walk(topology); + + Assert.Equal(0, layout.Placements[first].Anchor.Y, 6); + Assert.True( + layout.Placements[second].Anchor.Y > 0, + "A disconnected part must not be stacked on top of the first one."); + } + + [Fact] + public void Removing_a_part_also_removes_its_connections() + { + var (topology, ids) = Chain(Straight, Straight, Straight); + + var trimmed = topology.Remove(ids[1]); + + Assert.Equal(2, trimmed.Parts.Count); + Assert.Empty(trimmed.Links); + } + + // Builds a chain of parts, each plugged end-to-start into the previous one. + private static (TrackPlanTopology Topology, List Ids) Chain(params string[] specKeys) + { + var topology = TrackPlanTopology.Empty; + var ids = new List(specKeys.Length); + + foreach (var key in specKeys) + { + var id = Guid.NewGuid(); + topology = topology.Add(new TrackPlanPart(id, key)); + + if (ids.Count > 0) + { + topology = topology.Connect(new TrackPlanLink(ids[^1], TrackPortNames.End, id, TrackPortNames.Start)); + } + + ids.Add(id); + } + + return (topology, ids); + } + + // The same chain with its two loose ends plugged into each other. + private static TrackPlanTopology Closed(params string[] specKeys) + { + var (topology, ids) = Chain(specKeys); + + return topology.Connect(new TrackPlanLink(ids[^1], TrackPortNames.End, ids[0], TrackPortNames.Start)); + } +} From 65f6a706d680e3911c485ccb45730543857c7ad8 Mon Sep 17 00:00:00 2001 From: kevin Date: Wed, 14 Oct 2026 21:19:36 +0200 Subject: [PATCH 03/15] Snap a drawn stroke onto real track, and close loops by search Two halves of "draw it instead of configuring it", both pure geometry. TrackSnapper follows a stroke: from an open end it repeatedly picks the straight or R40 curve whose far end lands nearest where the stroke will be a part-length further on, with a small penalty for disagreeing with the stroke's own direction so it does not zig-zag between a left and a right curve where a straight tracks better. Greedy on purpose -- it runs on every pointer move, and a preview that follows the finger now beats one that is a millimetre closer later. The gesture may only lay straights and curves; points and crossings are placed deliberately, never guessed. TrackLoopCloser is a search, not a fit, because closing a loop has to land on the target pose exactly and greedy following never will. Iterative deepening gives the fewest parts that work, pruned by two admissible bounds: no part advances further than a straight, and none turns by more than one 22.5 degree step. The property that makes the whole approach honest is tested directly: a free-form stroke -- straight, quarter circle either way, or a wiggle -- always yields a plan the walker finds buildable, because nothing free-form is ever stored, only whole catalogue parts plugged port to port. 92 tests green, including: a quarter-circle stroke becomes exactly four R40 curves, a circle one curve short is closed with exactly one curve, two ends already nose to nose get a connection and no new track, and a gap beyond the budget is reported as no answer rather than a bad fit. Co-Authored-By: Claude Opus 5 --- Source/Trackify.Domain/Tracks/StudPoint.cs | 17 ++ .../Tracks/TrackJoinMismatch.cs | 15 +- .../Trackify.Domain/Tracks/TrackLoopCloser.cs | 118 +++++++++++ Source/Trackify.Domain/Tracks/TrackMetrics.cs | 12 ++ .../Trackify.Domain/Tracks/TrackSnapResult.cs | 42 ++++ Source/Trackify.Domain/Tracks/TrackSnapper.cs | 183 ++++++++++++++++++ .../Domain/TrackLoopCloserTests.cs | 118 +++++++++++ .../Domain/TrackSnapperTests.cs | 168 ++++++++++++++++ 8 files changed, 661 insertions(+), 12 deletions(-) create mode 100644 Source/Trackify.Domain/Tracks/StudPoint.cs create mode 100644 Source/Trackify.Domain/Tracks/TrackLoopCloser.cs create mode 100644 Source/Trackify.Domain/Tracks/TrackSnapResult.cs create mode 100644 Source/Trackify.Domain/Tracks/TrackSnapper.cs create mode 100644 Test/Trackify.Tests/Domain/TrackLoopCloserTests.cs create mode 100644 Test/Trackify.Tests/Domain/TrackSnapperTests.cs diff --git a/Source/Trackify.Domain/Tracks/StudPoint.cs b/Source/Trackify.Domain/Tracks/StudPoint.cs new file mode 100644 index 0000000..2c1d8c7 --- /dev/null +++ b/Source/Trackify.Domain/Tracks/StudPoint.cs @@ -0,0 +1,17 @@ +namespace Trackify.Domain.Tracks; + +/// A plain point on the plan, in studs — what a drawing gesture is made of. +public readonly record struct StudPoint(double X, double Y) +{ + /// Distance to . + public double DistanceTo(in StudPoint other) + { + var dx = X - other.X; + var dy = Y - other.Y; + + return Math.Sqrt((dx * dx) + (dy * dy)); + } + + /// This point as a pose facing . + public StudPose WithHeading(int heading) => new(X, Y, heading); +} diff --git a/Source/Trackify.Domain/Tracks/TrackJoinMismatch.cs b/Source/Trackify.Domain/Tracks/TrackJoinMismatch.cs index 31ea447..adc7eff 100644 --- a/Source/Trackify.Domain/Tracks/TrackJoinMismatch.cs +++ b/Source/Trackify.Domain/Tracks/TrackJoinMismatch.cs @@ -21,17 +21,8 @@ public sealed record TrackJoinMismatch public double GapStuds => Expected.DistanceTo(Actual); /// - /// How many 22.5 degree steps the two facings differ by, as the shortest signed turn (-8..7). - /// Signed and shortest because the useful message is "your loop is one curve short", which a - /// plain 0..15 difference would report as 15. + /// How many 22.5 degree steps the two facings differ by, as the shortest signed turn (-8..7) — + /// so a loop one curve short reports -1 rather than 15. /// - public int HeadingStepsOff - { - get - { - var difference = TrackMetrics.NormalizeHeading(Expected.Heading - Actual.Heading); - - return difference > TrackMetrics.HeadingCount / 2 ? difference - TrackMetrics.HeadingCount : difference; - } - } + public int HeadingStepsOff => TrackMetrics.ShortestHeadingStep(Expected.Heading, Actual.Heading); } diff --git a/Source/Trackify.Domain/Tracks/TrackLoopCloser.cs b/Source/Trackify.Domain/Tracks/TrackLoopCloser.cs new file mode 100644 index 0000000..77f7c37 --- /dev/null +++ b/Source/Trackify.Domain/Tracks/TrackLoopCloser.cs @@ -0,0 +1,118 @@ +namespace Trackify.Domain.Tracks; + +/// +/// Finds the shortest run of parts that joins two open ends exactly — the "Kreis schließen" step. +/// +/// This exists because cannot do it. Following a stroke greedily gets +/// near another open end but essentially never lands on it, and "near" is not a thing you can plug +/// together. Closing a loop is a search, not a fit: it has to end on the target pose exactly, which +/// is only decidable because headings are integers. +/// +/// Iterative deepening, so the answer is always the fewest parts that work, with two admissible +/// bounds doing the pruning: no part advances more than a straight's length, and no part turns by +/// more than one 22.5 degree step. Those cut the 3^n tree down to something that returns instantly +/// at the depths a person would actually accept. +/// +public static class TrackLoopCloser +{ + /// + /// How many parts the closer may add. Ten is well past what a user would accept as "close the + /// loop for me" and still searches in well under a millisecond. + /// + public const int DefaultMaxParts = 10; + + /// + /// The shortest run of parts joining to , or + /// when no run of at most does. + /// + public static TrackSnapResult Close(TrackOpenEnd from, TrackOpenEnd to, int maxParts = DefaultMaxParts) + { + if (from.PartId == to.PartId && string.Equals(from.PortName, to.PortName, StringComparison.Ordinal)) + { + return TrackSnapResult.None; + } + + // Already nose to nose: the loop just needs the connection, not more track. + if (from.WorldPose.Joins(to.WorldPose)) + { + return new TrackSnapResult + { + Parts = [], + Links = [new TrackPlanLink(from.PartId, from.PortName, to.PartId, to.PortName)], + EndPose = to.WorldPose, + ClosesLoop = true, + }; + } + + for (var depth = 1; depth <= maxParts; depth++) + { + var chosen = new List(depth); + + if (Search(from.WorldPose, to.WorldPose, depth, chosen)) return Build(from, to, chosen); + } + + return TrackSnapResult.None; + } + + // Depth-limited search for a run of exactly `remaining` parts ending on the target. + private static bool Search(in StudPose frontier, in StudPose target, int remaining, List chosen) + { + if (remaining == 0) return frontier.Joins(target); + if (!CanStillReach(frontier, target, remaining)) return false; + + foreach (var spec in TrackPartCatalog.DrawablePieces) + { + chosen.Add(spec); + + if (Search(TrackSnapper.EndPoseOf(spec, frontier), target, remaining - 1, chosen)) return true; + + chosen.RemoveAt(chosen.Count - 1); + } + + return false; + } + + // Both bounds are admissible, so pruning never discards a run that would have worked: + // no part moves further than a straight, and none turns by more than one step. + private static bool CanStillReach(in StudPose frontier, in StudPose target, int remaining) + { + var reach = (remaining * TrackMetrics.StraightStuds) + TrackMetrics.JoinToleranceStuds; + if (frontier.DistanceTo(target) > reach) return false; + + // The run has to finish facing the target head on. + var facing = TrackMetrics.NormalizeHeading(target.Heading + (TrackMetrics.HeadingCount / 2)); + + return Math.Abs(TrackMetrics.ShortestHeadingStep(facing, frontier.Heading)) <= remaining; + } + + private static TrackSnapResult Build(TrackOpenEnd from, TrackOpenEnd to, List chosen) + { + var parts = new List(chosen.Count); + var links = new List(chosen.Count + 1); + var frontier = from.WorldPose; + var frontierPart = from.PartId; + var frontierPort = from.PortName; + + foreach (var spec in chosen) + { + var part = new TrackPlanPart(Guid.NewGuid(), spec.Key); + + parts.Add(part); + links.Add(new TrackPlanLink(frontierPart, frontierPort, part.Id, TrackPortNames.Start)); + + frontier = TrackSnapper.EndPoseOf(spec, frontier); + frontierPart = part.Id; + frontierPort = TrackPortNames.End; + } + + links.Add(new TrackPlanLink(frontierPart, frontierPort, to.PartId, to.PortName)); + + return new TrackSnapResult + { + Parts = parts, + Links = links, + EndPose = frontier, + ClosesLoop = true, + }; + } +} diff --git a/Source/Trackify.Domain/Tracks/TrackMetrics.cs b/Source/Trackify.Domain/Tracks/TrackMetrics.cs index e83a71a..922a982 100644 --- a/Source/Trackify.Domain/Tracks/TrackMetrics.cs +++ b/Source/Trackify.Domain/Tracks/TrackMetrics.cs @@ -43,4 +43,16 @@ public static class TrackMetrics /// Normalises a heading step into 0..15, so -1 reads as 15 and 16 as 0. public static int NormalizeHeading(int heading) => ((heading % HeadingCount) + HeadingCount) % HeadingCount; + + /// + /// The shortest signed turn from to , in -8..7. + /// Signed and shortest because the useful statement is "one step short", which a plain 0..15 + /// difference would report as 15. + /// + public static int ShortestHeadingStep(int from, int to) + { + var difference = NormalizeHeading(from - to); + + return difference > HeadingCount / 2 ? difference - HeadingCount : difference; + } } diff --git a/Source/Trackify.Domain/Tracks/TrackSnapResult.cs b/Source/Trackify.Domain/Tracks/TrackSnapResult.cs new file mode 100644 index 0000000..3e163bf --- /dev/null +++ b/Source/Trackify.Domain/Tracks/TrackSnapResult.cs @@ -0,0 +1,42 @@ +namespace Trackify.Domain.Tracks; + +/// +/// A run of parts the planner proposes: what to lay and how it plugs together. The editor shows this +/// as the ghost preview under the user's finger and only commits it when they let go. +/// +public sealed record TrackSnapResult +{ + /// Nothing to lay. + public static TrackSnapResult None { get; } = new() + { + Parts = [], + Links = [], + EndPose = StudPose.Origin, + }; + + /// The parts to add, in the order they are laid. + public required IReadOnlyList Parts { get; init; } + + /// The connections to make — the first one plugs into the end the drag started from. + public required IReadOnlyList Links { get; init; } + + /// Where the run leaves off; the plan's new open end unless this run closed a loop. + public required StudPose EndPose { get; init; } + + /// Whether this run's far end was plugged into an already-existing open end. + public bool ClosesLoop { get; init; } + + /// Whether there is anything to lay at all. + public bool IsEmpty => Parts.Count == 0; + + /// Applies this run to , returning the grown plan. + public TrackPlanTopology ApplyTo(TrackPlanTopology topology) + { + var grown = topology; + + foreach (var part in Parts) grown = grown.Add(part); + foreach (var link in Links) grown = grown.Connect(link); + + return grown; + } +} diff --git a/Source/Trackify.Domain/Tracks/TrackSnapper.cs b/Source/Trackify.Domain/Tracks/TrackSnapper.cs new file mode 100644 index 0000000..c549243 --- /dev/null +++ b/Source/Trackify.Domain/Tracks/TrackSnapper.cs @@ -0,0 +1,183 @@ +namespace Trackify.Domain.Tracks; + +/// +/// Turns a drawn stroke into real track: walking out from an open end, it repeatedly picks whichever +/// straight or R40 curve follows the stroke best, until the stroke runs out. +/// +/// This is the piece that makes "just draw it" safe. The user's finger moves freely, but nothing +/// free-form is ever stored — only whole catalogue parts plugged port to port, so whatever appears +/// on screen can be built out of the box of track on the floor. Fitting greedily (rather than +/// searching for a globally best fit) is deliberate: it runs on every pointer move, and a preview +/// that follows the finger immediately is worth more than one that is a few millimetres closer. +/// +/// Closing a loop is a different problem and belongs to : greedy +/// following will come close to another open end but essentially never land on it exactly. +/// +public static class TrackSnapper +{ + /// How many parts one gesture may lay, so a wild drag cannot fill the plan. + public const int DefaultMaxParts = 60; + + /// + /// Studs of error per 22.5 degree step of heading disagreement with the stroke. Keeps the fit + /// from zig-zagging between a left and a right curve when a straight tracks the stroke better. + /// + private const double HeadingPenaltyStudsPerStep = 2.5; + + /// + /// Lays parts from along . Returns + /// when the stroke is too short to justify a single part. + /// + public static TrackSnapResult Snap( + TrackOpenEnd from, + IReadOnlyList stroke, + int maxParts = DefaultMaxParts) + { + var path = DrawnPath.From(stroke); + if (path.TotalLength <= 0) return TrackSnapResult.None; + + var parts = new List(); + var links = new List(); + var frontier = from.WorldPose; + var frontierPart = from.PartId; + var frontierPort = from.PortName; + var travelled = 0.0; + + while (parts.Count < maxParts) + { + var best = BestPiece(frontier, path, travelled); + if (best is not { } piece) break; + + var part = new TrackPlanPart(Guid.NewGuid(), piece.Spec.Key); + + parts.Add(part); + links.Add(new TrackPlanLink(frontierPart, frontierPort, part.Id, TrackPortNames.Start)); + + frontier = piece.End; + frontierPart = part.Id; + frontierPort = TrackPortNames.End; + travelled += piece.Spec.TotalLengthStuds; + } + + if (parts.Count == 0) return TrackSnapResult.None; + + return new TrackSnapResult + { + Parts = parts, + Links = links, + EndPose = frontier, + }; + } + + /// + /// Where a part's local frame has to sit for its to plug into + /// — the one placement rule the snapper and the loop closer share. + /// + public static StudPose AnchorFor(TrackPartSpec spec, string portName, in StudPose openEnd) + => openEnd.Reversed().Compose(spec.PortPose(portName).Inverse()); + + /// The pose a part leaves behind when plugged into by its start. + internal static StudPose EndPoseOf(TrackPartSpec spec, in StudPose openEnd) + => AnchorFor(spec, TrackPortNames.Start, openEnd).Compose(spec.PortPose(TrackPortNames.End)); + + // Picks the catalogue piece whose far end lands closest to where the stroke will be once this + // piece's worth of track has been laid. Returns null when too little stroke is left to bother. + private static Candidate? BestPiece(in StudPose frontier, in DrawnPath path, double travelled) + { + Candidate? best = null; + var bestScore = double.MaxValue; + + foreach (var spec in TrackPartCatalog.DrawablePieces) + { + var length = spec.TotalLengthStuds; + + // Stop once the stroke has less than half a part left to follow, so a gesture does not + // overshoot its own end by a whole piece. + if (travelled + (length / 2) > path.TotalLength) continue; + + var end = EndPoseOf(spec, frontier); + var target = path.PointAt(travelled + length); + var headingOff = Math.Abs( + TrackMetrics.ShortestHeadingStep(end.Heading, path.HeadingAt(travelled + length))); + var strayX = end.X - target.X; + var strayY = end.Y - target.Y; + var score = Math.Sqrt((strayX * strayX) + (strayY * strayY)) + + (headingOff * HeadingPenaltyStudsPerStep); + + if (score >= bestScore) continue; + + bestScore = score; + best = new Candidate(spec, end); + } + + return best; + } + + /// One piece under consideration: which part, and where it would leave off. + private readonly record struct Candidate(TrackPartSpec Spec, StudPose End); + + /// + /// The user's stroke with cumulative lengths, so it can be sampled by distance along itself + /// rather than by point index — a finger produces points at wildly uneven spacing. + /// + private readonly record struct DrawnPath(IReadOnlyList Points, double[] Cumulative) + { + public double TotalLength => Cumulative.Length == 0 ? 0 : Cumulative[^1]; + + public static DrawnPath From(IReadOnlyList points) + { + if (points.Count == 0) return new DrawnPath(points, []); + + var cumulative = new double[points.Count]; + for (var i = 1; i < points.Count; i++) + { + cumulative[i] = cumulative[i - 1] + points[i].DistanceTo(points[i - 1]); + } + + return new DrawnPath(points, cumulative); + } + + /// The point studs along the stroke, clamped to its ends. + public StudPoint PointAt(double distance) + { + if (Points.Count == 0) return default; + if (distance <= 0) return Points[0]; + if (distance >= TotalLength) return Points[^1]; + + var index = Segment(distance); + var spanLength = Cumulative[index + 1] - Cumulative[index]; + var t = spanLength <= 0 ? 0 : (distance - Cumulative[index]) / spanLength; + + return new StudPoint( + Points[index].X + ((Points[index + 1].X - Points[index].X) * t), + Points[index].Y + ((Points[index + 1].Y - Points[index].Y) * t)); + } + + /// + /// The stroke's own direction at , rounded to the nearest of the + /// 16 headings track can actually point in. + /// + public int HeadingAt(double distance) + { + if (Points.Count < 2) return 0; + + var index = Segment(Math.Clamp(distance, 0, TotalLength)); + var dx = Points[index + 1].X - Points[index].X; + var dy = Points[index + 1].Y - Points[index].Y; + var steps = Math.Atan2(dy, dx) / TrackMetrics.RadiansPerHeadingStep; + + return TrackMetrics.NormalizeHeading((int)Math.Round(steps)); + } + + // Index of the stroke span containing `distance`. + private int Segment(double distance) + { + for (var i = 0; i < Cumulative.Length - 1; i++) + { + if (distance <= Cumulative[i + 1]) return i; + } + + return Math.Max(0, Cumulative.Length - 2); + } + } +} diff --git a/Test/Trackify.Tests/Domain/TrackLoopCloserTests.cs b/Test/Trackify.Tests/Domain/TrackLoopCloserTests.cs new file mode 100644 index 0000000..28537c9 --- /dev/null +++ b/Test/Trackify.Tests/Domain/TrackLoopCloserTests.cs @@ -0,0 +1,118 @@ +using Trackify.Domain.Tracks; + +namespace Trackify.Tests.Domain; + +/// +/// "Kreis schließen": the planner works out the parts still missing, and only ever proposes a run +/// that really closes — which is why this is a search and not a fit. +/// +public class TrackLoopCloserTests +{ + private const string CurveRight = TrackPartCatalog.CurveRightKey; + + [Fact] + public void A_circle_one_curve_short_is_closed_with_one_curve() + { + var (topology, from, to) = OpenCircle(15); + + var result = TrackLoopCloser.Close(from, to); + + var part = Assert.Single(result.Parts); + Assert.Equal(CurveRight, part.SpecKey); + Assert.True(result.ClosesLoop); + } + + [Fact] + public void A_circle_two_curves_short_is_closed_with_two_curves() + { + var (_, from, to) = OpenCircle(14); + + var result = TrackLoopCloser.Close(from, to); + + Assert.Equal(2, result.Parts.Count); + Assert.All(result.Parts, part => Assert.Equal(CurveRight, part.SpecKey)); + } + + [Fact] + public void Closing_a_loop_leaves_a_buildable_plan_with_no_open_ends() + { + var (topology, from, to) = OpenCircle(15); + + var layout = TrackPlanWalker.Walk(TrackLoopCloser.Close(from, to).ApplyTo(topology)); + + Assert.True(layout.IsBuildable); + Assert.Empty(layout.OpenEnds); + Assert.Equal(16, layout.Placements.Count); + } + + [Fact] + public void Two_ends_already_facing_each_other_only_need_the_connection() + { + // Sixteen curves already form a circle, so the two loose ends are nose to nose. + var (topology, from, to) = OpenCircle(16); + + var result = TrackLoopCloser.Close(from, to); + + Assert.Empty(result.Parts); + Assert.Single(result.Links); + Assert.True(result.ClosesLoop); + Assert.True(TrackPlanWalker.Walk(result.ApplyTo(topology)).IsBuildable); + } + + [Fact] + public void An_end_cannot_be_closed_onto_itself() + { + var (_, from, _) = OpenCircle(15); + + Assert.True(TrackLoopCloser.Close(from, from).IsEmpty); + } + + [Fact] + public void A_gap_too_big_for_the_budget_is_reported_as_no_answer_rather_than_a_bad_fit() + { + var (_, from, to) = OpenCircle(15); + + // One curve would do it, but zero parts are allowed and the ends are not already touching. + Assert.True(TrackLoopCloser.Close(from, to, maxParts: 0).IsEmpty); + } + + [Fact] + public void The_two_ends_of_a_single_straight_cannot_be_looped_within_a_small_budget() + { + var id = Guid.NewGuid(); + var topology = TrackPlanTopology.Empty.Add(new TrackPlanPart(id, TrackPartCatalog.StraightKey)); + var ends = TrackPlanWalker.Walk(topology).OpenEnds; + var start = ends.Single(end => end.PortName == TrackPortNames.Start); + var finish = ends.Single(end => end.PortName == TrackPortNames.End); + + Assert.True(TrackLoopCloser.Close(start, finish, maxParts: 3).IsEmpty); + } + + // A chain of `count` right curves, returning its two loose ends: the last part's end, and the + // first part's start, which is what a closed loop has to join. + private static (TrackPlanTopology Topology, TrackOpenEnd From, TrackOpenEnd To) OpenCircle(int count) + { + var topology = TrackPlanTopology.Empty; + var ids = new List(count); + + for (var i = 0; i < count; i++) + { + var id = Guid.NewGuid(); + topology = topology.Add(new TrackPlanPart(id, CurveRight)); + + if (ids.Count > 0) + { + topology = topology.Connect(new TrackPlanLink(ids[^1], TrackPortNames.End, id, TrackPortNames.Start)); + } + + ids.Add(id); + } + + var openEnds = TrackPlanWalker.Walk(topology).OpenEnds; + + return ( + topology, + openEnds.Single(end => end.PartId == ids[^1] && end.PortName == TrackPortNames.End), + openEnds.Single(end => end.PartId == ids[0] && end.PortName == TrackPortNames.Start)); + } +} diff --git a/Test/Trackify.Tests/Domain/TrackSnapperTests.cs b/Test/Trackify.Tests/Domain/TrackSnapperTests.cs new file mode 100644 index 0000000..accf706 --- /dev/null +++ b/Test/Trackify.Tests/Domain/TrackSnapperTests.cs @@ -0,0 +1,168 @@ +using Trackify.Domain.Tracks; + +namespace Trackify.Tests.Domain; + +/// +/// The drawing path: a stroke in, real LEGO parts out. The property that matters most is the last +/// test — whatever a gesture produces has to be buildable, or "just draw it" is a lie. +/// +public class TrackSnapperTests +{ + [Fact] + public void A_stroke_straight_ahead_becomes_straights() + { + var (topology, start) = Seed(); + + var result = TrackSnapper.Snap(start, StraightStroke(48)); + + Assert.Equal(3, result.Parts.Count); + Assert.All(result.Parts, part => Assert.Equal(TrackPartCatalog.StraightKey, part.SpecKey)); + Assert.Equal(0, result.EndPose.Heading); + Assert.Equal(64, result.EndPose.X, 3); + Assert.True(result.ApplyTo(topology) is { } grown && TrackPlanWalker.Walk(grown).IsBuildable); + } + + [Fact] + public void A_stroke_shorter_than_half_a_part_lays_nothing() + { + var (_, start) = Seed(); + + var result = TrackSnapper.Snap(start, StraightStroke(4)); + + Assert.True(result.IsEmpty); + } + + [Fact] + public void An_empty_stroke_lays_nothing() + { + var (_, start) = Seed(); + + Assert.True(TrackSnapper.Snap(start, []).IsEmpty); + } + + [Fact] + public void A_stroke_curving_right_becomes_right_curves() + { + var (topology, start) = Seed(); + + // A quarter circle of LEGO's own radius, which is exactly four curves. + var result = TrackSnapper.Snap(start, QuarterCircleStroke(turnRight: true)); + + Assert.Equal(4, result.Parts.Count); + Assert.All(result.Parts, part => Assert.Equal(TrackPartCatalog.CurveRightKey, part.SpecKey)); + Assert.Equal(TrackMetrics.HeadingCount / 4, result.EndPose.Heading); + Assert.True(TrackPlanWalker.Walk(result.ApplyTo(topology)).IsBuildable); + } + + [Fact] + public void A_stroke_curving_left_becomes_left_curves() + { + var (_, start) = Seed(); + + var result = TrackSnapper.Snap(start, QuarterCircleStroke(turnRight: false)); + + Assert.All(result.Parts, part => Assert.Equal(TrackPartCatalog.CurveLeftKey, part.SpecKey)); + } + + [Fact] + public void A_gesture_never_lays_more_than_its_budget() + { + var (_, start) = Seed(); + + var result = TrackSnapper.Snap(start, StraightStroke(4000), maxParts: 5); + + Assert.Equal(5, result.Parts.Count); + } + + [Fact] + public void A_gesture_never_lays_a_point_or_a_crossing() + { + var (_, start) = Seed(); + + var result = TrackSnapper.Snap(start, QuarterCircleStroke(turnRight: true)); + + Assert.All(result.Parts, part => + { + Assert.NotEqual(TrackPartKind.Switch, part.Spec.Kind); + Assert.NotEqual(TrackPartKind.Crossing, part.Spec.Kind); + }); + } + + [Fact] + public void Whatever_a_stroke_produces_can_actually_be_built() + { + // The core promise of drawing instead of configuring: a free gesture, but only real parts + // plugged port to port, so the walk can never find a joint that does not work. + IReadOnlyList[] strokes = + [ + StraightStroke(100), + QuarterCircleStroke(turnRight: true), + QuarterCircleStroke(turnRight: false), + WigglyStroke(), + ]; + + foreach (var stroke in strokes) + { + var (topology, start) = Seed(); + + var layout = TrackPlanWalker.Walk(TrackSnapper.Snap(start, stroke).ApplyTo(topology)); + + Assert.True(layout.IsBuildable, "A snapped stroke produced track that cannot be plugged together."); + } + } + + // A single straight, with its far end as the open end a gesture starts from (pose 16,0 heading 0). + private static (TrackPlanTopology Topology, TrackOpenEnd Start) Seed() + { + var id = Guid.NewGuid(); + var topology = TrackPlanTopology.Empty.Add(new TrackPlanPart(id, TrackPartCatalog.StraightKey)); + var open = TrackPlanWalker.Walk(topology).OpenEnds.Single(end => end.PortName == TrackPortNames.End); + + return (topology, open); + } + + private static List StraightStroke(double lengthStuds) + { + var points = new List(); + for (var travelled = 0.0; travelled <= lengthStuds; travelled += 2) + { + points.Add(new StudPoint(TrackMetrics.StraightStuds + travelled, 0)); + } + + return points; + } + + // A quarter of an R40 circle leaving the seed's open end, sampled finely. + private static List QuarterCircleStroke(bool turnRight) + { + var sign = turnRight ? 1 : -1; + var centreY = sign * TrackMetrics.CurveRadiusStuds; + var points = new List(); + + for (var step = 0; step <= 32; step++) + { + var angle = sign * (Math.PI / 2) * step / 32; + + points.Add(new StudPoint( + TrackMetrics.StraightStuds + (TrackMetrics.CurveRadiusStuds * Math.Sin(Math.Abs(angle))), + centreY - (centreY * Math.Cos(angle)))); + } + + return points; + } + + // Right then left then right, the sort of thing a child's finger actually draws. + private static List WigglyStroke() + { + var points = new List(); + + for (var step = 0; step <= 120; step++) + { + var along = step * 2.0; + + points.Add(new StudPoint(TrackMetrics.StraightStuds + along, 26 * Math.Sin(along / 38))); + } + + return points; + } +} From e275617f3ccd4bb6183f6ec90300c2f1b7b60d7a Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 15 Oct 2026 13:55:06 +0200 Subject: [PATCH 04/15] Carry a track plan between the app and the CLI as a versioned file "I generate it in the UI and import it into the CLI, and the rest no longer interests me" -- so export/import is the feature, not a trailer. The format (trackplan.v1.json) stores the GRAPH, not coordinates: parts by catalogue key plus connections written as "p1:end" -> "p2:start". A stored coordinate could describe a layout that cannot be plugged together; a connection cannot. Document-local ids keep the file readable by a person, which is the point of having one. Drive behaviour sits in its own block per part, separate from topology, because they are different kinds of fact -- and because the camera import that comes later can only ever supply topology. The same reasoning puts an optional measured "pose" on each part: a reconstruction from a photograph hands over poses, and ports landing on each other within tolerance can be paired into connections from there. Nothing reads it yet; it is the hook that keeps that path open without changing the format. Points now carry what they need to be driven: which hub and port throws them, where the sensor watching the approach is plugged in, and entry rules saying "a train arriving here, bound for there, needs the point set this way". Destination-specific rules beat catch-all ones regardless of list order. Acting on this is later work; the data exists now. Persistence moves to EF Core migrations (the arc42 section 11 debt), with dotnet-ef pinned as a local tool so CI and every machine generate the same thing. A plan is read and written whole, in one transaction -- half a graph would render as track that falls apart. Generated migrations are marked as generated code in .editorconfig, since dotnet ef emits block-scoped namespaces that IDE0161 rejects as an error here. The DTO boundary grows to match: the architecture test now also forbids the CLI from touching Trackify.Domain.Tracks, which caught a real slip -- `track show` was reading TrackMetrics directly to print metres. The summary converts it instead. trackify track import [--replace] [--dry-run] | export | show Verified by 109 tests and a real CLI run: importing a straight, two R40 curves and a wired left point reports 4 parts, 3 connections, 3 open ends, and `track show` lists the parts needed, 79 studs of rail and confirms every joint lines up. Co-Authored-By: Claude Opus 5 --- .config/dotnet-tools.json | 13 + .editorconfig | 11 + Directory.Packages.props | 3 + .../DependencyInjection.cs | 2 + Source/Trackify.Application/GlobalUsings.cs | 1 + .../TrackPlans/ITrackPlanExchangeService.cs | 23 ++ .../TrackPlans/ITrackPlanRepository.cs | 18 + .../TrackPlans/TrackPlanBehaviourDocument.cs | 29 ++ .../TrackPlans/TrackPlanConnectionDocument.cs | 14 + .../TrackPlans/TrackPlanDocument.cs | 35 ++ .../TrackPlans/TrackPlanEntryRuleDocument.cs | 14 + .../TrackPlans/TrackPlanExchangeService.cs | 366 ++++++++++++++++++ .../TrackPlans/TrackPlanImportResult.cs | 33 ++ .../TrackPlans/TrackPlanPartDocument.cs | 27 ++ .../TrackPlans/TrackPlanPoseDocument.cs | 20 + .../TrackPlans/TrackPlanSerializer.cs | 58 +++ .../TrackPlans/TrackPlanSummary.cs | 44 +++ .../TrackPlans/TrackPlanSwitchDocument.cs | 29 ++ .../Commands/Settings/TrackExportSettings.cs | 15 + .../Commands/Settings/TrackImportSettings.cs | 19 + .../Commands/TrackExportCommand.cs | 25 ++ .../Commands/TrackImportCommand.cs | 52 +++ .../Trackify.Cli/Commands/TrackShowCommand.cs | 52 +++ Source/Trackify.Cli/GlobalUsings.cs | 1 + Source/Trackify.Cli/Program.cs | 15 + Source/Trackify.Domain/Tracks/SwitchConfig.cs | 67 ++++ .../Trackify.Domain/Tracks/SwitchEntryRule.cs | 22 ++ Source/Trackify.Domain/Tracks/TrackLink.cs | 23 ++ Source/Trackify.Domain/Tracks/TrackPart.cs | 52 +++ .../Tracks/TrackPlanSnapshot.cs | 24 ++ .../DependencyInjection.cs | 1 + .../Trackify.Infrastructure/GlobalUsings.cs | 2 + .../Persistence/BaseRepository.cs | 5 +- .../20261014193211_InitialCreate.Designer.cs | 329 ++++++++++++++++ .../20261014193211_InitialCreate.cs | 142 +++++++ .../TrackifyDbContextModelSnapshot.cs | 326 ++++++++++++++++ .../Persistence/SqliteTrackPlanRepository.cs | 80 ++++ .../Persistence/TrackifyDbContext.cs | 32 +- .../TrackifyDesignTimeDbContextFactory.cs | 20 + .../Trackify.Infrastructure.csproj | 2 + .../TrackPlanExchangeServiceTests.cs | 325 ++++++++++++++++ .../Architecture/LayerTrainDependencyTests.cs | 7 +- .../Fakes/FakeTrackPlanRepository.cs | 43 ++ 43 files changed, 2416 insertions(+), 5 deletions(-) create mode 100644 .config/dotnet-tools.json create mode 100644 Source/Trackify.Application/TrackPlans/ITrackPlanExchangeService.cs create mode 100644 Source/Trackify.Application/TrackPlans/ITrackPlanRepository.cs create mode 100644 Source/Trackify.Application/TrackPlans/TrackPlanBehaviourDocument.cs create mode 100644 Source/Trackify.Application/TrackPlans/TrackPlanConnectionDocument.cs create mode 100644 Source/Trackify.Application/TrackPlans/TrackPlanDocument.cs create mode 100644 Source/Trackify.Application/TrackPlans/TrackPlanEntryRuleDocument.cs create mode 100644 Source/Trackify.Application/TrackPlans/TrackPlanExchangeService.cs create mode 100644 Source/Trackify.Application/TrackPlans/TrackPlanImportResult.cs create mode 100644 Source/Trackify.Application/TrackPlans/TrackPlanPartDocument.cs create mode 100644 Source/Trackify.Application/TrackPlans/TrackPlanPoseDocument.cs create mode 100644 Source/Trackify.Application/TrackPlans/TrackPlanSerializer.cs create mode 100644 Source/Trackify.Application/TrackPlans/TrackPlanSummary.cs create mode 100644 Source/Trackify.Application/TrackPlans/TrackPlanSwitchDocument.cs create mode 100644 Source/Trackify.Cli/Commands/Settings/TrackExportSettings.cs create mode 100644 Source/Trackify.Cli/Commands/Settings/TrackImportSettings.cs create mode 100644 Source/Trackify.Cli/Commands/TrackExportCommand.cs create mode 100644 Source/Trackify.Cli/Commands/TrackImportCommand.cs create mode 100644 Source/Trackify.Cli/Commands/TrackShowCommand.cs create mode 100644 Source/Trackify.Domain/Tracks/SwitchConfig.cs create mode 100644 Source/Trackify.Domain/Tracks/SwitchEntryRule.cs create mode 100644 Source/Trackify.Domain/Tracks/TrackLink.cs create mode 100644 Source/Trackify.Domain/Tracks/TrackPart.cs create mode 100644 Source/Trackify.Domain/Tracks/TrackPlanSnapshot.cs create mode 100644 Source/Trackify.Infrastructure/Persistence/Migrations/20261014193211_InitialCreate.Designer.cs create mode 100644 Source/Trackify.Infrastructure/Persistence/Migrations/20261014193211_InitialCreate.cs create mode 100644 Source/Trackify.Infrastructure/Persistence/Migrations/TrackifyDbContextModelSnapshot.cs create mode 100644 Source/Trackify.Infrastructure/Persistence/SqliteTrackPlanRepository.cs create mode 100644 Source/Trackify.Infrastructure/Persistence/TrackifyDesignTimeDbContextFactory.cs create mode 100644 Test/Trackify.Tests/Application/TrackPlanExchangeServiceTests.cs create mode 100644 Test/Trackify.Tests/Fakes/FakeTrackPlanRepository.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..4f48799 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "9.0.0", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index 14182be..3554238 100644 --- a/.editorconfig +++ b/.editorconfig @@ -179,3 +179,14 @@ csharp_style_expression_bodied_local_functions = false:silent [**/Platforms/**.cs] dotnet_diagnostic.IDE0130.severity = none + +########################################## +# EF Core migrations are generated by `dotnet ef`, which emits block-scoped namespaces and its own +# formatting. Hand-editing them to satisfy our style rules would be undone by the next generated +# migration, so they are treated as what they are: generated code. +########################################## + +[**/Persistence/Migrations/**.cs] +generated_code = true +dotnet_diagnostic.IDE0130.severity = none +dotnet_diagnostic.IDE0161.severity = none diff --git a/Directory.Packages.props b/Directory.Packages.props index 431c64b..894d200 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -29,6 +29,9 @@ + + diff --git a/Source/Trackify.Application/DependencyInjection.cs b/Source/Trackify.Application/DependencyInjection.cs index 49d5b8c..d3310cd 100644 --- a/Source/Trackify.Application/DependencyInjection.cs +++ b/Source/Trackify.Application/DependencyInjection.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using Trackify.Application.TrackPlans; using Trackify.Application.Trains; #if __ANDROID__ || __IOS__ || WINDOWS using Trackify.Application.Services; @@ -22,6 +23,7 @@ public static IServiceCollection AddTrackifyApplication(this IServiceCollection services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); #if __ANDROID__ services.AddAndroidLego(); diff --git a/Source/Trackify.Application/GlobalUsings.cs b/Source/Trackify.Application/GlobalUsings.cs index ba35671..87b65ce 100644 --- a/Source/Trackify.Application/GlobalUsings.cs +++ b/Source/Trackify.Application/GlobalUsings.cs @@ -1,3 +1,4 @@ global using Trackify.Application.Lego; global using Trackify.Domain.Enums; +global using Trackify.Domain.Tracks; global using Trackify.Domain.Trains; diff --git a/Source/Trackify.Application/TrackPlans/ITrackPlanExchangeService.cs b/Source/Trackify.Application/TrackPlans/ITrackPlanExchangeService.cs new file mode 100644 index 0000000..db2ab15 --- /dev/null +++ b/Source/Trackify.Application/TrackPlans/ITrackPlanExchangeService.cs @@ -0,0 +1,23 @@ +namespace Trackify.Application.TrackPlans; + +/// +/// Moves a track plan between the app and a file — the "build it in the UI, import it into the CLI" +/// path. Its whole surface is DTOs and documents, so a front-end never needs a Domain type. +/// +public interface ITrackPlanExchangeService +{ + /// Checks a plan file without touching the store, for --dry-run. + TrackPlanImportResult Validate(string json); + + /// + /// Imports a plan file. swaps the saved plan for this one; otherwise + /// the parts are added alongside what is already stored. + /// + Task ImportAsync(string json, bool replace, CancellationToken cancellationToken = default); + + /// Writes the saved plan out as a plan file. + Task ExportAsync(string planName = "", CancellationToken cancellationToken = default); + + /// Describes the saved plan: counts, the parts needed, and anything geometrically wrong. + Task SummariseAsync(CancellationToken cancellationToken = default); +} diff --git a/Source/Trackify.Application/TrackPlans/ITrackPlanRepository.cs b/Source/Trackify.Application/TrackPlans/ITrackPlanRepository.cs new file mode 100644 index 0000000..815bbf3 --- /dev/null +++ b/Source/Trackify.Application/TrackPlans/ITrackPlanRepository.cs @@ -0,0 +1,18 @@ +namespace Trackify.Application.TrackPlans; + +/// +/// Storage for the whole plan at once. Deliberately not a per-entity IBaseRepository: a part +/// without its connections is meaningless and a half-written graph would render as track that falls +/// apart, so the plan is loaded and replaced as one unit. +/// +public interface ITrackPlanRepository +{ + /// Loads the saved plan, or when there is none. + Task LoadAsync(CancellationToken cancellationToken = default); + + /// Replaces the saved plan entirely. + Task ReplaceAsync(TrackPlanSnapshot snapshot, CancellationToken cancellationToken = default); + + /// Adds parts and connections to the saved plan, keeping what is already there. + Task AppendAsync(TrackPlanSnapshot snapshot, CancellationToken cancellationToken = default); +} diff --git a/Source/Trackify.Application/TrackPlans/TrackPlanBehaviourDocument.cs b/Source/Trackify.Application/TrackPlans/TrackPlanBehaviourDocument.cs new file mode 100644 index 0000000..8981d45 --- /dev/null +++ b/Source/Trackify.Application/TrackPlans/TrackPlanBehaviourDocument.cs @@ -0,0 +1,29 @@ +namespace Trackify.Application.TrackPlans; + +/// +/// How a train should behave over one part. Kept separate from the part's identity because the two +/// are different kinds of fact: a photograph can tell you the topology but never the intended speed. +/// +public sealed record TrackPlanBehaviourDocument +{ + /// Top speed over the part. + public int MaxSpeed { get; init; } = 70; + + /// Travel direction. + public TrackDirection Direction { get; init; } = TrackDirection.Forward; + + /// Acceleration curve. + public SpeedFunctionType AccelFn { get; init; } = SpeedFunctionType.EaseOut; + + /// Braking curve. + public SpeedFunctionType BrakeFn { get; init; } = SpeedFunctionType.EaseIn; + + /// Sensor on the part, if any. + public SensorType Sensor { get; init; } = SensorType.None; + + /// What the sensor triggers. + public SensorActionType Action { get; init; } = SensorActionType.Notify; + + /// Target speed when the action is to slow down. + public int SlowTarget { get; init; } = 30; +} diff --git a/Source/Trackify.Application/TrackPlans/TrackPlanConnectionDocument.cs b/Source/Trackify.Application/TrackPlans/TrackPlanConnectionDocument.cs new file mode 100644 index 0000000..c4dc8f8 --- /dev/null +++ b/Source/Trackify.Application/TrackPlans/TrackPlanConnectionDocument.cs @@ -0,0 +1,14 @@ +namespace Trackify.Application.TrackPlans; + +/// +/// One joint, written as "p1:end" / "p2:start" — compact and readable, which matters because a +/// person is expected to be able to open this file and understand it. +/// +public sealed record TrackPlanConnectionDocument +{ + /// One side, as "<part id>:<port>". + public string From { get; init; } = ""; + + /// The other side, same form. + public string To { get; init; } = ""; +} diff --git a/Source/Trackify.Application/TrackPlans/TrackPlanDocument.cs b/Source/Trackify.Application/TrackPlans/TrackPlanDocument.cs new file mode 100644 index 0000000..116611b --- /dev/null +++ b/Source/Trackify.Application/TrackPlans/TrackPlanDocument.cs @@ -0,0 +1,35 @@ +namespace Trackify.Application.TrackPlans; + +/// +/// The on-disk, versioned exchange format for a track plan — what the app writes and the CLI reads. +/// +/// It stores the graph (parts plus port connections), not screen coordinates, for the same +/// reason the planner derives geometry rather than storing it: a coordinate could describe a layout +/// that cannot be plugged together, a connection cannot. +/// +public sealed record TrackPlanDocument +{ + /// Marker so a stray JSON file is recognised as not being a track plan. + public const string FormatName = "trackify.trackplan"; + + /// The only version written today. + public const int CurrentVersion = 1; + + /// Always . + public string Format { get; init; } = FormatName; + + /// Format version; an importer refuses what it does not know. + public int Version { get; init; } = CurrentVersion; + + /// Length unit for any . Always "stud" in version 1. + public string Units { get; init; } = "stud"; + + /// What the user called this plan. + public string Name { get; init; } = ""; + + /// The parts, each with a document-local id. + public IReadOnlyList Parts { get; init; } = []; + + /// How they plug together. + public IReadOnlyList Connections { get; init; } = []; +} diff --git a/Source/Trackify.Application/TrackPlans/TrackPlanEntryRuleDocument.cs b/Source/Trackify.Application/TrackPlans/TrackPlanEntryRuleDocument.cs new file mode 100644 index 0000000..0eb9a65 --- /dev/null +++ b/Source/Trackify.Application/TrackPlans/TrackPlanEntryRuleDocument.cs @@ -0,0 +1,14 @@ +namespace Trackify.Application.TrackPlans; + +/// "A train coming in here, bound for there, needs the point set this way." +public sealed record TrackPlanEntryRuleDocument +{ + /// Port the train arrives by: "toe", "main" or "branch". + public string FromPort { get; init; } = ""; + + /// Optional document-local id of the part the train is headed for. + public string? TowardPart { get; init; } + + /// How the point must be set. + public SwitchRoute SetRoute { get; init; } = SwitchRoute.Main; +} diff --git a/Source/Trackify.Application/TrackPlans/TrackPlanExchangeService.cs b/Source/Trackify.Application/TrackPlans/TrackPlanExchangeService.cs new file mode 100644 index 0000000..74b5d0c --- /dev/null +++ b/Source/Trackify.Application/TrackPlans/TrackPlanExchangeService.cs @@ -0,0 +1,366 @@ +using System.Globalization; + +namespace Trackify.Application.TrackPlans; + +/// +public sealed class TrackPlanExchangeService(ITrackPlanRepository repository) : ITrackPlanExchangeService +{ + public TrackPlanImportResult Validate(string json) + => TrackPlanSerializer.TryRead(json, out var document, out var error) + ? Inspect(document, out _) + : TrackPlanImportResult.Failed(error); + + public async Task ImportAsync( + string json, bool replace, CancellationToken cancellationToken = default) + { + if (!TrackPlanSerializer.TryRead(json, out var document, out var error)) + { + return TrackPlanImportResult.Failed(error); + } + + var result = Inspect(document, out var snapshot); + if (!result.Succeeded) return result; + + if (replace) + { + await repository.ReplaceAsync(snapshot, cancellationToken); + } + else + { + await repository.AppendAsync(snapshot, cancellationToken); + } + + return result; + } + + public async Task ExportAsync(string planName = "", CancellationToken cancellationToken = default) + { + var snapshot = await repository.LoadAsync(cancellationToken); + var ordered = snapshot.Parts.OrderBy(part => part.PlanOrder).ToList(); + + // Document-local ids, so the file reads as p1..pN instead of a wall of GUIDs. + var localIds = new Dictionary(ordered.Count); + for (var index = 0; index < ordered.Count; index++) + { + localIds[ordered[index].Id] = $"p{index + 1}"; + } + + return TrackPlanSerializer.Write(new TrackPlanDocument + { + Name = planName, + Parts = [.. ordered.Select(part => ToDocument(part, localIds))], + Connections = + [ + .. snapshot.Links + .Where(link => localIds.ContainsKey(link.FromPartId) && localIds.ContainsKey(link.ToPartId)) + .Select(link => new TrackPlanConnectionDocument + { + From = $"{localIds[link.FromPartId]}:{link.FromPort}", + To = $"{localIds[link.ToPartId]}:{link.ToPort}", + }), + ], + }); + } + + public async Task SummariseAsync(CancellationToken cancellationToken = default) + { + var snapshot = await repository.LoadAsync(cancellationToken); + if (snapshot.Parts.Count == 0) return TrackPlanSummary.Empty; + + var layout = snapshot.ToLayout(); + var switches = snapshot.Parts.Where(part => part.Switch is not null).ToList(); + + return new TrackPlanSummary + { + PartCount = snapshot.Parts.Count, + ConnectionCount = snapshot.Links.Count, + OpenEndCount = layout.OpenEnds.Count, + SwitchCount = switches.Count, + MotorisedSwitchCount = switches.Count(part => part.Switch!.IsMotorised), + SensorCount = snapshot.Parts.Count(part => part.Sensor != SensorType.None), + TotalLengthStuds = snapshot.Parts + .Where(part => TrackPartCatalog.TryGet(part.SpecKey, out _)) + .Sum(part => TrackPartCatalog.Get(part.SpecKey).TotalLengthStuds), + IsBuildable = layout.IsBuildable, + PartsByKey = snapshot.Parts + .GroupBy(part => part.SpecKey, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal), + Problems = [.. layout.Mismatches.Select(Describe)], + }; + } + + // Validates a document and, when it holds together, builds the snapshot it describes. + private static TrackPlanImportResult Inspect(TrackPlanDocument document, out TrackPlanSnapshot snapshot) + { + snapshot = TrackPlanSnapshot.Empty; + + if (!string.Equals(document.Format, TrackPlanDocument.FormatName, StringComparison.OrdinalIgnoreCase)) + { + return TrackPlanImportResult.Failed( + $"Not a track plan: expected format '{TrackPlanDocument.FormatName}', found '{document.Format}'."); + } + + if (document.Version is < 1 or > TrackPlanDocument.CurrentVersion) + { + return TrackPlanImportResult.Failed( + $"Plan version {document.Version} is not supported; this build reads version " + + $"1..{TrackPlanDocument.CurrentVersion}."); + } + + var errors = new List(); + var warnings = new List(); + var parts = ReadParts(document, errors); + + if (document.Parts.Count == 0) errors.Add("The plan has no parts."); + + var links = ReadConnections(document, parts, errors); + + if (errors.Count > 0) + { + return new TrackPlanImportResult { Succeeded = false, Errors = errors, PlanName = document.Name }; + } + + var built = new TrackPlanSnapshot + { + Parts = [.. parts.Values.Select(entry => entry.Part)], + Links = links, + }; + + ResolveEntryRuleTargets(document, parts); + + var layout = built.ToLayout(); + foreach (var mismatch in layout.Mismatches) warnings.Add(Describe(mismatch)); + + if (!string.Equals(document.Units, "stud", StringComparison.OrdinalIgnoreCase)) + { + warnings.Add($"Unknown unit '{document.Units}'; lengths were read as studs."); + } + + snapshot = built; + + return new TrackPlanImportResult + { + Succeeded = true, + PlanName = document.Name, + PartCount = built.Parts.Count, + ConnectionCount = built.Links.Count, + OpenEndCount = layout.OpenEnds.Count, + Warnings = warnings, + }; + } + + private static Dictionary ReadParts(TrackPlanDocument document, List errors) + { + var parts = new Dictionary(document.Parts.Count, StringComparer.Ordinal); + var order = 0; + + foreach (var documentPart in document.Parts) + { + if (string.IsNullOrWhiteSpace(documentPart.Id)) + { + errors.Add("A part has no id."); + continue; + } + + if (documentPart.Id.Contains(':', StringComparison.Ordinal)) + { + errors.Add($"Part id '{documentPart.Id}' must not contain ':'."); + continue; + } + + if (!TrackPartCatalog.TryGet(documentPart.Part, out var spec)) + { + errors.Add($"Part '{documentPart.Id}' is an unknown track part '{documentPart.Part}'."); + continue; + } + + if (parts.ContainsKey(documentPart.Id)) + { + errors.Add($"Duplicate part id '{documentPart.Id}'."); + continue; + } + + parts[documentPart.Id] = new PartEntry(ToEntity(documentPart, spec, order++), spec); + } + + return parts; + } + + private static List ReadConnections( + TrackPlanDocument document, Dictionary parts, List errors) + { + var links = new List(document.Connections.Count); + var occupied = new HashSet(StringComparer.Ordinal); + + foreach (var connection in document.Connections) + { + if (!TryEndpoint(connection.From, parts, errors, out var from)) continue; + if (!TryEndpoint(connection.To, parts, errors, out var to)) continue; + + // A port takes exactly one thing; two connections into one port is not a plan, it is a + // contradiction, and silently keeping the first would hide it. + if (!occupied.Add(connection.From)) errors.Add($"Port '{connection.From}' is connected more than once."); + if (!occupied.Add(connection.To)) errors.Add($"Port '{connection.To}' is connected more than once."); + + links.Add(new TrackLink + { + FromPartId = from.PartId, + FromPort = from.PortName, + ToPartId = to.PartId, + ToPort = to.PortName, + }); + } + + return links; + } + + private static bool TryEndpoint( + string endpoint, Dictionary parts, List errors, out Endpoint resolved) + { + resolved = default; + + var separator = endpoint.IndexOf(':', StringComparison.Ordinal); + if (separator <= 0 || separator == endpoint.Length - 1) + { + errors.Add($"Connection endpoint '{endpoint}' is not in the form 'part:port'."); + return false; + } + + var partId = endpoint[..separator]; + var portName = endpoint[(separator + 1)..]; + + if (!parts.TryGetValue(partId, out var entry)) + { + errors.Add($"Connection endpoint '{endpoint}' names a part that is not in the plan."); + return false; + } + + if (!entry.Spec.HasPort(portName)) + { + errors.Add($"Connection endpoint '{endpoint}': a '{entry.Spec.Key}' has no port '{portName}'."); + return false; + } + + resolved = new Endpoint(entry.Part.Id, portName); + return true; + } + + // Entry rules point at a part by document-local id, so they can only be resolved once every + // part has an identity. + private static void ResolveEntryRuleTargets(TrackPlanDocument document, Dictionary parts) + { + foreach (var documentPart in document.Parts) + { + if (documentPart.Switch is null) continue; + if (!parts.TryGetValue(documentPart.Id, out var entry)) continue; + if (entry.Part.Switch is not { } config) continue; + + var documentRules = documentPart.Switch.EntryRules; + var shared = Math.Min(config.EntryRules.Count, documentRules.Count); + + for (var index = 0; index < shared; index++) + { + if (documentRules[index].TowardPart is { } towardId && parts.TryGetValue(towardId, out var target)) + { + config.EntryRules[index].TowardPartId = target.Part.Id; + } + } + } + } + + private static TrackPart ToEntity(TrackPlanPartDocument document, TrackPartSpec spec, int order) + { + var behaviour = document.Behaviour ?? new TrackPlanBehaviourDocument(); + + return new TrackPart + { + Id = Guid.CreateVersion7(), + SpecKey = spec.Key, + Name = document.Name, + PlanOrder = order, + MaxSpeed = behaviour.MaxSpeed, + Direction = behaviour.Direction, + AccelFn = behaviour.AccelFn, + BrakeFn = behaviour.BrakeFn, + Sensor = behaviour.Sensor, + Action = behaviour.Action, + SlowTarget = behaviour.SlowTarget, + Switch = document.Switch is null ? null : ToEntity(document.Switch), + }; + } + + private static SwitchConfig ToEntity(TrackPlanSwitchDocument document) => new() + { + DefaultRoute = document.DefaultRoute, + MotorHubId = document.MotorHubId, + MotorPort = document.MotorPort, + SensorType = document.SensorType, + SensorHubId = document.SensorHubId, + SensorPort = document.SensorPort, + SensorTrigger = document.SensorTrigger, + EntryRules = + [ + .. document.EntryRules.Select(rule => new SwitchEntryRule + { + FromPort = rule.FromPort, + SetRoute = rule.SetRoute, + }), + ], + }; + + private static TrackPlanPartDocument ToDocument(TrackPart part, Dictionary localIds) => new() + { + Id = localIds[part.Id], + Part = part.SpecKey, + Name = part.Name, + Behaviour = new TrackPlanBehaviourDocument + { + MaxSpeed = part.MaxSpeed, + Direction = part.Direction, + AccelFn = part.AccelFn, + BrakeFn = part.BrakeFn, + Sensor = part.Sensor, + Action = part.Action, + SlowTarget = part.SlowTarget, + }, + Switch = part.Switch is null ? null : ToDocument(part.Switch, localIds), + }; + + private static TrackPlanSwitchDocument ToDocument(SwitchConfig config, Dictionary localIds) => new() + { + DefaultRoute = config.DefaultRoute, + MotorHubId = config.MotorHubId, + MotorPort = config.MotorPort, + SensorType = config.SensorType, + SensorHubId = config.SensorHubId, + SensorPort = config.SensorPort, + SensorTrigger = config.SensorTrigger, + EntryRules = + [ + .. config.EntryRules.Select(rule => new TrackPlanEntryRuleDocument + { + FromPort = rule.FromPort, + TowardPart = rule.TowardPartId is { } toward && localIds.TryGetValue(toward, out var local) + ? local + : null, + SetRoute = rule.SetRoute, + }), + ], + }; + + private static string Describe(TrackJoinMismatch mismatch) + { + var gap = mismatch.GapStuds.ToString("0.0", CultureInfo.InvariantCulture); + + return mismatch.HeadingStepsOff == 0 + ? $"A joint does not line up: {gap} studs apart." + : $"A joint does not line up: {gap} studs apart and " + + $"{Math.Abs(mismatch.HeadingStepsOff)} curve(s) off. The loop does not really close."; + } + + /// A part being read, paired with the catalogue geometry its ports are checked against. + private readonly record struct PartEntry(TrackPart Part, TrackPartSpec Spec); + + /// A resolved "part:port" endpoint. + private readonly record struct Endpoint(Guid PartId, string PortName); +} diff --git a/Source/Trackify.Application/TrackPlans/TrackPlanImportResult.cs b/Source/Trackify.Application/TrackPlans/TrackPlanImportResult.cs new file mode 100644 index 0000000..cdcb4e9 --- /dev/null +++ b/Source/Trackify.Application/TrackPlans/TrackPlanImportResult.cs @@ -0,0 +1,33 @@ +namespace Trackify.Application.TrackPlans; + +/// +/// What came of reading a plan file. Errors mean nothing was imported; warnings mean it was, but +/// something is worth saying — a loop that does not really close, most usefully. +/// +public sealed record TrackPlanImportResult +{ + /// Whether the file was good enough to import. + public bool Succeeded { get; init; } + + /// The plan's name from the file. + public string PlanName { get; init; } = ""; + + /// How many parts it holds. + public int PartCount { get; init; } + + /// How many connections it holds. + public int ConnectionCount { get; init; } + + /// How many ports are still free. + public int OpenEndCount { get; init; } + + /// Why it could not be imported. + public IReadOnlyList Errors { get; init; } = []; + + /// Things that are true but worth knowing. + public IReadOnlyList Warnings { get; init; } = []; + + /// A refusal carrying the reasons. + public static TrackPlanImportResult Failed(params string[] errors) + => new() { Succeeded = false, Errors = errors }; +} diff --git a/Source/Trackify.Application/TrackPlans/TrackPlanPartDocument.cs b/Source/Trackify.Application/TrackPlans/TrackPlanPartDocument.cs new file mode 100644 index 0000000..ed460c9 --- /dev/null +++ b/Source/Trackify.Application/TrackPlans/TrackPlanPartDocument.cs @@ -0,0 +1,27 @@ +namespace Trackify.Application.TrackPlans; + +/// One part in an exported plan. +public sealed record TrackPlanPartDocument +{ + /// Document-local id ("p1"), referenced by the connections. Readable on purpose. + public string Id { get; init; } = ""; + + /// Catalogue key, e.g. "curve-r40-right". + public string Part { get; init; } = ""; + + /// The planner's label for it. + public string Name { get; init; } = ""; + + /// Drive behaviour; omitted when it is all defaults. + public TrackPlanBehaviourDocument? Behaviour { get; init; } + + /// Point configuration; omitted for every part that is not a point. + public TrackPlanSwitchDocument? Switch { get; init; } + + /// + /// Optional measured world pose. Nothing needs it — the geometry comes from the connections — + /// but it is where a reconstruction from a photograph can hand over what it measured, letting + /// the importer derive the connections instead of demanding them. + /// + public TrackPlanPoseDocument? Pose { get; init; } +} diff --git a/Source/Trackify.Application/TrackPlans/TrackPlanPoseDocument.cs b/Source/Trackify.Application/TrackPlans/TrackPlanPoseDocument.cs new file mode 100644 index 0000000..a245f7c --- /dev/null +++ b/Source/Trackify.Application/TrackPlans/TrackPlanPoseDocument.cs @@ -0,0 +1,20 @@ +namespace Trackify.Application.TrackPlans; + +/// +/// A measured world pose in studs, with the heading as a 22.5 degree step 0..15. +/// +/// Never required and never trusted over the connections. It is the hook for importing a plan that +/// was measured rather than drawn — a photograph of a real layout yields poses, and ports that land +/// on each other within tolerance can be paired up into connections from there. +/// +public sealed record TrackPlanPoseDocument +{ + /// Studs right of the plan origin. + public double X { get; init; } + + /// Studs below the plan origin. + public double Y { get; init; } + + /// Facing, as a 22.5 degree step 0..15. + public int Heading { get; init; } +} diff --git a/Source/Trackify.Application/TrackPlans/TrackPlanSerializer.cs b/Source/Trackify.Application/TrackPlans/TrackPlanSerializer.cs new file mode 100644 index 0000000..ccc957f --- /dev/null +++ b/Source/Trackify.Application/TrackPlans/TrackPlanSerializer.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Trackify.Application.TrackPlans; + +/// +/// Reads and writes as JSON. Enums go out as names and numbers stay +/// culture-invariant, so the file is readable and means the same thing on every machine. +/// +public static class TrackPlanSerializer +{ + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true, + Converters = { new JsonStringEnumConverter() }, + }; + + /// Serialises to indented JSON. + public static string Write(TrackPlanDocument document) => JsonSerializer.Serialize(document, Options); + + /// + /// Parses . Returns false with a human-readable + /// rather than throwing, because the usual cause is a person handing over the wrong file. + /// + public static bool TryRead(string json, out TrackPlanDocument document, out string error) + { + document = new TrackPlanDocument(); + + if (string.IsNullOrWhiteSpace(json)) + { + error = "The file is empty."; + return false; + } + + try + { + var parsed = JsonSerializer.Deserialize(json, Options); + + if (parsed is null) + { + error = "The file does not contain a track plan."; + return false; + } + + document = parsed; + error = ""; + return true; + } + catch (JsonException exception) + { + error = $"The file is not valid JSON: {exception.Message}"; + return false; + } + } +} diff --git a/Source/Trackify.Application/TrackPlans/TrackPlanSummary.cs b/Source/Trackify.Application/TrackPlans/TrackPlanSummary.cs new file mode 100644 index 0000000..f6f2008 --- /dev/null +++ b/Source/Trackify.Application/TrackPlans/TrackPlanSummary.cs @@ -0,0 +1,44 @@ +namespace Trackify.Application.TrackPlans; + +/// What the saved plan currently looks like — the shape behind trackify track show. +public sealed record TrackPlanSummary +{ + /// A plan with nothing in it. + public static TrackPlanSummary Empty { get; } = new(); + + /// Total parts laid. + public int PartCount { get; init; } + + /// Total connections. + public int ConnectionCount { get; init; } + + /// Ports with nothing plugged into them. + public int OpenEndCount { get; init; } + + /// Points in the plan. + public int SwitchCount { get; init; } + + /// Points that have a motor assigned and so can actually be thrown. + public int MotorisedSwitchCount { get; init; } + + /// Parts carrying a sensor. + public int SensorCount { get; init; } + + /// Total rail, in studs. + public double TotalLengthStuds { get; init; } + + /// + /// The same length in metres. Converted here rather than by the caller so a front-end never has + /// to reach into the Domain's track metrics to print it. + /// + public double TotalLengthMetres => TotalLengthStuds * TrackMetrics.MillimetresPerStud / 1000; + + /// Whether every claimed connection actually works geometrically. + public bool IsBuildable { get; init; } = true; + + /// How many of each catalogue part the plan needs — the shopping list. + public IReadOnlyDictionary PartsByKey { get; init; } = new Dictionary(); + + /// Joints the geometry contradicts, described for a person. + public IReadOnlyList Problems { get; init; } = []; +} diff --git a/Source/Trackify.Application/TrackPlans/TrackPlanSwitchDocument.cs b/Source/Trackify.Application/TrackPlans/TrackPlanSwitchDocument.cs new file mode 100644 index 0000000..f8d4de9 --- /dev/null +++ b/Source/Trackify.Application/TrackPlans/TrackPlanSwitchDocument.cs @@ -0,0 +1,29 @@ +namespace Trackify.Application.TrackPlans; + +/// A point's stored configuration: how it sits, what throws it, what watches it, how to route it. +public sealed record TrackPlanSwitchDocument +{ + /// Where the point sits when no rule applies. + public SwitchRoute DefaultRoute { get; init; } = SwitchRoute.Main; + + /// Hub the throwing motor is on. + public string? MotorHubId { get; init; } + + /// Port the throwing motor is plugged into. + public string? MotorPort { get; init; } + + /// Kind of sensor watching the approach. + public SensorType SensorType { get; init; } = SensorType.None; + + /// Hub the sensor is on. + public string? SensorHubId { get; init; } + + /// Port the sensor is plugged into. + public string? SensorPort { get; init; } + + /// Colour that counts as the trigger, for a colour sensor. + public LedColorType? SensorTrigger { get; init; } + + /// Automatic-routing rules, in priority order. + public IReadOnlyList EntryRules { get; init; } = []; +} diff --git a/Source/Trackify.Cli/Commands/Settings/TrackExportSettings.cs b/Source/Trackify.Cli/Commands/Settings/TrackExportSettings.cs new file mode 100644 index 0000000..fa2c5e6 --- /dev/null +++ b/Source/Trackify.Cli/Commands/Settings/TrackExportSettings.cs @@ -0,0 +1,15 @@ +using System.ComponentModel; + +namespace Trackify.Cli.Commands.Settings; + +/// Options for track export. +public sealed class TrackExportSettings : CommandSettings +{ + [CommandArgument(0, "[FILE]")] + [Description("Where to write the plan. Omit it to print to standard output.")] + public string? File { get; init; } + + [CommandOption("-n|--name ")] + [Description("Name to record in the exported plan.")] + public string Name { get; init; } = ""; +} diff --git a/Source/Trackify.Cli/Commands/Settings/TrackImportSettings.cs b/Source/Trackify.Cli/Commands/Settings/TrackImportSettings.cs new file mode 100644 index 0000000..55b4fdc --- /dev/null +++ b/Source/Trackify.Cli/Commands/Settings/TrackImportSettings.cs @@ -0,0 +1,19 @@ +using System.ComponentModel; + +namespace Trackify.Cli.Commands.Settings; + +/// Options for track import. +public sealed class TrackImportSettings : CommandSettings +{ + [CommandArgument(0, "")] + [Description("The track plan file to read (JSON, as exported from the app).")] + public string File { get; init; } = ""; + + [CommandOption("--replace")] + [Description("Replace the saved plan instead of adding these parts to it.")] + public bool Replace { get; init; } + + [CommandOption("--dry-run")] + [Description("Check the file and report what would happen, without changing the store.")] + public bool DryRun { get; init; } +} diff --git a/Source/Trackify.Cli/Commands/TrackExportCommand.cs b/Source/Trackify.Cli/Commands/TrackExportCommand.cs new file mode 100644 index 0000000..02a178f --- /dev/null +++ b/Source/Trackify.Cli/Commands/TrackExportCommand.cs @@ -0,0 +1,25 @@ +using Trackify.Cli.Commands.Settings; + +namespace Trackify.Cli.Commands; + +/// Writes the saved track plan out as a plan file. +public sealed class TrackExportCommand(ITrackPlanExchangeService exchange) : AsyncCommand +{ + protected override async Task ExecuteAsync( + CommandContext context, TrackExportSettings settings, CancellationToken cancellationToken) + { + var json = await exchange.ExportAsync(settings.Name, cancellationToken); + + if (string.IsNullOrWhiteSpace(settings.File)) + { + // Plain, unmarkupped, so the output can be piped straight into a file. + AnsiConsole.WriteLine(json); + return 0; + } + + await File.WriteAllTextAsync(settings.File, json, cancellationToken); + AnsiConsole.MarkupLineInterpolated($"[green]✓[/] Wrote the plan to {settings.File}."); + + return 0; + } +} diff --git a/Source/Trackify.Cli/Commands/TrackImportCommand.cs b/Source/Trackify.Cli/Commands/TrackImportCommand.cs new file mode 100644 index 0000000..d74fc1d --- /dev/null +++ b/Source/Trackify.Cli/Commands/TrackImportCommand.cs @@ -0,0 +1,52 @@ +using Trackify.Cli.Commands.Settings; + +namespace Trackify.Cli.Commands; + +/// +/// Reads a track plan built in the app into this machine's store — the "generate it in the UI, +/// import it into the CLI" path. +/// +public sealed class TrackImportCommand(ITrackPlanExchangeService exchange) : AsyncCommand +{ + protected override async Task ExecuteAsync( + CommandContext context, TrackImportSettings settings, CancellationToken cancellationToken) + { + if (!File.Exists(settings.File)) + { + AnsiConsole.MarkupLineInterpolated($"[red]✗[/] No such file: {settings.File}"); + return 1; + } + + var json = await File.ReadAllTextAsync(settings.File, cancellationToken); + + var result = settings.DryRun + ? exchange.Validate(json) + : await exchange.ImportAsync(json, settings.Replace, cancellationToken); + + // Messages carry part keys and port names straight out of the file, so they are escaped. + foreach (var error in result.Errors) AnsiConsole.MarkupLineInterpolated($"[red]✗[/] {error}"); + foreach (var warning in result.Warnings) AnsiConsole.MarkupLineInterpolated($"[yellow]![/] {warning}"); + + if (!result.Succeeded) + { + AnsiConsole.MarkupLine("[red]Nothing was imported.[/]"); + return 1; + } + + var verb = settings.DryRun + ? "Would import" + : settings.Replace ? "Replaced the plan with" : "Imported"; + var name = string.IsNullOrWhiteSpace(result.PlanName) ? "(unnamed)" : result.PlanName; + + AnsiConsole.MarkupLineInterpolated( + $"[green]✓[/] {verb} {result.PartCount} part(s) and {result.ConnectionCount} connection(s) from {name}."); + + if (result.OpenEndCount > 0) + { + AnsiConsole.MarkupLineInterpolated( + $" {result.OpenEndCount} open end(s) — the track is not a closed loop."); + } + + return 0; + } +} diff --git a/Source/Trackify.Cli/Commands/TrackShowCommand.cs b/Source/Trackify.Cli/Commands/TrackShowCommand.cs new file mode 100644 index 0000000..a72a615 --- /dev/null +++ b/Source/Trackify.Cli/Commands/TrackShowCommand.cs @@ -0,0 +1,52 @@ +namespace Trackify.Cli.Commands; + +/// +/// Describes the saved track plan: how much of each part it needs, how much rail that is, what is +/// still open, and whether every claimed joint actually works. +/// +public sealed class TrackShowCommand(ITrackPlanExchangeService exchange) : AsyncCommand +{ + protected override async Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) + { + var summary = await exchange.SummariseAsync(cancellationToken); + + if (summary.PartCount == 0) + { + AnsiConsole.MarkupLine( + "[yellow]No track plan saved yet.[/] Build one in the app, then 'track import' it here."); + return 0; + } + + var parts = new Table().Border(TableBorder.Rounded); + parts.AddColumn("Part"); + parts.AddColumn(new TableColumn("Count").RightAligned()); + + foreach (var entry in summary.PartsByKey.OrderByDescending(entry => entry.Value)) + { + parts.AddRow(Markup.Escape(entry.Key), $"{entry.Value}"); + } + + AnsiConsole.Write(parts); + + AnsiConsole.MarkupLineInterpolated( + $"{summary.PartCount} parts · {summary.ConnectionCount} connections · {summary.OpenEndCount} open end(s)"); + + // Studs and metres both come from the summary: converting here would mean reaching into the + // Domain's track metrics, which is exactly the dependency the DTO boundary forbids the CLI. + AnsiConsole.MarkupLineInterpolated( + $"{summary.TotalLengthStuds:0} studs of rail ({summary.TotalLengthMetres:0.00} m) · {summary.SensorCount} sensor(s)"); + AnsiConsole.MarkupLineInterpolated( + $"{summary.SwitchCount} point(s), {summary.MotorisedSwitchCount} of them motorised"); + + if (summary.IsBuildable) + { + AnsiConsole.MarkupLine("[green]✓ Every connection lines up — this plan can be built.[/]"); + return 0; + } + + AnsiConsole.MarkupLine("[red]✗ This plan cannot be built as connected:[/]"); + foreach (var problem in summary.Problems) AnsiConsole.MarkupLineInterpolated($" {problem}"); + + return 1; + } +} diff --git a/Source/Trackify.Cli/GlobalUsings.cs b/Source/Trackify.Cli/GlobalUsings.cs index 9c8933e..3b9c1a6 100644 --- a/Source/Trackify.Cli/GlobalUsings.cs +++ b/Source/Trackify.Cli/GlobalUsings.cs @@ -1,3 +1,4 @@ global using Spectre.Console; global using Spectre.Console.Cli; +global using Trackify.Application.TrackPlans; global using Trackify.Application.Trains; diff --git a/Source/Trackify.Cli/Program.cs b/Source/Trackify.Cli/Program.cs index f7fbd22..141de0e 100644 --- a/Source/Trackify.Cli/Program.cs +++ b/Source/Trackify.Cli/Program.cs @@ -63,6 +63,21 @@ config.AddCommand("stop").WithDescription("Stop a train's motor."); config.AddCommand("color").WithDescription("Set a train's hub LED colour.").WithExample("color", "\"Blauer Zug\"", "Blue"); config.AddCommand("auto").WithDescription("Auto-pilot: keep all saved trains running, re-scanning on an interval.").WithExample("auto", "--interval", "60"); + + // The track plan built in the app arrives here as a file: "I generate it in the UI and import it + // into the CLI, and the rest no longer interests me." + config.AddBranch("track", track => + { + track.SetDescription("Work with the track plan built in the app."); + track.AddCommand("import") + .WithDescription("Read a track plan file into this machine's store.") + .WithExample("track", "import", "kellerbahn.json", "--replace"); + track.AddCommand("export") + .WithDescription("Write the saved track plan out as a file.") + .WithExample("track", "export", "kellerbahn.json"); + track.AddCommand("show") + .WithDescription("Describe the saved track plan: parts needed, open ends, problems."); + }); // S5332: this is --help example text for the documented LAN deployment (a Pi has no cert), not a // client dialling out over http. The bind address itself comes from appsettings/--urls. #pragma warning disable S5332 // Using http protocol is insecure diff --git a/Source/Trackify.Domain/Tracks/SwitchConfig.cs b/Source/Trackify.Domain/Tracks/SwitchConfig.cs new file mode 100644 index 0000000..cc1c103 --- /dev/null +++ b/Source/Trackify.Domain/Tracks/SwitchConfig.cs @@ -0,0 +1,67 @@ +namespace Trackify.Domain.Tracks; + +/// +/// Everything a point needs beyond its geometry: how it is standing, which motor throws it, where +/// the sensor that spots an approaching train is plugged in, and the rules that decide which way to +/// set it (). +/// +/// The hub and port fields are deliberately plain strings and nullable: a plan is built at the +/// kitchen table long before anything is wired up, and an unwired point is a perfectly valid plan. +/// +public sealed class SwitchConfig +{ + /// Which way the point sits when nothing has asked for anything else. + public SwitchRoute DefaultRoute { get; set; } = SwitchRoute.Main; + + /// Hub address of the motor that throws this point; null while it is still hand-thrown. + public string? MotorHubId { get; set; } + + /// Port on the motor is plugged into. + public string? MotorPort { get; set; } + + /// What kind of sensor watches this point's approach. + public SensorType SensorType { get; set; } = SensorType.None; + + /// Hub address the sensor is plugged into. + public string? SensorHubId { get; set; } + + /// Port on the sensor is plugged into. + public string? SensorPort { get; set; } + + /// For a colour sensor, which colour counts as the trigger. + public LedColorType? SensorTrigger { get; set; } + + /// The automatic-routing rules, in the order they should be considered. + public List EntryRules { get; set; } = []; + + /// Whether a motor has been assigned, i.e. whether this point can be thrown at all. + public bool IsMotorised => !string.IsNullOrWhiteSpace(MotorHubId) && !string.IsNullOrWhiteSpace(MotorPort); + + /// Whether a sensor has been assigned. + public bool HasSensor => SensorType != SensorType.None + && !string.IsNullOrWhiteSpace(SensorHubId) + && !string.IsNullOrWhiteSpace(SensorPort); + + /// + /// Which way the point must be set for a train arriving at bound for + /// . The first matching rule wins; destination-specific rules are + /// considered before catch-all ones, so a general rule never shadows a specific one. + /// + public SwitchRoute ResolveRoute(string fromPort, Guid? towardPartId = null) + { + foreach (var rule in EntryRules) + { + if (rule.TowardPartId is null || towardPartId is null) continue; + if (!string.Equals(rule.FromPort, fromPort, StringComparison.Ordinal)) continue; + if (rule.TowardPartId == towardPartId) return rule.SetRoute; + } + + foreach (var rule in EntryRules) + { + if (rule.TowardPartId is not null) continue; + if (string.Equals(rule.FromPort, fromPort, StringComparison.Ordinal)) return rule.SetRoute; + } + + return DefaultRoute; + } +} diff --git a/Source/Trackify.Domain/Tracks/SwitchEntryRule.cs b/Source/Trackify.Domain/Tracks/SwitchEntryRule.cs new file mode 100644 index 0000000..fc72169 --- /dev/null +++ b/Source/Trackify.Domain/Tracks/SwitchEntryRule.cs @@ -0,0 +1,22 @@ +namespace Trackify.Domain.Tracks; + +/// +/// One rule for throwing a point automatically: a train arriving at — and, +/// when set, heading for — means the point must be set to +/// before it gets there. +/// +/// The data exists now; acting on it is a later job. Without it the plan could record how a point +/// happens to be standing but not what it should do, which is the thing that actually matters once +/// a train is running. +/// +public sealed class SwitchEntryRule +{ + /// Which of the point's ports the train comes in by (see ). + public string FromPort { get; set; } = TrackPortNames.Toe; + + /// Optionally, the part the train is headed for — so one point can route by destination. + public Guid? TowardPartId { get; set; } + + /// How the point must be set for that train. + public SwitchRoute SetRoute { get; set; } = SwitchRoute.Main; +} diff --git a/Source/Trackify.Domain/Tracks/TrackLink.cs b/Source/Trackify.Domain/Tracks/TrackLink.cs new file mode 100644 index 0000000..10cfd6a --- /dev/null +++ b/Source/Trackify.Domain/Tracks/TrackLink.cs @@ -0,0 +1,23 @@ +namespace Trackify.Domain.Tracks; + +/// +/// A saved connection between two parts' ports — the persisted form of +/// , which is the same fact without the entity bookkeeping. +/// +public sealed record TrackLink : BaseEntity +{ + /// The part one side of the joint belongs to. + public Guid FromPartId { get; set; } + + /// Which of its ports (see ). + public string FromPort { get; set; } = TrackPortNames.End; + + /// The part on the other side. + public Guid ToPartId { get; set; } + + /// Which of its ports. + public string ToPort { get; set; } = TrackPortNames.Start; + + /// This connection as the geometry layer wants it. + public TrackPlanLink ToPlanLink() => new(FromPartId, FromPort, ToPartId, ToPort); +} diff --git a/Source/Trackify.Domain/Tracks/TrackPart.cs b/Source/Trackify.Domain/Tracks/TrackPart.cs new file mode 100644 index 0000000..a0126db --- /dev/null +++ b/Source/Trackify.Domain/Tracks/TrackPart.cs @@ -0,0 +1,52 @@ +namespace Trackify.Domain.Tracks; + +/// +/// A laid, saved part: which catalogue piece it is, how a train should behave over it, and — for a +/// point — how it should be thrown (). +/// +/// Distinct from on purpose: that one is the bare identity plus +/// catalogue key the geometry needs, keeping and +/// free of any dependency on drive behaviour. Convert with +/// . +/// +/// A record because is one; note that the generated equality therefore +/// compares by reference. +/// +public sealed record TrackPart : BaseEntity +{ + /// Which catalogue part this is — see . + public string SpecKey { get; set; } = TrackPartCatalog.StraightKey; + + /// What the planner calls it ("Kurve rechts 3"). + public string Name { get; set; } = ""; + + /// Stable display order, so lists and the layout root do not shuffle between loads. + public int PlanOrder { get; set; } + + /// Top speed over this part. + public int MaxSpeed { get; set; } = 70; + + /// Which way a train runs over it. + public TrackDirection Direction { get; set; } = TrackDirection.Forward; + + /// Acceleration curve. + public SpeedFunctionType AccelFn { get; set; } = SpeedFunctionType.EaseOut; + + /// Braking curve. + public SpeedFunctionType BrakeFn { get; set; } = SpeedFunctionType.EaseIn; + + /// A sensor sitting on this part, if any. + public SensorType Sensor { get; set; } = SensorType.None; + + /// What that sensor should cause. + public SensorActionType Action { get; set; } = SensorActionType.Notify; + + /// Target speed when the sensor action is to slow down. + public int SlowTarget { get; set; } = 30; + + /// Point configuration; null for every part that is not a point. + public SwitchConfig? Switch { get; set; } + + /// This part as the geometry layer wants it. + public TrackPlanPart ToPlanPart() => new(Id, SpecKey); +} diff --git a/Source/Trackify.Domain/Tracks/TrackPlanSnapshot.cs b/Source/Trackify.Domain/Tracks/TrackPlanSnapshot.cs new file mode 100644 index 0000000..43caa21 --- /dev/null +++ b/Source/Trackify.Domain/Tracks/TrackPlanSnapshot.cs @@ -0,0 +1,24 @@ +namespace Trackify.Domain.Tracks; + +/// A whole saved plan: its parts and their connections, loaded or stored as one unit. +public sealed record TrackPlanSnapshot +{ + /// A plan with nothing in it. + public static TrackPlanSnapshot Empty { get; } = new() { Parts = [], Links = [] }; + + /// The parts, in order. + public required IReadOnlyList Parts { get; init; } + + /// Their connections. + public required IReadOnlyList Links { get; init; } + + /// The bare graph the geometry layer works on. + public TrackPlanTopology ToTopology() => new() + { + Parts = [.. Parts.OrderBy(part => part.PlanOrder).Select(part => part.ToPlanPart())], + Links = [.. Links.Select(link => link.ToPlanLink())], + }; + + /// Walks this plan into real coordinates. + public TrackPlanLayout ToLayout() => TrackPlanWalker.Walk(ToTopology()); +} diff --git a/Source/Trackify.Infrastructure/DependencyInjection.cs b/Source/Trackify.Infrastructure/DependencyInjection.cs index 3dd5782..dfa77c2 100644 --- a/Source/Trackify.Infrastructure/DependencyInjection.cs +++ b/Source/Trackify.Infrastructure/DependencyInjection.cs @@ -25,6 +25,7 @@ public static IServiceCollection AddTrackifyInfrastructure(this IServiceCollecti services.AddDbContextFactory(options => options.UseSqlite($"Data Source={resolvedPath}")); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddLinuxLego(); return services; diff --git a/Source/Trackify.Infrastructure/GlobalUsings.cs b/Source/Trackify.Infrastructure/GlobalUsings.cs index beb4b4a..4dd67a4 100644 --- a/Source/Trackify.Infrastructure/GlobalUsings.cs +++ b/Source/Trackify.Infrastructure/GlobalUsings.cs @@ -1,3 +1,5 @@ global using Trackify.Application.Lego; +global using Trackify.Application.TrackPlans; global using Trackify.Application.Trains; +global using Trackify.Domain.Tracks; global using Trackify.Domain.Trains; diff --git a/Source/Trackify.Infrastructure/Persistence/BaseRepository.cs b/Source/Trackify.Infrastructure/Persistence/BaseRepository.cs index cccb924..c00c82f 100644 --- a/Source/Trackify.Infrastructure/Persistence/BaseRepository.cs +++ b/Source/Trackify.Infrastructure/Persistence/BaseRepository.cs @@ -10,7 +10,8 @@ namespace Trackify.Infrastructure.Persistence; /// : BaseRepository<TContext, TEntity>, ITEntityRepository. Generic over the /// too, so the base type never depends on a concrete /// . A context is created per operation via -/// (thread-safe); the database is created on first use. Writes run inside a database transaction +/// (thread-safe); the database is created and brought up to the latest migration on first use. +/// Writes run inside a database transaction /// (committed on success, rolled back on error) whenever the provider is relational (SQLite here); /// a non-relational provider skips it. /// @@ -25,7 +26,7 @@ protected BaseRepository(IDbContextFactory dbContextFactory) DbContextFactory = dbContextFactory; using var database = DbContextFactory.CreateDbContext(); - database.Database.EnsureCreated(); + database.Database.Migrate(); } public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) diff --git a/Source/Trackify.Infrastructure/Persistence/Migrations/20261014193211_InitialCreate.Designer.cs b/Source/Trackify.Infrastructure/Persistence/Migrations/20261014193211_InitialCreate.Designer.cs new file mode 100644 index 0000000..1bfcfeb --- /dev/null +++ b/Source/Trackify.Infrastructure/Persistence/Migrations/20261014193211_InitialCreate.Designer.cs @@ -0,0 +1,329 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Trackify.Infrastructure.Persistence; + +#nullable disable + +namespace Trackify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(TrackifyDbContext))] + [Migration("20261014193211_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + + modelBuilder.Entity("Trackify.Domain.Tracks.TrackLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("INTEGER"); + + b.Property("DateUpdated") + .HasColumnType("INTEGER"); + + b.Property("FromPartId") + .HasColumnType("TEXT"); + + b.Property("FromPort") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ToPartId") + .HasColumnType("TEXT"); + + b.Property("ToPort") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FromPartId"); + + b.HasIndex("ToPartId"); + + b.ToTable("TrackLinks"); + }); + + modelBuilder.Entity("Trackify.Domain.Tracks.TrackPart", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccelFn") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Action") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("BrakeFn") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("INTEGER"); + + b.Property("DateUpdated") + .HasColumnType("INTEGER"); + + b.Property("Direction") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MaxSpeed") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlanOrder") + .HasColumnType("INTEGER"); + + b.Property("Sensor") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SlowTarget") + .HasColumnType("INTEGER"); + + b.Property("SpecKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlanOrder"); + + b.ToTable("TrackParts"); + }); + + modelBuilder.Entity("Trackify.Domain.Trains.TrackSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccelFn") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Action") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("BrakeFn") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("BranchDirection") + .HasColumnType("TEXT"); + + b.Property("BranchLinkSegmentId") + .HasColumnType("TEXT"); + + b.Property("BranchStrandId") + .HasColumnType("TEXT"); + + b.Property("Curve") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("INTEGER"); + + b.Property("DateUpdated") + .HasColumnType("INTEGER"); + + b.Property("Direction") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MaxSpeed") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Route") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Sensor") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SlowTarget") + .HasColumnType("INTEGER"); + + b.Property("StrandId") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("TrackSegments"); + }); + + modelBuilder.Entity("Trackify.Domain.Trains.Train", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccelExpression") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AccelFn") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("BleAddress") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("BrakeExpression") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("BrakeFn") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Color") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("INTEGER"); + + b.Property("DateUpdated") + .HasColumnType("INTEGER"); + + b.Property("Hub") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("HubId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PortA") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PortB") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Speed") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Trains"); + }); + + modelBuilder.Entity("Trackify.Domain.Tracks.TrackPart", b => + { + b.OwnsOne("Trackify.Domain.Tracks.SwitchConfig", "Switch", b1 => + { + b1.Property("TrackPartId") + .HasColumnType("TEXT"); + + b1.Property("DefaultRoute") + .IsRequired() + .HasColumnType("TEXT"); + + b1.Property("MotorHubId") + .HasColumnType("TEXT"); + + b1.Property("MotorPort") + .HasColumnType("TEXT"); + + b1.Property("SensorHubId") + .HasColumnType("TEXT"); + + b1.Property("SensorPort") + .HasColumnType("TEXT"); + + b1.Property("SensorTrigger") + .HasColumnType("TEXT"); + + b1.Property("SensorType") + .IsRequired() + .HasColumnType("TEXT"); + + b1.HasKey("TrackPartId"); + + b1.ToTable("TrackParts"); + + b1.ToJson("Switch"); + + b1.WithOwner() + .HasForeignKey("TrackPartId"); + + b1.OwnsMany("Trackify.Domain.Tracks.SwitchEntryRule", "EntryRules", b2 => + { + b2.Property("SwitchConfigTrackPartId") + .HasColumnType("TEXT"); + + b2.Property("__synthesizedOrdinal") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("INTEGER"); + + b2.Property("FromPort") + .IsRequired() + .HasColumnType("TEXT"); + + b2.Property("SetRoute") + .IsRequired() + .HasColumnType("TEXT"); + + b2.Property("TowardPartId") + .HasColumnType("TEXT"); + + b2.HasKey("SwitchConfigTrackPartId", "__synthesizedOrdinal"); + + b2.ToTable("TrackParts"); + + b2.WithOwner() + .HasForeignKey("SwitchConfigTrackPartId"); + }); + + b1.Navigation("EntryRules"); + }); + + b.Navigation("Switch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Source/Trackify.Infrastructure/Persistence/Migrations/20261014193211_InitialCreate.cs b/Source/Trackify.Infrastructure/Persistence/Migrations/20261014193211_InitialCreate.cs new file mode 100644 index 0000000..3885244 --- /dev/null +++ b/Source/Trackify.Infrastructure/Persistence/Migrations/20261014193211_InitialCreate.cs @@ -0,0 +1,142 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Trackify.Infrastructure.Persistence.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "TrackLinks", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + FromPartId = table.Column(type: "TEXT", nullable: false), + FromPort = table.Column(type: "TEXT", nullable: false), + ToPartId = table.Column(type: "TEXT", nullable: false), + ToPort = table.Column(type: "TEXT", nullable: false), + DateCreated = table.Column(type: "INTEGER", nullable: false), + DateUpdated = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TrackLinks", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "TrackParts", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + SpecKey = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + PlanOrder = table.Column(type: "INTEGER", nullable: false), + MaxSpeed = table.Column(type: "INTEGER", nullable: false), + Direction = table.Column(type: "TEXT", nullable: false), + AccelFn = table.Column(type: "TEXT", nullable: false), + BrakeFn = table.Column(type: "TEXT", nullable: false), + Sensor = table.Column(type: "TEXT", nullable: false), + Action = table.Column(type: "TEXT", nullable: false), + SlowTarget = table.Column(type: "INTEGER", nullable: false), + DateCreated = table.Column(type: "INTEGER", nullable: false), + DateUpdated = table.Column(type: "INTEGER", nullable: false), + Switch = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TrackParts", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "TrackSegments", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + Type = table.Column(type: "TEXT", nullable: false), + MaxSpeed = table.Column(type: "INTEGER", nullable: false), + Direction = table.Column(type: "TEXT", nullable: false), + AccelFn = table.Column(type: "TEXT", nullable: false), + BrakeFn = table.Column(type: "TEXT", nullable: false), + Sensor = table.Column(type: "TEXT", nullable: false), + Action = table.Column(type: "TEXT", nullable: false), + SlowTarget = table.Column(type: "INTEGER", nullable: false), + StrandId = table.Column(type: "TEXT", nullable: false), + Order = table.Column(type: "INTEGER", nullable: false), + Curve = table.Column(type: "TEXT", nullable: true), + BranchStrandId = table.Column(type: "TEXT", nullable: true), + BranchDirection = table.Column(type: "TEXT", nullable: true), + Route = table.Column(type: "TEXT", nullable: false), + BranchLinkSegmentId = table.Column(type: "TEXT", nullable: true), + DateCreated = table.Column(type: "INTEGER", nullable: false), + DateUpdated = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TrackSegments", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Trains", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + Hub = table.Column(type: "TEXT", nullable: false), + BleAddress = table.Column(type: "TEXT", nullable: false), + HubId = table.Column(type: "TEXT", nullable: false), + Color = table.Column(type: "TEXT", nullable: false), + PortA = table.Column(type: "TEXT", nullable: false), + PortB = table.Column(type: "TEXT", nullable: false), + Speed = table.Column(type: "INTEGER", nullable: false), + AccelFn = table.Column(type: "TEXT", nullable: false), + AccelExpression = table.Column(type: "TEXT", nullable: false), + BrakeFn = table.Column(type: "TEXT", nullable: false), + BrakeExpression = table.Column(type: "TEXT", nullable: false), + IsActive = table.Column(type: "INTEGER", nullable: false), + DateCreated = table.Column(type: "INTEGER", nullable: false), + DateUpdated = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Trains", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_TrackLinks_FromPartId", + table: "TrackLinks", + column: "FromPartId"); + + migrationBuilder.CreateIndex( + name: "IX_TrackLinks_ToPartId", + table: "TrackLinks", + column: "ToPartId"); + + migrationBuilder.CreateIndex( + name: "IX_TrackParts_PlanOrder", + table: "TrackParts", + column: "PlanOrder"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TrackLinks"); + + migrationBuilder.DropTable( + name: "TrackParts"); + + migrationBuilder.DropTable( + name: "TrackSegments"); + + migrationBuilder.DropTable( + name: "Trains"); + } + } +} diff --git a/Source/Trackify.Infrastructure/Persistence/Migrations/TrackifyDbContextModelSnapshot.cs b/Source/Trackify.Infrastructure/Persistence/Migrations/TrackifyDbContextModelSnapshot.cs new file mode 100644 index 0000000..a02b155 --- /dev/null +++ b/Source/Trackify.Infrastructure/Persistence/Migrations/TrackifyDbContextModelSnapshot.cs @@ -0,0 +1,326 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Trackify.Infrastructure.Persistence; + +#nullable disable + +namespace Trackify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(TrackifyDbContext))] + partial class TrackifyDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + + modelBuilder.Entity("Trackify.Domain.Tracks.TrackLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("INTEGER"); + + b.Property("DateUpdated") + .HasColumnType("INTEGER"); + + b.Property("FromPartId") + .HasColumnType("TEXT"); + + b.Property("FromPort") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ToPartId") + .HasColumnType("TEXT"); + + b.Property("ToPort") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FromPartId"); + + b.HasIndex("ToPartId"); + + b.ToTable("TrackLinks"); + }); + + modelBuilder.Entity("Trackify.Domain.Tracks.TrackPart", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccelFn") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Action") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("BrakeFn") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("INTEGER"); + + b.Property("DateUpdated") + .HasColumnType("INTEGER"); + + b.Property("Direction") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MaxSpeed") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlanOrder") + .HasColumnType("INTEGER"); + + b.Property("Sensor") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SlowTarget") + .HasColumnType("INTEGER"); + + b.Property("SpecKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlanOrder"); + + b.ToTable("TrackParts"); + }); + + modelBuilder.Entity("Trackify.Domain.Trains.TrackSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccelFn") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Action") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("BrakeFn") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("BranchDirection") + .HasColumnType("TEXT"); + + b.Property("BranchLinkSegmentId") + .HasColumnType("TEXT"); + + b.Property("BranchStrandId") + .HasColumnType("TEXT"); + + b.Property("Curve") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("INTEGER"); + + b.Property("DateUpdated") + .HasColumnType("INTEGER"); + + b.Property("Direction") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MaxSpeed") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Route") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Sensor") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SlowTarget") + .HasColumnType("INTEGER"); + + b.Property("StrandId") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("TrackSegments"); + }); + + modelBuilder.Entity("Trackify.Domain.Trains.Train", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccelExpression") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AccelFn") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("BleAddress") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("BrakeExpression") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("BrakeFn") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Color") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("INTEGER"); + + b.Property("DateUpdated") + .HasColumnType("INTEGER"); + + b.Property("Hub") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("HubId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PortA") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PortB") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Speed") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Trains"); + }); + + modelBuilder.Entity("Trackify.Domain.Tracks.TrackPart", b => + { + b.OwnsOne("Trackify.Domain.Tracks.SwitchConfig", "Switch", b1 => + { + b1.Property("TrackPartId") + .HasColumnType("TEXT"); + + b1.Property("DefaultRoute") + .IsRequired() + .HasColumnType("TEXT"); + + b1.Property("MotorHubId") + .HasColumnType("TEXT"); + + b1.Property("MotorPort") + .HasColumnType("TEXT"); + + b1.Property("SensorHubId") + .HasColumnType("TEXT"); + + b1.Property("SensorPort") + .HasColumnType("TEXT"); + + b1.Property("SensorTrigger") + .HasColumnType("TEXT"); + + b1.Property("SensorType") + .IsRequired() + .HasColumnType("TEXT"); + + b1.HasKey("TrackPartId"); + + b1.ToTable("TrackParts"); + + b1.ToJson("Switch"); + + b1.WithOwner() + .HasForeignKey("TrackPartId"); + + b1.OwnsMany("Trackify.Domain.Tracks.SwitchEntryRule", "EntryRules", b2 => + { + b2.Property("SwitchConfigTrackPartId") + .HasColumnType("TEXT"); + + b2.Property("__synthesizedOrdinal") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("INTEGER"); + + b2.Property("FromPort") + .IsRequired() + .HasColumnType("TEXT"); + + b2.Property("SetRoute") + .IsRequired() + .HasColumnType("TEXT"); + + b2.Property("TowardPartId") + .HasColumnType("TEXT"); + + b2.HasKey("SwitchConfigTrackPartId", "__synthesizedOrdinal"); + + b2.ToTable("TrackParts"); + + b2.WithOwner() + .HasForeignKey("SwitchConfigTrackPartId"); + }); + + b1.Navigation("EntryRules"); + }); + + b.Navigation("Switch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Source/Trackify.Infrastructure/Persistence/SqliteTrackPlanRepository.cs b/Source/Trackify.Infrastructure/Persistence/SqliteTrackPlanRepository.cs new file mode 100644 index 0000000..9fcfb88 --- /dev/null +++ b/Source/Trackify.Infrastructure/Persistence/SqliteTrackPlanRepository.cs @@ -0,0 +1,80 @@ +using Microsoft.EntityFrameworkCore; + +namespace Trackify.Infrastructure.Persistence; + +/// +/// Stores the track plan in SQLite. Deliberately not a : a +/// plan is an aggregate, so it is read and written whole, inside one transaction. Half a graph would +/// render as track that falls apart. +/// +public sealed class SqliteTrackPlanRepository : ITrackPlanRepository +{ + private readonly IDbContextFactory _dbContextFactory; + + public SqliteTrackPlanRepository(IDbContextFactory dbContextFactory) + { + _dbContextFactory = dbContextFactory; + + using var database = _dbContextFactory.CreateDbContext(); + database.Database.Migrate(); + } + + public async Task LoadAsync(CancellationToken cancellationToken = default) + { + await using var database = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + var parts = await database.TrackParts + .AsNoTracking() + .OrderBy(part => part.PlanOrder) + .ToListAsync(cancellationToken); + + if (parts.Count == 0) return TrackPlanSnapshot.Empty; + + var links = await database.TrackLinks.AsNoTracking().ToListAsync(cancellationToken); + + return new TrackPlanSnapshot { Parts = parts, Links = links }; + } + + public Task ReplaceAsync(TrackPlanSnapshot snapshot, CancellationToken cancellationToken = default) + => WriteAsync(snapshot, replace: true, cancellationToken); + + public Task AppendAsync(TrackPlanSnapshot snapshot, CancellationToken cancellationToken = default) + => WriteAsync(snapshot, replace: false, cancellationToken); + + private async Task WriteAsync(TrackPlanSnapshot snapshot, bool replace, CancellationToken cancellationToken) + { + await using var database = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + await using var transaction = await database.Database.BeginTransactionAsync(cancellationToken); + + try + { + if (replace) + { + database.TrackLinks.RemoveRange(await database.TrackLinks.ToListAsync(cancellationToken)); + database.TrackParts.RemoveRange(await database.TrackParts.ToListAsync(cancellationToken)); + await database.SaveChangesAsync(cancellationToken); + } + + // Appending has to continue the existing order, or the newcomers would all claim + // positions that are already taken and the layout root would depend on load order. + var offset = 0; + if (!replace && await database.TrackParts.AnyAsync(cancellationToken)) + { + offset = await database.TrackParts.MaxAsync(part => part.PlanOrder, cancellationToken) + 1; + } + + foreach (var part in snapshot.Parts) part.PlanOrder += offset; + + database.TrackParts.AddRange(snapshot.Parts); + database.TrackLinks.AddRange(snapshot.Links); + + await database.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + } +} diff --git a/Source/Trackify.Infrastructure/Persistence/TrackifyDbContext.cs b/Source/Trackify.Infrastructure/Persistence/TrackifyDbContext.cs index 1825ea8..8045ea8 100644 --- a/Source/Trackify.Infrastructure/Persistence/TrackifyDbContext.cs +++ b/Source/Trackify.Infrastructure/Persistence/TrackifyDbContext.cs @@ -3,8 +3,8 @@ namespace Trackify.Infrastructure.Persistence; /// -/// EF Core context for the SQLite train store. The Domain entity stays pure -/// (no EF attributes) — all mapping is fluent here. Enums are stored as readable names, not ints. +/// EF Core context for the SQLite train store. The Domain entities stay pure (no EF attributes) — +/// all mapping is fluent here. Enums are stored as readable names, not ints. /// public sealed class TrackifyDbContext(DbContextOptions options) : DbContext(options) { @@ -12,10 +12,38 @@ public sealed class TrackifyDbContext(DbContextOptions option public DbSet TrackSegments => Set(); + public DbSet TrackParts => Set(); + + public DbSet TrackLinks => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity().HasKey(train => train.Id); modelBuilder.Entity().HasKey(segment => segment.Id); + + modelBuilder.Entity(part => + { + part.HasKey(entity => entity.Id); + part.HasIndex(entity => entity.PlanOrder); + + // A point's configuration is one nested document, not a second table: it is only ever + // read and written together with its part, and a rule list is far clearer as JSON than + // as a join nobody queries. + part.OwnsOne(entity => entity.Switch, config => + { + config.ToJson(); + config.Ignore(switchConfig => switchConfig.IsMotorised); + config.Ignore(switchConfig => switchConfig.HasSensor); + config.OwnsMany(switchConfig => switchConfig.EntryRules); + }); + }); + + modelBuilder.Entity(link => + { + link.HasKey(entity => entity.Id); + link.HasIndex(entity => entity.FromPartId); + link.HasIndex(entity => entity.ToPartId); + }); } protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) diff --git a/Source/Trackify.Infrastructure/Persistence/TrackifyDesignTimeDbContextFactory.cs b/Source/Trackify.Infrastructure/Persistence/TrackifyDesignTimeDbContextFactory.cs new file mode 100644 index 0000000..a7c3b46 --- /dev/null +++ b/Source/Trackify.Infrastructure/Persistence/TrackifyDesignTimeDbContextFactory.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Trackify.Infrastructure.Persistence; + +/// +/// Lets dotnet ef migrations add build the model without a startup project, which a class +/// library otherwise has no way to provide. +/// +/// Design-time only. The connection string below is never opened — generating a migration needs the +/// SQLite provider's conventions, not a database — so the real path still comes from +/// AddTrackifyInfrastructure (and TRACKIFY_STORE) at runtime. +/// +public sealed class TrackifyDesignTimeDbContextFactory : IDesignTimeDbContextFactory +{ + public TrackifyDbContext CreateDbContext(string[] args) + => new(new DbContextOptionsBuilder() + .UseSqlite("Data Source=trackify.design-time.db") + .Options); +} diff --git a/Source/Trackify.Infrastructure/Trackify.Infrastructure.csproj b/Source/Trackify.Infrastructure/Trackify.Infrastructure.csproj index 990613f..0b1e124 100644 --- a/Source/Trackify.Infrastructure/Trackify.Infrastructure.csproj +++ b/Source/Trackify.Infrastructure/Trackify.Infrastructure.csproj @@ -14,6 +14,8 @@ + + diff --git a/Test/Trackify.Tests/Application/TrackPlanExchangeServiceTests.cs b/Test/Trackify.Tests/Application/TrackPlanExchangeServiceTests.cs new file mode 100644 index 0000000..16f20e7 --- /dev/null +++ b/Test/Trackify.Tests/Application/TrackPlanExchangeServiceTests.cs @@ -0,0 +1,325 @@ +using Trackify.Application.TrackPlans; +using Trackify.Domain.Tracks; +using Trackify.Tests.Fakes; + +namespace Trackify.Tests.Application; + +/// +/// The import/export path: build it in the app, carry it in a file, read it in the CLI. A plan file +/// comes from outside, so most of these are about refusing a bad one clearly rather than guessing. +/// +public class TrackPlanExchangeServiceTests +{ + [Fact] + public async Task A_plan_survives_a_round_trip_through_the_file_format() + { + var service = new TrackPlanExchangeService(new FakeTrackPlanRepository()); + + var imported = await service.ImportAsync(OvalJson(), replace: true); + var exported = await service.ExportAsync("Kleines Oval"); + var reimported = await service.ImportAsync(exported, replace: true); + + Assert.True(imported.Succeeded); + Assert.True(reimported.Succeeded); + Assert.Equal(imported.PartCount, reimported.PartCount); + Assert.Equal(imported.ConnectionCount, reimported.ConnectionCount); + Assert.Equal(imported.OpenEndCount, reimported.OpenEndCount); + } + + [Fact] + public async Task A_points_sensor_and_routing_rule_survive_the_round_trip() + { + var repository = new FakeTrackPlanRepository(); + var service = new TrackPlanExchangeService(repository); + + await service.ImportAsync(OvalJson(), replace: true); + var exported = await service.ExportAsync(); + await service.ImportAsync(exported, replace: true); + + var point = Assert.Single(repository.Stored.Parts, part => part.Switch is not null); + var config = point.Switch!; + + Assert.Equal("90:84:2B:00:11:22", config.MotorHubId); + Assert.Equal("A", config.MotorPort); + Assert.Equal(SensorType.Color, config.SensorType); + Assert.Equal(LedColorType.Red, config.SensorTrigger); + Assert.True(config.IsMotorised); + Assert.True(config.HasSensor); + + var rule = Assert.Single(config.EntryRules); + Assert.Equal(TrackPortNames.Toe, rule.FromPort); + Assert.Equal(SwitchRoute.Branch, rule.SetRoute); + } + + [Fact] + public void A_routing_rule_decides_which_way_the_point_is_thrown() + { + var target = Guid.CreateVersion7(); + var config = new SwitchConfig + { + DefaultRoute = SwitchRoute.Main, + EntryRules = + [ + new SwitchEntryRule + { + FromPort = TrackPortNames.Toe, + TowardPartId = target, + SetRoute = SwitchRoute.Branch, + }, + ], + }; + + Assert.Equal(SwitchRoute.Branch, config.ResolveRoute(TrackPortNames.Toe, target)); + Assert.Equal(SwitchRoute.Main, config.ResolveRoute(TrackPortNames.Toe, Guid.CreateVersion7())); + Assert.Equal(SwitchRoute.Main, config.ResolveRoute(TrackPortNames.Main, target)); + } + + [Fact] + public void A_destination_specific_rule_wins_over_a_catch_all_one() + { + var target = Guid.CreateVersion7(); + var config = new SwitchConfig + { + DefaultRoute = SwitchRoute.Main, + EntryRules = + [ + // Catch-all first, so list order alone cannot be what makes the specific rule win. + new SwitchEntryRule { FromPort = TrackPortNames.Toe, SetRoute = SwitchRoute.Main }, + new SwitchEntryRule + { + FromPort = TrackPortNames.Toe, + TowardPartId = target, + SetRoute = SwitchRoute.Branch, + }, + ], + }; + + Assert.Equal(SwitchRoute.Branch, config.ResolveRoute(TrackPortNames.Toe, target)); + Assert.Equal(SwitchRoute.Main, config.ResolveRoute(TrackPortNames.Toe)); + } + + [Theory] + [InlineData("", "empty")] + [InlineData("{ not json", "not valid JSON")] + public void A_file_that_is_not_a_plan_is_refused_with_a_reason(string json, string expectedFragment) + { + var result = new TrackPlanExchangeService(new FakeTrackPlanRepository()).Validate(json); + + Assert.False(result.Succeeded); + Assert.Contains(expectedFragment, string.Join(" ", result.Errors), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void A_json_file_that_is_not_a_track_plan_is_refused() + { + var result = new TrackPlanExchangeService(new FakeTrackPlanRepository()) + .Validate("""{ "format": "something.else", "version": 1 }"""); + + Assert.False(result.Succeeded); + Assert.Contains("Not a track plan", string.Join(" ", result.Errors), StringComparison.Ordinal); + } + + [Fact] + public void A_future_format_version_is_refused_rather_than_read_hopefully() + { + var result = new TrackPlanExchangeService(new FakeTrackPlanRepository()) + .Validate("""{ "format": "trackify.trackplan", "version": 99 }"""); + + Assert.False(result.Succeeded); + Assert.Contains("not supported", string.Join(" ", result.Errors), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void An_unknown_track_part_is_named_in_the_error() + { + // R104 is a community radius, deliberately not in the catalogue — so this must be refused, + // not silently turned into the nearest part LEGO does make. + var result = new TrackPlanExchangeService(new FakeTrackPlanRepository()).Validate( + """ + { + "format": "trackify.trackplan", "version": 1, + "parts": [ { "id": "p1", "part": "curve-r104-right" } ], + "connections": [] + } + """); + + Assert.False(result.Succeeded); + Assert.Contains("curve-r104-right", string.Join(" ", result.Errors), StringComparison.Ordinal); + } + + [Fact] + public void A_port_that_the_part_does_not_have_is_refused() + { + var result = new TrackPlanExchangeService(new FakeTrackPlanRepository()).Validate( + """ + { + "format": "trackify.trackplan", "version": 1, + "parts": [ + { "id": "p1", "part": "straight" }, + { "id": "p2", "part": "straight" } + ], + "connections": [ { "from": "p1:branch", "to": "p2:start" } ] + } + """); + + Assert.False(result.Succeeded); + Assert.Contains("has no port 'branch'", string.Join(" ", result.Errors), StringComparison.Ordinal); + } + + [Fact] + public void A_port_connected_twice_is_refused() + { + var result = new TrackPlanExchangeService(new FakeTrackPlanRepository()).Validate( + """ + { + "format": "trackify.trackplan", "version": 1, + "parts": [ + { "id": "p1", "part": "straight" }, + { "id": "p2", "part": "straight" }, + { "id": "p3", "part": "straight" } + ], + "connections": [ + { "from": "p1:end", "to": "p2:start" }, + { "from": "p1:end", "to": "p3:start" } + ] + } + """); + + Assert.False(result.Succeeded); + Assert.Contains("connected more than once", string.Join(" ", result.Errors), StringComparison.Ordinal); + } + + [Fact] + public void A_malformed_endpoint_is_refused() + { + var result = new TrackPlanExchangeService(new FakeTrackPlanRepository()).Validate( + """ + { + "format": "trackify.trackplan", "version": 1, + "parts": [ { "id": "p1", "part": "straight" } ], + "connections": [ { "from": "p1", "to": "p1:start" } ] + } + """); + + Assert.False(result.Succeeded); + Assert.Contains("'part:port'", string.Join(" ", result.Errors), StringComparison.Ordinal); + } + + [Fact] + public void A_loop_that_does_not_really_close_imports_with_a_warning() + { + // Fifteen curves claimed as a closed circle: importable (you may be mid-build), but the + // planner has to say so rather than store the claim silently. + var result = new TrackPlanExchangeService(new FakeTrackPlanRepository()).Validate(RingJson(15)); + + Assert.True(result.Succeeded); + Assert.NotEmpty(result.Warnings); + Assert.Contains("does not really close", string.Join(" ", result.Warnings), StringComparison.Ordinal); + } + + [Fact] + public void Sixteen_curves_claimed_as_a_circle_import_without_complaint() + { + var result = new TrackPlanExchangeService(new FakeTrackPlanRepository()).Validate(RingJson(16)); + + Assert.True(result.Succeeded); + Assert.Empty(result.Warnings); + Assert.Equal(0, result.OpenEndCount); + } + + [Fact] + public async Task Importing_without_replace_keeps_what_was_already_stored() + { + var repository = new FakeTrackPlanRepository(); + var service = new TrackPlanExchangeService(repository); + + await service.ImportAsync(OvalJson(), replace: true); + await service.ImportAsync(OvalJson(), replace: false); + + Assert.Equal(1, repository.ReplaceCalls); + Assert.Equal(1, repository.AppendCalls); + Assert.Equal(8, repository.Stored.Parts.Count); + + // The appended parts must not fight the originals for the same positions. + Assert.Equal(8, repository.Stored.Parts.Select(part => part.PlanOrder).Distinct().Count()); + } + + [Fact] + public async Task The_summary_counts_what_the_plan_needs() + { + var service = new TrackPlanExchangeService(new FakeTrackPlanRepository()); + + await service.ImportAsync(OvalJson(), replace: true); + var summary = await service.SummariseAsync(); + + Assert.Equal(4, summary.PartCount); + Assert.Equal(3, summary.ConnectionCount); + Assert.Equal(1, summary.SwitchCount); + Assert.Equal(1, summary.MotorisedSwitchCount); + Assert.True(summary.IsBuildable); + Assert.Equal(2, summary.PartsByKey[TrackPartCatalog.CurveRightKey]); + Assert.Equal( + summary.TotalLengthStuds * TrackMetrics.MillimetresPerStud / 1000, + summary.TotalLengthMetres, + 6); + } + + [Fact] + public async Task An_empty_store_summarises_as_empty_rather_than_failing() + { + var summary = await new TrackPlanExchangeService(new FakeTrackPlanRepository()).SummariseAsync(); + + Assert.Equal(0, summary.PartCount); + Assert.True(summary.IsBuildable); + } + + // A straight, two right curves and a wired-up left point — the same file the CLI run used. + private static string OvalJson() => + """ + { + "format": "trackify.trackplan", + "version": 1, + "units": "stud", + "name": "Kleines Oval", + "parts": [ + { "id": "p1", "part": "straight", "name": "Gerade 1" }, + { "id": "p2", "part": "curve-r40-right", "name": "Kurve rechts 1" }, + { "id": "p3", "part": "curve-r40-right", "name": "Kurve rechts 2" }, + { "id": "p4", "part": "switch-left", "name": "Weiche 1", + "switch": { + "defaultRoute": "Main", + "motorHubId": "90:84:2B:00:11:22", + "motorPort": "A", + "sensorType": "Color", + "sensorHubId": "90:84:2B:00:11:22", + "sensorPort": "C", + "sensorTrigger": "Red", + "entryRules": [ { "fromPort": "toe", "towardPart": "p1", "setRoute": "Branch" } ] + } } + ], + "connections": [ + { "from": "p1:end", "to": "p2:start" }, + { "from": "p2:end", "to": "p3:start" }, + { "from": "p3:end", "to": "p4:toe" } + ] + } + """; + + // `count` right curves wired into a ring, whether or not that many actually close one. + private static string RingJson(int count) + { + var parts = Enumerable.Range(1, count) + .Select(index => $"{{ \"id\": \"p{index}\", \"part\": \"curve-r40-right\" }}"); + var connections = Enumerable.Range(1, count) + .Select(index => $"{{ \"from\": \"p{index}:end\", \"to\": \"p{(index % count) + 1}:start\" }}"); + + return $$""" + { + "format": "trackify.trackplan", + "version": 1, + "parts": [ {{string.Join(", ", parts)}} ], + "connections": [ {{string.Join(", ", connections)}} ] + } + """; + } +} diff --git a/Test/Trackify.Tests/Architecture/LayerTrainDependencyTests.cs b/Test/Trackify.Tests/Architecture/LayerTrainDependencyTests.cs index 8b9a5c6..3df4191 100644 --- a/Test/Trackify.Tests/Architecture/LayerTrainDependencyTests.cs +++ b/Test/Trackify.Tests/Architecture/LayerTrainDependencyTests.cs @@ -26,6 +26,11 @@ public class LayerTrainDependencyTests // The Domain ENTITY namespace specifically (not the enums, which are shared value types). private const string DomainEntities = "Trackify.Domain.Trains"; + // The track-plan half of the same boundary. A front-end works with TrackPlanDocument and the + // summary/result DTOs; the geometry types and the catalogue stay behind the use-case layer, so a + // convenience like "just read TrackMetrics to print metres" cannot quietly creep in. + private const string DomainTracks = "Trackify.Domain.Tracks"; + // Frameworks that belong to the outer layers only. private const string EntityFramework = "Microsoft.EntityFrameworkCore"; private const string SharpBrick = "SharpBrick"; @@ -57,7 +62,7 @@ public void Infrastructure_does_not_depend_on_frontends() [Fact] public void Cli_never_touches_the_domain_entity_namespace() - => AssertNoDependency(CliAssembly, DomainEntities); + => AssertNoDependency(CliAssembly, DomainEntities, DomainTracks); private static void AssertNoDependency(Assembly assembly, params string[] forbiddenNamespaces) { diff --git a/Test/Trackify.Tests/Fakes/FakeTrackPlanRepository.cs b/Test/Trackify.Tests/Fakes/FakeTrackPlanRepository.cs new file mode 100644 index 0000000..9ff96ec --- /dev/null +++ b/Test/Trackify.Tests/Fakes/FakeTrackPlanRepository.cs @@ -0,0 +1,43 @@ +using Trackify.Application.TrackPlans; +using Trackify.Domain.Tracks; + +namespace Trackify.Tests.Fakes; + +/// In-memory stand-in for the plan store, so the exchange service can be tested without EF. +public sealed class FakeTrackPlanRepository : ITrackPlanRepository +{ + public TrackPlanSnapshot Stored { get; private set; } = TrackPlanSnapshot.Empty; + + public int ReplaceCalls { get; private set; } + + public int AppendCalls { get; private set; } + + public Task LoadAsync(CancellationToken cancellationToken = default) + => Task.FromResult(Stored); + + public Task ReplaceAsync(TrackPlanSnapshot snapshot, CancellationToken cancellationToken = default) + { + ReplaceCalls++; + Stored = snapshot; + + return Task.CompletedTask; + } + + public Task AppendAsync(TrackPlanSnapshot snapshot, CancellationToken cancellationToken = default) + { + AppendCalls++; + + // Mirrors what the SQLite repository does: appended parts continue the existing order + // instead of fighting the originals for the same positions. + var offset = Stored.Parts.Count == 0 ? 0 : Stored.Parts.Max(part => part.PlanOrder) + 1; + foreach (var part in snapshot.Parts) part.PlanOrder += offset; + + Stored = new TrackPlanSnapshot + { + Parts = [.. Stored.Parts, .. snapshot.Parts], + Links = [.. Stored.Links, .. snapshot.Links], + }; + + return Task.CompletedTask; + } +} From e3df738e70c22b865819126708ee17876ab9c5dd Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 15 Oct 2026 14:30:47 +0200 Subject: [PATCH 05/15] Make the Streckenplaner a drawing surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit You pull from a glowing dot and real LEGO track follows your finger. That is the whole mechanic, and building needs no properties at all: speed, direction and sensor are edited afterwards by tapping a piece. Nothing free-form is ever stored. A gesture becomes a stroke in studs, TrackSnapper turns the stroke into whole catalogue parts, and the drawing comes back out of walking the graph -- so everything on screen is buildable out of real track by construction. Drag near another open end and TrackLoopCloser searches for the parts that genuinely reach it rather than bending anything to fit. The editor is no longer a chain of segments with a properties table beside it. Strands are gone: a plan is a graph, so a part has ports, not a position in a list, which is why reorder and duplicate went with them. Points and crossings are placed deliberately at the aimed-at open end, because the snapper may only ever lay straights and curves -- a gesture must not guess a turnout. Undo/redo covers building; property edits stay off the stack, since one tap on "Zurück" undoing a slider nobody thought of as a step is worse than no undo. Within the house rules: a new DrawGestureBehavior turns pointer press/ drag/release into commands with a point, so drawing adds no code-behind; it sits on the Canvas itself, so points arrive in canvas coordinates and the zoom (still a bound Viewbox size) never has to be undone by hand. TrackPlanFitter computes the view from the committed parts only, so the plan does not rescale under the finger while a preview grows past its edge. Found while verifying, and fixed here: - Uno does not implement Shape.StrokeLineJoin, so it is gone from the XAML. - A database created by the old EnsureCreated() has no __EFMigrationsHistory, so Migrate() died with "table already exists" -- which says nothing about what to do, against a file holding the user's saved trains. TrackifySchema now detects it and says to move the file aside; deleting someone's data is not ours to decide. - That message arrived wrapped in "Could not resolve type ..." because commands are built by DI, so the CLI's handler now reports the innermost cause. - SecondViewModel is started with a fire-and-forget load, where a store failure would vanish as an unobserved task exception and leave an editor that silently has no plan. Load and save now surface it on the canvas. Verified: all four buildable heads green, 109 tests green, the desktop head launches (exit 0), and `track import` still round-trips against a fresh store. The legacy-database path was exercised directly and reports the intended message. arc42 §05 updated, ADR-17 (graph, not coordinates) and ADR-18 (migrations) recorded. Co-Authored-By: Claude Opus 5 --- Source/Trackify.Cli/Program.cs | 8 +- .../Persistence/BaseRepository.cs | 2 +- ... 20261015122519_InitialCreate.Designer.cs} | 2 +- ...ate.cs => 20261015122519_InitialCreate.cs} | 0 .../Persistence/SqliteTrackPlanRepository.cs | 2 +- .../Persistence/TrackifySchema.cs | 55 ++ Source/Trackify/GlobalUsings.cs | 2 + Source/Trackify/Helpers/FittedAnchor.cs | 8 + Source/Trackify/Helpers/FittedPiece.cs | 19 + Source/Trackify/Helpers/FittedPlan.cs | 47 + Source/Trackify/Helpers/TrackGeometry.cs | 301 ------- Source/Trackify/Helpers/TrackLayout.cs | 8 - Source/Trackify/Helpers/TrackPlanFitter.cs | 249 ++++++ Source/Trackify/Models/Trains/TrackAnchor.cs | 35 + Source/Trackify/Models/Trains/TrackPiece.cs | 137 +++ Source/Trackify/Models/Trains/TrackSegment.cs | 204 ----- .../Behaviors/DrawGestureBehavior.cs | 126 +++ .../Presentation/Components/PieceList.xaml | 63 ++ .../Presentation/Components/PieceList.xaml.cs | 9 + .../Components/SegmentInspector.xaml | 135 +-- .../Presentation/Components/StrandList.xaml | 86 -- .../Components/StrandList.xaml.cs | 9 - .../Presentation/Components/TrackCanvas.xaml | 460 +++++----- .../Presentation/Pages/SecondPage.xaml | 12 +- .../Presentation/Pages/SecondPage.xaml.cs | 4 +- .../ViewModels/SecondViewModel.cs | 841 +++++++++++++----- .../Presentation/ViewModels/StrandGroup.cs | 20 - docs/arc42/05-building-block-view.md | 8 +- docs/arc42/09-architecture-decisions.md | 60 ++ 29 files changed, 1717 insertions(+), 1195 deletions(-) rename Source/Trackify.Infrastructure/Persistence/Migrations/{20261014193211_InitialCreate.Designer.cs => 20261015122519_InitialCreate.Designer.cs} (99%) rename Source/Trackify.Infrastructure/Persistence/Migrations/{20261014193211_InitialCreate.cs => 20261015122519_InitialCreate.cs} (100%) create mode 100644 Source/Trackify.Infrastructure/Persistence/TrackifySchema.cs create mode 100644 Source/Trackify/Helpers/FittedAnchor.cs create mode 100644 Source/Trackify/Helpers/FittedPiece.cs create mode 100644 Source/Trackify/Helpers/FittedPlan.cs delete mode 100644 Source/Trackify/Helpers/TrackGeometry.cs delete mode 100644 Source/Trackify/Helpers/TrackLayout.cs create mode 100644 Source/Trackify/Helpers/TrackPlanFitter.cs create mode 100644 Source/Trackify/Models/Trains/TrackAnchor.cs create mode 100644 Source/Trackify/Models/Trains/TrackPiece.cs delete mode 100644 Source/Trackify/Models/Trains/TrackSegment.cs create mode 100644 Source/Trackify/Presentation/Behaviors/DrawGestureBehavior.cs create mode 100644 Source/Trackify/Presentation/Components/PieceList.xaml create mode 100644 Source/Trackify/Presentation/Components/PieceList.xaml.cs delete mode 100644 Source/Trackify/Presentation/Components/StrandList.xaml delete mode 100644 Source/Trackify/Presentation/Components/StrandList.xaml.cs delete mode 100644 Source/Trackify/Presentation/ViewModels/StrandGroup.cs diff --git a/Source/Trackify.Cli/Program.cs b/Source/Trackify.Cli/Program.cs index 141de0e..2ba25b5 100644 --- a/Source/Trackify.Cli/Program.cs +++ b/Source/Trackify.Cli/Program.cs @@ -52,7 +52,13 @@ config.SetExceptionHandler((ex, _) => { Log.Unhandled(hostLogger, ex); - AnsiConsole.MarkupLineInterpolated($"[red]✗ Error:[/] {ex.Message}"); + + // Report the innermost message: commands are built by DI, so a failure in a repository's + // constructor arrives wrapped in "Could not resolve type ...", which tells the user nothing. + var cause = ex; + while (cause.InnerException is { } inner) cause = inner; + + AnsiConsole.MarkupLineInterpolated($"[red]✗ Error:[/] {cause.Message}"); return 1; }); diff --git a/Source/Trackify.Infrastructure/Persistence/BaseRepository.cs b/Source/Trackify.Infrastructure/Persistence/BaseRepository.cs index c00c82f..6208109 100644 --- a/Source/Trackify.Infrastructure/Persistence/BaseRepository.cs +++ b/Source/Trackify.Infrastructure/Persistence/BaseRepository.cs @@ -26,7 +26,7 @@ protected BaseRepository(IDbContextFactory dbContextFactory) DbContextFactory = dbContextFactory; using var database = DbContextFactory.CreateDbContext(); - database.Database.Migrate(); + TrackifySchema.BringUpToDate(database); } public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) diff --git a/Source/Trackify.Infrastructure/Persistence/Migrations/20261014193211_InitialCreate.Designer.cs b/Source/Trackify.Infrastructure/Persistence/Migrations/20261015122519_InitialCreate.Designer.cs similarity index 99% rename from Source/Trackify.Infrastructure/Persistence/Migrations/20261014193211_InitialCreate.Designer.cs rename to Source/Trackify.Infrastructure/Persistence/Migrations/20261015122519_InitialCreate.Designer.cs index 1bfcfeb..4077d5d 100644 --- a/Source/Trackify.Infrastructure/Persistence/Migrations/20261014193211_InitialCreate.Designer.cs +++ b/Source/Trackify.Infrastructure/Persistence/Migrations/20261015122519_InitialCreate.Designer.cs @@ -11,7 +11,7 @@ namespace Trackify.Infrastructure.Persistence.Migrations { [DbContext(typeof(TrackifyDbContext))] - [Migration("20261014193211_InitialCreate")] + [Migration("20261015122519_InitialCreate")] partial class InitialCreate { /// diff --git a/Source/Trackify.Infrastructure/Persistence/Migrations/20261014193211_InitialCreate.cs b/Source/Trackify.Infrastructure/Persistence/Migrations/20261015122519_InitialCreate.cs similarity index 100% rename from Source/Trackify.Infrastructure/Persistence/Migrations/20261014193211_InitialCreate.cs rename to Source/Trackify.Infrastructure/Persistence/Migrations/20261015122519_InitialCreate.cs diff --git a/Source/Trackify.Infrastructure/Persistence/SqliteTrackPlanRepository.cs b/Source/Trackify.Infrastructure/Persistence/SqliteTrackPlanRepository.cs index 9fcfb88..424c558 100644 --- a/Source/Trackify.Infrastructure/Persistence/SqliteTrackPlanRepository.cs +++ b/Source/Trackify.Infrastructure/Persistence/SqliteTrackPlanRepository.cs @@ -16,7 +16,7 @@ public SqliteTrackPlanRepository(IDbContextFactory dbContextF _dbContextFactory = dbContextFactory; using var database = _dbContextFactory.CreateDbContext(); - database.Database.Migrate(); + TrackifySchema.BringUpToDate(database); } public async Task LoadAsync(CancellationToken cancellationToken = default) diff --git a/Source/Trackify.Infrastructure/Persistence/TrackifySchema.cs b/Source/Trackify.Infrastructure/Persistence/TrackifySchema.cs new file mode 100644 index 0000000..3b08191 --- /dev/null +++ b/Source/Trackify.Infrastructure/Persistence/TrackifySchema.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Trackify.Infrastructure.Persistence; + +/// +/// Brings the store up to the latest migration, and refuses to do so in a way that would look like a +/// bug. +/// +/// Trackify used EnsureCreated() before migrations existed (arc42 ADR-06 → ADR-18). A database +/// from that era has the tables but no __EFMigrationsHistory, so plain Migrate() tries +/// to create Trains a second time and dies with "table already exists" — which says nothing +/// about what to do. That file holds the user's saved trains, so deleting it automatically is not +/// ours to decide; this says plainly what it is and what to do instead. +/// +internal static class TrackifySchema +{ + /// Migrates , or throws an actionable error for a pre-migration store. + public static void BringUpToDate(DbContext database) + { + var store = database.Database; + if (!store.IsRelational()) return; + + if (store.GetAppliedMigrations().Any()) + { + store.Migrate(); + return; + } + + // Nothing recorded: either a brand-new database, which migrates cleanly, or a pre-migration + // one, which cannot. + if (WasCreatedBeforeMigrations(store)) + { + throw new InvalidOperationException( + $"The Trackify store at '{store.GetDbConnection().DataSource}' predates EF Core " + + "migrations: it has the tables but no __EFMigrationsHistory, so it cannot be " + + "migrated in place. Move the file aside — it still holds your saved trains — and " + + "start again; a fresh store is created automatically. See arc42 ADR-18."); + } + + store.Migrate(); + } + + // SQLite-specific on purpose: it is the only provider this store runs on, and the check has to + // look at the schema catalogue rather than the EF model. + private static bool WasCreatedBeforeMigrations(DatabaseFacade store) + { + if (store.ProviderName?.Contains("Sqlite", StringComparison.OrdinalIgnoreCase) != true) return false; + + return store + .SqlQueryRaw("SELECT COUNT(*) AS Value FROM sqlite_master WHERE type = 'table' AND name = 'Trains'") + .AsEnumerable() + .FirstOrDefault() > 0; + } +} diff --git a/Source/Trackify/GlobalUsings.cs b/Source/Trackify/GlobalUsings.cs index 7fc72eb..2640d50 100644 --- a/Source/Trackify/GlobalUsings.cs +++ b/Source/Trackify/GlobalUsings.cs @@ -8,10 +8,12 @@ global using Trackify.Models; global using Trackify.Domain; global using Trackify.Domain.Enums; +global using Trackify.Domain.Tracks; global using Trackify.Domain.Trains; global using Trackify.Application.Lego; global using Trackify.Application.Catalog; global using Trackify.Application.Services; +global using Trackify.Application.TrackPlans; global using Trackify.Application.Trains; global using Trackify.Presentation.Pages; global using Trackify.Presentation.ViewModels; diff --git a/Source/Trackify/Helpers/FittedAnchor.cs b/Source/Trackify/Helpers/FittedAnchor.cs new file mode 100644 index 0000000..a566807 --- /dev/null +++ b/Source/Trackify/Helpers/FittedAnchor.cs @@ -0,0 +1,8 @@ +namespace Trackify.Helpers; + +/// +/// An open end as the canvas shows it — the big tappable dot a drag starts from. Carries the part +/// and port it belongs to so a gesture can be handed straight back to the geometry layer. +/// +[ImplicitKeys(IsEnabled = false)] +public readonly record struct FittedAnchor(Guid PartId, string PortName, double X, double Y); diff --git a/Source/Trackify/Helpers/FittedPiece.cs b/Source/Trackify/Helpers/FittedPiece.cs new file mode 100644 index 0000000..59e6080 --- /dev/null +++ b/Source/Trackify/Helpers/FittedPiece.cs @@ -0,0 +1,19 @@ +namespace Trackify.Helpers; + +/// +/// One laid part, ready to draw: its rail as canvas path data plus the anchor points the labels and +/// sensor markers hang off. Everything is a plain pre-computed coordinate, because a +/// {Binding} inside a RenderTransform has no DataContext to inherit on every target. +/// +[ImplicitKeys(IsEnabled = false)] +public readonly record struct FittedPiece( + Guid PartId, + string PathData, + string HitPathData, + double MidX, + double MidY, + double OutwardX, + double OutwardY, + double TanX, + double TanY, + bool IsPreview); diff --git a/Source/Trackify/Helpers/FittedPlan.cs b/Source/Trackify/Helpers/FittedPlan.cs new file mode 100644 index 0000000..bd5320b --- /dev/null +++ b/Source/Trackify/Helpers/FittedPlan.cs @@ -0,0 +1,47 @@ +namespace Trackify.Helpers; + +/// A whole plan laid out on the canvas: the pieces, the open ends, and the ballast behind them. +public sealed record FittedPlan +{ + /// Nothing laid yet. + public static FittedPlan Empty { get; } = new() + { + Pieces = [], + Anchors = [], + BedPathData = "", + PreviewPathData = "", + StudsPerPixel = 1, + }; + + /// Every part, committed ones and the ghost preview alike. + public required IReadOnlyList Pieces { get; init; } + + /// The open ends, as tappable dots. + public required IReadOnlyList Anchors { get; init; } + + /// All committed rail as one path, drawn thick underneath as ballast. + public required string BedPathData { get; init; } + + /// The ghost preview as one path, drawn on top while a drag is in flight. + public required string PreviewPathData { get; init; } + + /// + /// How many studs one canvas pixel is worth. Part of turning a drag in canvas space back into + /// the studs the geometry layer works in — see . + /// + public required double StudsPerPixel { get; init; } + + /// Canvas X the plan's world origin landed on. + public double OriginX { get; init; } + + /// Canvas Y the plan's world origin landed on. + public double OriginY { get; init; } + + /// + /// A canvas point back in world studs — the inverse of the fit. A gesture arrives in canvas + /// coordinates, but the snapper only ever reasons in studs, so this is the one place the two + /// spaces meet. + /// + public StudPoint ToStuds(double canvasX, double canvasY) + => new((canvasX - OriginX) * StudsPerPixel, (canvasY - OriginY) * StudsPerPixel); +} diff --git a/Source/Trackify/Helpers/TrackGeometry.cs b/Source/Trackify/Helpers/TrackGeometry.cs deleted file mode 100644 index ed841c0..0000000 --- a/Source/Trackify/Helpers/TrackGeometry.cs +++ /dev/null @@ -1,301 +0,0 @@ -namespace Trackify.Helpers; - -/// -/// Lays out an arbitrary, user-built track (straights/curves/stations/switches, branching at -/// switches into their own strands) on the shared 900x600 canvas — turtle graphics: walk each -/// strand's pieces in order from an anchor pose (position + heading), each piece advancing the pose. -/// A branch strand's anchor is its switch's end pose, rotated by the branch's peel-off direction, so -/// switches never need geometry of their own beyond a short stub. -/// Two passes, because a plan grows wherever the user builds and how far it reaches is only known -/// once every strand is walked: the walk collects each piece's shape in unbounded world coordinates -/// from a (0,0) anchor, then the emit pass bakes in the scale/offset that centers the finished plan -/// on the canvas — so nothing can walk off the visible area, and everything the canvas needs is a -/// plain, ready-to-draw coordinate (no data-bound render transforms, which don't inherit a -/// DataContext on every platform). -/// Replaces the previous closed-form fixed 8-segment stadium loop now that the Streckenplaner has -/// real CRUD instead of one hard-coded layout. -/// -public static class TrackGeometry -{ - private const double StraightLength = 90; - private const double StationLength = 90; - private const double SwitchLength = 55; - private const double CurveRadius = 70; - private const double Trim = 6; - private const double CanvasWidth = 900; - private const double CanvasHeight = 600; - - /// Kept free on every side of the fitted track, so the ballast stroke and the label/sensor - /// markers hanging off the centerline stay on the canvas — see TrackSegment.ApplyGeometry - /// for the outward offsets this has to cover. - private const double Margin = 70; - - /// Vertical spacing between strands parked below the plan by the orphan pass. - private const double OrphanLaneGap = 170; - - /// Points sampled along an arc to bound it — enough that the fit never clips a curve's - /// bulge (a quarter of a 70-radius circle deviates well under a pixel between samples). - private const int ArcSamples = 8; - - private static readonly double QuarterTurn = Math.PI / 2; - private static readonly double BranchPeel = Math.PI / 6; - - public static TrackLayout Build(IReadOnlyList segments) - { - var pieces = Walk(segments); - var view = Fit(pieces); - var result = new Dictionary(pieces.Count); - - foreach (var piece in pieces) result[piece.Id] = Emit(piece, view); - - return new TrackLayout( - result, - // The ballast layer behind the colored track — every piece's own path, as separate subpaths - // (each starting with its own "M") within one Data string, drawn with a thick stroke. - string.Join(" ", result.Values.Select(g => g.PathData))); - } - - private static List Walk(IReadOnlyList segments) - { - var byStrand = segments.GroupBy(s => s.StrandId).ToDictionary(g => g.Key, g => g.OrderBy(s => s.Order).ToList()); - var pieces = new List(segments.Count); - var anchors = new Dictionary { [Guid.Empty] = new(0, 0, 0) }; - var pending = new Queue([Guid.Empty]); - var visited = new HashSet(); - var lane = 0; - - while (true) - { - while (pending.Count > 0) - { - var strandId = pending.Dequeue(); - if (!visited.Add(strandId) || !byStrand.TryGetValue(strandId, out var strand)) continue; - - var pose = anchors[strandId]; - foreach (var segment in strand) - { - var piece = segment.Type == SegmentType.Curve - ? ArcPiece(segment.Id, pose, segment.Curve == CurveDirection.Left ? -1.0 : 1.0) - : LinePiece(segment.Id, pose, LengthOf(segment.Type)); - - pieces.Add(piece); - pose = piece.End; - - if (segment.Type == SegmentType.Switch && segment.BranchStrandId is { } branchId) - { - var peelSign = segment.BranchDirection == CurveDirection.Left ? -1.0 : 1.0; - anchors[branchId] = pose with { Heading = pose.Heading + (peelSign * BranchPeel) }; - pending.Enqueue(branchId); - } - } - } - - // Orphan pass — a strand nothing branches into (a branch whose Weiche is missing, e.g. from a - // plan saved mid-write) is unreachable from the main anchor. Without this it would get no - // geometry at all and keep rendering wherever the previous layout happened to leave it, so it - // is parked on its own lane below the plan: visible, and therefore deletable. - // Guid.Empty is always visited by the first inner pass, so it doubles as "nothing left". - var orphanStrandId = byStrand.Keys.FirstOrDefault(id => !visited.Contains(id)); - if (orphanStrandId == Guid.Empty) return pieces; - - anchors[orphanStrandId] = new Pose(0, ++lane * OrphanLaneGap, 0); - pending.Enqueue(orphanStrandId); - } - } - - /// Centers the plan's bounding box on the canvas, shrinking it (never enlarging) when it - /// would otherwise reach into the margin reserved for the labels and sensor markers. - private static View Fit(List pieces) - { - if (pieces.Count == 0) return new View(1, CanvasWidth / 2, CanvasHeight / 2); - - var extent = pieces.Select(p => p.Extent).Aggregate((a, b) => a.Union(b)); - var scale = Math.Min(1, Math.Min( - (CanvasWidth - (2 * Margin)) / Math.Max(extent.MaxX - extent.MinX, 1), - (CanvasHeight - (2 * Margin)) / Math.Max(extent.MaxY - extent.MinY, 1))); - - return new View( - scale, - (CanvasWidth / 2) - (scale * (extent.MinX + extent.MaxX) / 2), - (CanvasHeight / 2) - (scale * (extent.MinY + extent.MaxY) / 2)); - } - - private static SegmentGeometry Emit(Piece piece, View view) - { - var tanX = Math.Cos(piece.MidHeading); - var tanY = Math.Sin(piece.MidHeading); - var (pathData, hitPathData) = piece.Arc is { } arc ? ArcPaths(arc, view) : LinePaths(piece.Line, view); - - return new SegmentGeometry( - piece.Id, pathData, hitPathData, - MidX: view.X(piece.MidX), MidY: view.Y(piece.MidY), - // The left-hand normal of the travel direction — for straights *and* curves, so labels and - // sensor markers stay on the same side of the track throughout. (Deriving a curve's normal - // from "away from the arc center" instead flips it between left and right curves.) - OutwardX: tanY, OutwardY: -tanX, - TanX: tanX, TanY: tanY); - } - - private static double LengthOf(SegmentType type) => type switch - { - SegmentType.Station => StationLength, - SegmentType.Switch => SwitchLength, - _ => StraightLength, - }; - - private static Piece LinePiece(Guid id, Pose start, double length) - { - var end = new Pose( - start.X + (Math.Cos(start.Heading) * length), - start.Y + (Math.Sin(start.Heading) * length), - start.Heading); - - return new Piece( - id, end, - MidX: (start.X + end.X) / 2, MidY: (start.Y + end.Y) / 2, MidHeading: start.Heading, - Extent: Extent.Of([(start.X, start.Y), (end.X, end.Y)]), - Line: new LineShape(start.X, start.Y, end.X, end.Y), - Arc: null); - } - - private static Piece ArcPiece(Guid id, Pose start, double sign) - { - var cx = start.X + (CurveRadius * Math.Cos(start.Heading + (sign * QuarterTurn))); - var cy = start.Y + (CurveRadius * Math.Sin(start.Heading + (sign * QuarterTurn))); - var startAngle = Math.Atan2(start.Y - cy, start.X - cx); - var endAngle = startAngle + (sign * QuarterTurn); - var midAngle = startAngle + (sign * QuarterTurn / 2); - var end = new Pose( - cx + (CurveRadius * Math.Cos(endAngle)), - cy + (CurveRadius * Math.Sin(endAngle)), - start.Heading + (sign * QuarterTurn)); - - return new Piece( - id, end, - MidX: cx + (CurveRadius * Math.Cos(midAngle)), MidY: cy + (CurveRadius * Math.Sin(midAngle)), - MidHeading: start.Heading + (sign * QuarterTurn / 2), - Extent: ArcExtent(cx, cy, startAngle, endAngle), - Line: null, - Arc: new ArcShape(cx, cy, startAngle, endAngle, sign)); - } - - // Drawn path and (untrimmed, wider-stroked) hit path. The trim gap that separates neighboring - // pieces is applied after fitting, so it stays the same few pixels at any scale. - private static (string PathData, string HitPathData) LinePaths(LineShape? shape, View view) - { - var line = shape!.Value; - var (x0, y0) = (view.X(line.X0), view.Y(line.Y0)); - var (x1, y1) = (view.X(line.X1), view.Y(line.Y1)); - - return (LinePath(x0, y0, x1, y1, Trim), LinePath(x0, y0, x1, y1, 0)); - } - - private static (string PathData, string HitPathData) ArcPaths(ArcShape arc, View view) - { - var (cx, cy) = (view.X(arc.Cx), view.Y(arc.Cy)); - var radius = CurveRadius * view.Scale; - var trimAngle = Trim / radius; - - return ( - ArcPath(cx, cy, radius, arc.StartAngle + (arc.Sign * trimAngle), arc.EndAngle - (arc.Sign * trimAngle), arc.Sign), - ArcPath(cx, cy, radius, arc.StartAngle, arc.EndAngle, arc.Sign)); - } - - private static string LinePath(double x0, double y0, double x1, double y1, double trim) - { - if (trim > 0) - { - var len = Math.Sqrt(((x1 - x0) * (x1 - x0)) + ((y1 - y0) * (y1 - y0))); - if (len > trim * 2) - { - var t = trim / len; - (x0, y0, x1, y1) = (x0 + ((x1 - x0) * t), y0 + ((y1 - y0) * t), x1 - ((x1 - x0) * t), y1 - ((y1 - y0) * t)); - } - } - return FormattableString.Invariant($"M {x0:0.0} {y0:0.0} L {x1:0.0} {y1:0.0}"); - } - - private static string ArcPath(double cx, double cy, double radius, double startAngle, double endAngle, double sign) - { - var sx = cx + (radius * Math.Cos(startAngle)); - var sy = cy + (radius * Math.Sin(startAngle)); - var ex = cx + (radius * Math.Cos(endAngle)); - var ey = cy + (radius * Math.Sin(endAngle)); - var sweep = sign > 0 ? 1 : 0; - return FormattableString.Invariant( - $"M {sx:0.0} {sy:0.0} A {radius:0.0},{radius:0.0} 0 0,{sweep} {ex:0.0} {ey:0.0}"); - } - - private static Extent ArcExtent(double cx, double cy, double startAngle, double endAngle) - { - var points = new (double X, double Y)[ArcSamples + 1]; - for (var i = 0; i <= ArcSamples; i++) - { - var angle = startAngle + ((endAngle - startAngle) * i / ArcSamples); - points[i] = (cx + (CurveRadius * Math.Cos(angle)), cy + (CurveRadius * Math.Sin(angle))); - } - - return Extent.Of(points); - } - - private readonly record struct Pose(double X, double Y, double Heading); - - /// One walked piece in world coordinates: where it leaves the pose, where its markers go, - /// what it covers, and the shape to emit — exactly one of /. - [ImplicitKeys(IsEnabled = false)] - private readonly record struct Piece( - Guid Id, - Pose End, - double MidX, - double MidY, - double MidHeading, - Extent Extent, - LineShape? Line, - ArcShape? Arc); - - private readonly record struct LineShape(double X0, double Y0, double X1, double Y1); - - private readonly record struct ArcShape(double Cx, double Cy, double StartAngle, double EndAngle, double Sign); - - /// World-to-canvas transform from the fit pass. - private readonly record struct View(double Scale, double OffsetX, double OffsetY) - { - public double X(double worldX) => (worldX * Scale) + OffsetX; - - public double Y(double worldY) => (worldY * Scale) + OffsetY; - } - - private readonly record struct Extent(double MinX, double MinY, double MaxX, double MaxY) - { - public static Extent Of(IReadOnlyList<(double X, double Y)> points) => new( - points.Min(p => p.X), points.Min(p => p.Y), points.Max(p => p.X), points.Max(p => p.Y)); - - public Extent Union(Extent other) => new( - Math.Min(MinX, other.MinX), Math.Min(MinY, other.MinY), - Math.Max(MaxX, other.MaxX), Math.Max(MaxY, other.MaxY)); - } -} - -/// The subset of a track segment's fields needs to lay it out — -/// decoupled from the presentation model so the geometry engine has no upward dependency. -[ImplicitKeys(IsEnabled = false)] -public readonly record struct TrackGeometryInput( - Guid Id, - SegmentType Type, - CurveDirection? Curve, - Guid StrandId, - int Order, - Guid? BranchStrandId, - CurveDirection? BranchDirection); - -[ImplicitKeys(IsEnabled = false)] -public readonly record struct SegmentGeometry( - Guid Id, - string PathData, - string HitPathData, - double MidX, - double MidY, - double OutwardX, - double OutwardY, - double TanX, - double TanY); diff --git a/Source/Trackify/Helpers/TrackLayout.cs b/Source/Trackify/Helpers/TrackLayout.cs deleted file mode 100644 index 508d438..0000000 --- a/Source/Trackify/Helpers/TrackLayout.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Trackify.Helpers; - -/// A finished layout pass: every segment's geometry, already fitted to the canvas, plus the -/// ballast path drawn behind them all. -[ImplicitKeys(IsEnabled = false)] -public readonly record struct TrackLayout( - IReadOnlyDictionary Segments, - string BedPathData); diff --git a/Source/Trackify/Helpers/TrackPlanFitter.cs b/Source/Trackify/Helpers/TrackPlanFitter.cs new file mode 100644 index 0000000..164ba19 --- /dev/null +++ b/Source/Trackify/Helpers/TrackPlanFitter.cs @@ -0,0 +1,249 @@ +namespace Trackify.Helpers; + +/// +/// Turns a walked plan (world studs) into something the canvas can draw: path data per part, the +/// open-end dots, and the ballast underneath. +/// +/// Two deliberate properties: +/// +/// Coordinates are baked in here, not applied as a RenderTransform — a +/// {Binding} inside a transform has no DataContext to inherit on every Uno target. +/// The view is computed from the committed parts only, so the plan does not shift and +/// rescale under the user's finger while a ghost preview grows past its edge. +/// +/// Replaces the old TrackGeometry, which walked a chain of segments with invented lengths; the +/// walking now happens in the Domain, over real parts, and only the fitting is left here. +/// +public static class TrackPlanFitter +{ + /// The fixed coordinate space the canvas draws in; zoom scales it via a Viewbox. + public const double CanvasWidth = 900; + + /// + public const double CanvasHeight = 600; + + /// Kept free on every side, so labels and sensor markers hanging off the centreline stay on. + private const double Margin = 70; + + /// + /// How big track is drawn when the plan is small enough not to need shrinking: a 16-stud + /// straight comes out 80 px long, which is a comfortable finger target. + /// + private const double DefaultPixelsPerStud = 5.0; + + /// Gap left between neighbouring parts so a plan reads as parts, not one smear of rail. + private const double TrimPixels = 5; + + /// Samples per arc when bounding it — a quarter of an R40 circle deviates well under a pixel. + private const int ArcSamples = 8; + + /// + /// Lays out on the canvas. Parts whose id is in + /// are marked as preview and excluded from the fit. + /// + public static FittedPlan Fit(TrackPlanLayout layout, IReadOnlyCollection? previewPartIds = null) + { + if (layout.Placements.Count == 0) return FittedPlan.Empty; + + var preview = previewPartIds ?? []; + var committed = layout.Placements.Values.Where(placement => !preview.Contains(placement.PartId)).ToList(); + var view = BuildView(committed.Count > 0 ? committed : [.. layout.Placements.Values]); + + var pieces = new List(layout.Placements.Count); + var bed = new List(committed.Count); + var ghost = new List(); + + foreach (var placement in layout.Placements.Values) + { + var isPreview = preview.Contains(placement.PartId); + var piece = Emit(placement, view, isPreview); + + pieces.Add(piece); + (isPreview ? ghost : bed).Add(piece.HitPathData); + } + + return new FittedPlan + { + Pieces = pieces, + Anchors = + [ + .. layout.OpenEnds.Select(end => new FittedAnchor( + end.PartId, end.PortName, view.X(end.WorldPose.X), view.Y(end.WorldPose.Y))), + ], + BedPathData = string.Join(" ", bed), + PreviewPathData = string.Join(" ", ghost), + StudsPerPixel = 1 / view.Scale, + OriginX = view.OffsetX, + OriginY = view.OffsetY, + }; + } + + // Centres the plan and shrinks it (never enlarges past the default) so it stays inside the margin. + private static View BuildView(IReadOnlyList placements) + { + var extent = Extent.Of(placements.SelectMany(Outline)); + var scale = Math.Min( + DefaultPixelsPerStud, + Math.Min( + (CanvasWidth - (2 * Margin)) / Math.Max(extent.MaxX - extent.MinX, 1), + (CanvasHeight - (2 * Margin)) / Math.Max(extent.MaxY - extent.MinY, 1))); + + return new View( + scale, + (CanvasWidth / 2) - (scale * (extent.MinX + extent.MaxX) / 2), + (CanvasHeight / 2) - (scale * (extent.MinY + extent.MaxY) / 2)); + } + + // Enough world points to bound a part: a straight's two ends, an arc's sampled sweep. + private static IEnumerable<(double X, double Y)> Outline(TrackPartPlacement placement) + { + foreach (var span in placement.Spans) + { + if (!span.IsArc) + { + var end = span.End; + yield return (span.Start.X, span.Start.Y); + yield return (end.X, end.Y); + continue; + } + + var (centreX, centreY, from, to) = (span.CentreX, span.CentreY, span.StartAngle, span.EndAngle); + + for (var step = 0; step <= ArcSamples; step++) + { + var angle = from + ((to - from) * step / ArcSamples); + yield return ( + centreX + (span.RadiusStuds * Math.Cos(angle)), + centreY + (span.RadiusStuds * Math.Sin(angle))); + } + } + } + + private static FittedPiece Emit(TrackPartPlacement placement, in View view, bool isPreview) + { + var drawn = new List(placement.Spans.Count); + var hit = new List(placement.Spans.Count); + + foreach (var span in placement.Spans) + { + drawn.Add(Path(span, view, TrimPixels)); + hit.Add(Path(span, view, 0)); + } + + // The first span is the part's main run, so its middle is where a label belongs: along a + // straight, along the main route of a point, along the first route of a crossing. + var (midX, midY, tangent) = Middle(placement.Spans[0]); + var tanX = Math.Cos(tangent); + var tanY = Math.Sin(tangent); + + return new FittedPiece( + placement.PartId, + string.Join(" ", drawn), + string.Join(" ", hit), + MidX: view.X(midX), + MidY: view.Y(midY), + // Left-hand normal of travel, for straights and curves alike, so labels and sensor + // markers stay on one side of the track throughout. + OutwardX: tanY, + OutwardY: -tanX, + TanX: tanX, + TanY: tanY, + IsPreview: isPreview); + } + + private static (double X, double Y, double Tangent) Middle(in TrackSpan span) + { + if (!span.IsArc) + { + var mid = span.Start.Advance(span.LengthStuds / 2); + return (mid.X, mid.Y, span.Start.HeadingRadians); + } + + var midAngle = span.StartAngle + ((span.EndAngle - span.StartAngle) / 2); + + return ( + span.CentreX + (span.RadiusStuds * Math.Cos(midAngle)), + span.CentreY + (span.RadiusStuds * Math.Sin(midAngle)), + span.Start.HeadingRadians + (span.SweepSteps * TrackMetrics.RadiansPerHeadingStep / 2)); + } + + // The trim is applied after scaling, so the gap between parts stays the same few pixels at any + // zoom rather than growing with the track. + private static string Path(in TrackSpan span, in View view, double trim) + { + if (!span.IsArc) + { + var start = span.Start; + var end = span.End; + + return Line(view.X(start.X), view.Y(start.Y), view.X(end.X), view.Y(end.Y), trim); + } + + var radius = span.RadiusStuds * view.Scale; + var trimAngle = radius > 0 ? trim / radius : 0; + var sign = span.Sign; + + return Arc( + view.X(span.CentreX), + view.Y(span.CentreY), + radius, + span.StartAngle + (sign * trimAngle), + span.EndAngle - (sign * trimAngle), + sign); + } + + private static string Line(double x0, double y0, double x1, double y1, double trim) + { + if (trim > 0) + { + var length = Math.Sqrt(((x1 - x0) * (x1 - x0)) + ((y1 - y0) * (y1 - y0))); + + if (length > trim * 2) + { + var t = trim / length; + (x0, y0, x1, y1) = ( + x0 + ((x1 - x0) * t), y0 + ((y1 - y0) * t), + x1 - ((x1 - x0) * t), y1 - ((y1 - y0) * t)); + } + } + + return FormattableString.Invariant($"M {x0:0.0} {y0:0.0} L {x1:0.0} {y1:0.0}"); + } + + private static string Arc(double centreX, double centreY, double radius, double from, double to, int sign) + { + var startX = centreX + (radius * Math.Cos(from)); + var startY = centreY + (radius * Math.Sin(from)); + var endX = centreX + (radius * Math.Cos(to)); + var endY = centreY + (radius * Math.Sin(to)); + + return FormattableString.Invariant( + $"M {startX:0.0} {startY:0.0} A {radius:0.0},{radius:0.0} 0 0,{(sign > 0 ? 1 : 0)} {endX:0.0} {endY:0.0}"); + } + + /// The studs-to-canvas transform the fit settled on. + private readonly record struct View(double Scale, double OffsetX, double OffsetY) + { + public double X(double studs) => (studs * Scale) + OffsetX; + + public double Y(double studs) => (studs * Scale) + OffsetY; + } + + private readonly record struct Extent(double MinX, double MinY, double MaxX, double MaxY) + { + public static Extent Of(IEnumerable<(double X, double Y)> points) + { + var (minX, minY, maxX, maxY) = (double.MaxValue, double.MaxValue, double.MinValue, double.MinValue); + + foreach (var (x, y) in points) + { + minX = Math.Min(minX, x); + minY = Math.Min(minY, y); + maxX = Math.Max(maxX, x); + maxY = Math.Max(maxY, y); + } + + return minX > maxX ? new Extent(0, 0, 0, 0) : new Extent(minX, minY, maxX, maxY); + } + } +} diff --git a/Source/Trackify/Models/Trains/TrackAnchor.cs b/Source/Trackify/Models/Trains/TrackAnchor.cs new file mode 100644 index 0000000..734824f --- /dev/null +++ b/Source/Trackify/Models/Trains/TrackAnchor.cs @@ -0,0 +1,35 @@ +namespace Trackify.Models.Trains; + +/// +/// An open end as the canvas shows it: the big "Andockpunkt" dot a drag starts from, and the target +/// a deliberate part (point, crossing, station) is appended to when tapped. +/// +public partial class TrackAnchor : ObservableObject +{ + /// Diameter of the dot, so the model can pre-compute its top-left corner. + public const double DotSize = 30; + + [ObservableProperty] private double x; + [ObservableProperty] private double y; + [ObservableProperty] private bool isActive; + + /// The part this port belongs to. + public required Guid PartId { get; init; } + + /// Which port of it (see TrackPortNames). + public required string PortName { get; init; } + + /// Canvas position of the dot's top-left corner, since Canvas.Left is not centre-based. + public double Left => X - (DotSize / 2); + + /// + public double Top => Y - (DotSize / 2); + + /// Whether this anchor and are the same port. + public bool Matches(Guid other, string otherPort) + => PartId == other && string.Equals(PortName, otherPort, StringComparison.Ordinal); + + partial void OnXChanged(double value) => OnPropertyChanged(nameof(Left)); + + partial void OnYChanged(double value) => OnPropertyChanged(nameof(Top)); +} diff --git a/Source/Trackify/Models/Trains/TrackPiece.cs b/Source/Trackify/Models/Trains/TrackPiece.cs new file mode 100644 index 0000000..c83b831 --- /dev/null +++ b/Source/Trackify/Models/Trains/TrackPiece.cs @@ -0,0 +1,137 @@ +namespace Trackify.Models.Trains; + +/// +/// One part as the Streckenplaner shows it: the drive behaviour the inspector edits, plus the +/// ready-to-draw coordinates worked out. +/// +/// The topology — which part is plugged into which — is deliberately not here. That lives in +/// the ViewModel's TrackPlanTopology, because it is the plan's structural truth, and a piece +/// that could edit its own connections would let the view invent geometry. +/// +public partial class TrackPiece : ObservableObject +{ + private const double LabelWidth = 40; + private const double LabelHeight = 21; + private const double LabelOffset = 34; + private const double SensorSize = 28; + private const double SensorOffset = 42; + + [ObservableProperty] private string specKey = TrackPartCatalog.StraightKey; + [ObservableProperty] private string name = ""; + [ObservableProperty] private int maxSpeed = 70; + [ObservableProperty] private TrackDirection direction = TrackDirection.Forward; + [ObservableProperty] private SpeedFunctionType accelFn = SpeedFunctionType.EaseOut; + [ObservableProperty] private SpeedFunctionType brakeFn = SpeedFunctionType.EaseIn; + [ObservableProperty] private SensorType sensor = SensorType.None; + [ObservableProperty] private SensorActionType action = SensorActionType.Notify; + [ObservableProperty] private int slowTarget = 30; + + /// Which way a point is set. Meaningless on anything that is not a point. + [ObservableProperty] private SwitchRoute defaultRoute = SwitchRoute.Main; + + [ObservableProperty] private string pathData = ""; + [ObservableProperty] private string hitPathData = ""; + [ObservableProperty] private double labelLeft; + [ObservableProperty] private double labelTop; + [ObservableProperty] private double sensorLeft; + [ObservableProperty] private double sensorTop; + [ObservableProperty] private bool isPreview; + + /// Identity, shared with the plan graph and the stored part. + public required Guid Id { get; init; } + + /// German label for this kind of part, as the planner lists it. + public string TypeLabel => SpecKey switch + { + TrackPartCatalog.StraightKey => "Gerade", + TrackPartCatalog.StationKey => "Bahnhof", + TrackPartCatalog.CurveLeftKey => "Kurve links", + TrackPartCatalog.CurveRightKey => "Kurve rechts", + TrackPartCatalog.SwitchLeftKey => "Weiche links", + TrackPartCatalog.SwitchRightKey => "Weiche rechts", + TrackPartCatalog.CrossingKey => "Kreuzung", + _ => "Gleis", + }; + + /// Whether this part is a point, which is what unlocks the switch part of the inspector. + public bool IsSwitch => SpecKey is TrackPartCatalog.SwitchLeftKey or TrackPartCatalog.SwitchRightKey; + + public string SpeedColor => MaxSpeed switch + { + <= 0 => "#E5484D", + < 35 => "#E5701C", + < 60 => "#F5A623", + < 85 => "#2FAE4A", + _ => "#16A34A", + }; + + public bool HasSensor => Sensor != SensorType.None; + + public string SensorColor => Sensor == SensorType.Color ? "#0A54C9" : "#E8730C"; + + public string SensorGlyph => Sensor == SensorType.Color ? "F" : "A"; + + public bool ShowSlowTarget => Action == SensorActionType.Slower; + + public string AccelFormulaDisplay => LegoinoCatalog.SpeedFunction(AccelFn).Formula; + + public string BrakeFormulaDisplay => LegoinoCatalog.SpeedFunction(BrakeFn).Formula; + + /// The LWP device token the sensor section shows, matching the CLI's vocabulary. + public string SensorToken => "DeviceType." + (Sensor == SensorType.None ? "NONE" : "COLOR_DISTANCE_SENSOR"); + + /// The speed profile the widget draws — presentation only. + public SpeedProfileGraph Graph => SpeedCurve.BuildGraph( + MaxSpeed / 100.0, + x => SpeedFunction.Evaluate(AccelFn, x), + x => SpeedFunction.Evaluate(BrakeFn, x)); + + /// Takes the coordinates the fitter worked out for this part. + public void ApplyGeometry(in FittedPiece piece) + { + PathData = piece.PathData; + HitPathData = piece.HitPathData; + IsPreview = piece.IsPreview; + + // Label and sensor sit on opposite sides of the track, both offset along the piece's outward + // normal, then pulled back by half their box so the offset lands on the box's centre. + LabelLeft = piece.MidX - (piece.OutwardX * LabelOffset) - (LabelWidth / 2); + LabelTop = piece.MidY - (piece.OutwardY * LabelOffset) - (LabelHeight / 2); + SensorLeft = piece.MidX + (piece.OutwardX * SensorOffset) - (SensorSize / 2); + SensorTop = piece.MidY + (piece.OutwardY * SensorOffset) - (SensorSize / 2); + } + + partial void OnMaxSpeedChanged(int value) + { + OnPropertyChanged(nameof(SpeedColor)); + OnPropertyChanged(nameof(Graph)); + } + + partial void OnSpecKeyChanged(string value) + { + OnPropertyChanged(nameof(TypeLabel)); + OnPropertyChanged(nameof(IsSwitch)); + } + + partial void OnSensorChanged(SensorType value) + { + OnPropertyChanged(nameof(HasSensor)); + OnPropertyChanged(nameof(SensorColor)); + OnPropertyChanged(nameof(SensorGlyph)); + OnPropertyChanged(nameof(SensorToken)); + } + + partial void OnActionChanged(SensorActionType value) => OnPropertyChanged(nameof(ShowSlowTarget)); + + partial void OnAccelFnChanged(SpeedFunctionType value) + { + OnPropertyChanged(nameof(AccelFormulaDisplay)); + OnPropertyChanged(nameof(Graph)); + } + + partial void OnBrakeFnChanged(SpeedFunctionType value) + { + OnPropertyChanged(nameof(BrakeFormulaDisplay)); + OnPropertyChanged(nameof(Graph)); + } +} diff --git a/Source/Trackify/Models/Trains/TrackSegment.cs b/Source/Trackify/Models/Trains/TrackSegment.cs deleted file mode 100644 index 065c5ba..0000000 --- a/Source/Trackify/Models/Trains/TrackSegment.cs +++ /dev/null @@ -1,204 +0,0 @@ - -namespace Trackify.Models.Trains; - -public partial class TrackSegment : ObservableObject -{ - // Canvas marker boxes — TrackCanvas.xaml draws elements of exactly these sizes, and TrackGeometry's - // fit padding keeps the largest outward offset on screen. - private const double LabelWidth = 40; - private const double LabelHeight = 21; - private const double LabelOffset = 40; - private const double SensorSize = 30; - private const double SensorOffset = 50; - private const double ArrowLength = 16; - private const double ArrowHalfWidth = 6; - - /// Where a sensor's connecting line starts: just clear of the 30-thick ballast stroke. - private const double TrackEdge = 18; - - [ObservableProperty] private Guid id = Guid.CreateVersion7(); - [ObservableProperty] private string name = ""; - [ObservableProperty] private SegmentType type = SegmentType.Straight; - [ObservableProperty] private int maxSpeed = 70; - [ObservableProperty] private TrackDirection direction = TrackDirection.Forward; - [ObservableProperty] private SpeedFunctionType accelFn = SpeedFunctionType.EaseOut; - [ObservableProperty] private SpeedFunctionType brakeFn = SpeedFunctionType.EaseIn; - [ObservableProperty] private SensorType sensor = SensorType.None; - [ObservableProperty] private SensorActionType action = SensorActionType.Notify; - [ObservableProperty] private int slowTarget = 30; - - // Strand/branch graph — see Domain's TrackSegment for the full field-by-field rationale. - [ObservableProperty] private Guid strandId = Guid.Empty; - [ObservableProperty] private int order; - [ObservableProperty] private CurveDirection? curve; - [ObservableProperty] private Guid? branchStrandId; - [ObservableProperty] private CurveDirection? branchDirection; - [ObservableProperty] private SwitchRoute route = SwitchRoute.Main; - [ObservableProperty] private Guid? branchLinkSegmentId; - - /// Static SVG-style path data for the track centerline, in the shared 900x600 canvas viewBox. - public string PathData { get; private set; } = ""; - - /// Wider, untrimmed path data used for pointer hit-testing and the selection glow. - public string HitPathData { get; private set; } = ""; - - /// Segment midpoint — every marker on the piece (label, sensor, arrow) hangs off it. - public double MidX { get; private set; } - public double MidY { get; private set; } - - /// Top-left of the speed label's box, centered on its position (Canvas.Left/Top friendly). - public double LabelLeft { get; private set; } - public double LabelTop { get; private set; } - - /// Top-left of the sensor-marker box (Canvas.Left/Top friendly). - public double SensorLeft { get; private set; } - public double SensorTop { get; private set; } - - /// The travel-direction arrow: a triangle at the segment midpoint, pointing along the - /// track (flipped for ), as ready-to-draw path data. It is - /// emitted rather than drawn once and rotated by a bound RenderTransform, because a - /// {Binding} inside a transform has no DataContext to inherit — transforms aren't in the - /// visual tree — so the angle silently never arrives. - public string ArrowPathData - { - get - { - var sign = Direction == TrackDirection.Reverse ? -1 : 1; - var (tanX, tanY) = (TanX * sign, TanY * sign); - var (tipX, tipY) = (MidX + (tanX * ArrowLength / 2), MidY + (tanY * ArrowLength / 2)); - var (backX, backY) = (MidX - (tanX * ArrowLength / 2), MidY - (tanY * ArrowLength / 2)); - var (leftX, leftY) = (backX + (tanY * ArrowHalfWidth), backY - (tanX * ArrowHalfWidth)); - var (rightX, rightY) = (backX - (tanY * ArrowHalfWidth), backY + (tanX * ArrowHalfWidth)); - - return FormattableString.Invariant($"M {leftX:0.0} {leftY:0.0} L {tipX:0.0} {tipY:0.0} L {rightX:0.0} {rightY:0.0} Z"); - } - } - - public double SensorLineX1 { get; private set; } - public double SensorLineY1 { get; private set; } - public double SensorLineX2 { get; private set; } - public double SensorLineY2 { get; private set; } - - /// Raw track-tangent direction at the segment midpoint (forward travel), used to orient the direction arrow. - public double TanX { get; private set; } - public double TanY { get; private set; } - - /// Copies a freshly-computed result onto this segment and - /// derives the label/sensor marker positions from it — called for every segment after any - /// structural change to the plan (append/delete/reorder/…), since a change anywhere upstream in - /// a strand shifts every piece after it. - public void ApplyGeometry(SegmentGeometry geometry) - { - PathData = geometry.PathData; - HitPathData = geometry.HitPathData; - MidX = geometry.MidX; - MidY = geometry.MidY; - TanX = geometry.TanX; - TanY = geometry.TanY; - // Label and sensor sit on opposite sides of the track, both offset along the piece's outward - // normal, then pulled back by half their box so the offset lands on the box's center. - LabelLeft = MidX - (geometry.OutwardX * LabelOffset) - (LabelWidth / 2); - LabelTop = MidY - (geometry.OutwardY * LabelOffset) - (LabelHeight / 2); - SensorLeft = MidX + (geometry.OutwardX * SensorOffset) - (SensorSize / 2); - SensorTop = MidY + (geometry.OutwardY * SensorOffset) - (SensorSize / 2); - SensorLineX1 = MidX + (geometry.OutwardX * TrackEdge); - SensorLineY1 = MidY + (geometry.OutwardY * TrackEdge); - SensorLineX2 = MidX + (geometry.OutwardX * (SensorOffset - 2)); - SensorLineY2 = MidY + (geometry.OutwardY * (SensorOffset - 2)); - - OnPropertyChanged(nameof(PathData)); - OnPropertyChanged(nameof(HitPathData)); - OnPropertyChanged(nameof(MidX)); - OnPropertyChanged(nameof(MidY)); - OnPropertyChanged(nameof(TanX)); - OnPropertyChanged(nameof(TanY)); - OnPropertyChanged(nameof(LabelLeft)); - OnPropertyChanged(nameof(LabelTop)); - OnPropertyChanged(nameof(SensorLeft)); - OnPropertyChanged(nameof(SensorTop)); - OnPropertyChanged(nameof(ArrowPathData)); - OnPropertyChanged(nameof(SensorLineX1)); - OnPropertyChanged(nameof(SensorLineY1)); - OnPropertyChanged(nameof(SensorLineX2)); - OnPropertyChanged(nameof(SensorLineY2)); - } - - public TrackGeometryInput GeometryInput => new(Id, Type, Curve, StrandId, Order, BranchStrandId, BranchDirection); - - public bool IsWeiche => Type == SegmentType.Switch; - - public string TypeLabel => Type switch - { - SegmentType.Straight => "Gerade", - SegmentType.Curve => Curve == CurveDirection.Left ? "Kurve links" : "Kurve rechts", - SegmentType.Switch => "Weiche", - SegmentType.Station => "Bahnhof", - _ => "Segment", - }; - - public string SpeedColor => MaxSpeed switch - { - <= 0 => "#E5484D", - < 35 => "#E5701C", - < 60 => "#F5A623", - < 85 => "#2FAE4A", - _ => "#16A34A", - }; - - public bool HasSensor => Sensor != SensorType.None; - - public string SensorColor => Sensor == SensorType.Color ? "#0A54C9" : "#E8730C"; - - public string SensorGlyph => Sensor == SensorType.Color ? "F" : "A"; - - public string SensorToken => "DeviceType." + (Sensor == SensorType.None ? "NONE" : "COLOR_DISTANCE_SENSOR"); - - public bool ShowSlowTarget => Action == SensorActionType.Slower; - - public string AccelFormulaDisplay => LegoinoCatalog.SpeedFunction(AccelFn).Formula; - - public string BrakeFormulaDisplay => LegoinoCatalog.SpeedFunction(BrakeFn).Formula; - - public SpeedProfileGraph Graph => SpeedCurve.BuildGraph( - MaxSpeed / 100.0, - x => SpeedFunction.Evaluate(AccelFn, x), - x => SpeedFunction.Evaluate(BrakeFn, x)); - - partial void OnMaxSpeedChanged(int value) - { - OnPropertyChanged(nameof(SpeedColor)); - OnPropertyChanged(nameof(Graph)); - } - - partial void OnAccelFnChanged(SpeedFunctionType value) - { - OnPropertyChanged(nameof(AccelFormulaDisplay)); - OnPropertyChanged(nameof(Graph)); - } - - partial void OnBrakeFnChanged(SpeedFunctionType value) - { - OnPropertyChanged(nameof(BrakeFormulaDisplay)); - OnPropertyChanged(nameof(Graph)); - } - - partial void OnSensorChanged(SensorType value) - { - OnPropertyChanged(nameof(HasSensor)); - OnPropertyChanged(nameof(SensorColor)); - OnPropertyChanged(nameof(SensorGlyph)); - OnPropertyChanged(nameof(SensorToken)); - } - - partial void OnActionChanged(SensorActionType value) => OnPropertyChanged(nameof(ShowSlowTarget)); - - partial void OnDirectionChanged(TrackDirection value) => OnPropertyChanged(nameof(ArrowPathData)); - - partial void OnTypeChanged(SegmentType value) - { - OnPropertyChanged(nameof(TypeLabel)); - OnPropertyChanged(nameof(IsWeiche)); - } - - partial void OnCurveChanged(CurveDirection? value) => OnPropertyChanged(nameof(TypeLabel)); -} diff --git a/Source/Trackify/Presentation/Behaviors/DrawGestureBehavior.cs b/Source/Trackify/Presentation/Behaviors/DrawGestureBehavior.cs new file mode 100644 index 0000000..1249dab --- /dev/null +++ b/Source/Trackify/Presentation/Behaviors/DrawGestureBehavior.cs @@ -0,0 +1,126 @@ +using System.Windows.Input; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Input; +using Windows.Foundation; + +namespace Trackify.Presentation.Behaviors; + +/// +/// Attached behavior: turns a press-drag-release on an element into three commands, each given the +/// point in the element's own coordinate space. +/// +/// This is the one escape hatch the house rules allow — an event that has to reach a command which +/// is not natively Command-bindable — and it is why drawing needs no code-behind. Attach it to +/// the fixed-size Canvas itself, not to the Viewbox around it: points then arrive in +/// canvas coordinates already, so zoom never has to be undone by hand. +/// +/// Usage: +/// +/// behaviors:DrawGestureBehavior.StartCommand="{Binding ElementName=Root, Path=DataContext.DrawStartCommand}" +/// behaviors:DrawGestureBehavior.MoveCommand="{Binding ElementName=Root, Path=DataContext.DrawMoveCommand}" +/// behaviors:DrawGestureBehavior.EndCommand="{Binding ElementName=Root, Path=DataContext.DrawEndCommand}" +/// +/// +public static class DrawGestureBehavior +{ + /// Runs when a drag begins, with the point it began at. + public static readonly DependencyProperty StartCommandProperty = DependencyProperty.RegisterAttached( + "StartCommand", typeof(ICommand), typeof(DrawGestureBehavior), new PropertyMetadata(null, OnCommandChanged)); + + /// Runs as the drag moves, with each point it passes. + public static readonly DependencyProperty MoveCommandProperty = DependencyProperty.RegisterAttached( + "MoveCommand", typeof(ICommand), typeof(DrawGestureBehavior), new PropertyMetadata(null, OnCommandChanged)); + + /// Runs when the drag ends. + public static readonly DependencyProperty EndCommandProperty = DependencyProperty.RegisterAttached( + "EndCommand", typeof(ICommand), typeof(DrawGestureBehavior), new PropertyMetadata(null, OnCommandChanged)); + + // Tracks which element is mid-drag, so a move that arrives without a press is ignored. + private static readonly DependencyProperty IsDrawingProperty = DependencyProperty.RegisterAttached( + "IsDrawing", typeof(bool), typeof(DrawGestureBehavior), new PropertyMetadata(false)); + + public static ICommand? GetStartCommand(DependencyObject element) + => (ICommand?)element.GetValue(StartCommandProperty); + + public static void SetStartCommand(DependencyObject element, ICommand? value) + => element.SetValue(StartCommandProperty, value); + + public static ICommand? GetMoveCommand(DependencyObject element) + => (ICommand?)element.GetValue(MoveCommandProperty); + + public static void SetMoveCommand(DependencyObject element, ICommand? value) + => element.SetValue(MoveCommandProperty, value); + + public static ICommand? GetEndCommand(DependencyObject element) + => (ICommand?)element.GetValue(EndCommandProperty); + + public static void SetEndCommand(DependencyObject element, ICommand? value) + => element.SetValue(EndCommandProperty, value); + + private static void OnCommandChanged(DependencyObject element, DependencyPropertyChangedEventArgs e) + { + if (element is not FrameworkElement target) return; + + // Re-subscribing is idempotent: all three properties share this handler, so a second binding + // must not attach the pointer handlers twice. + target.PointerPressed -= OnPointerPressed; + target.PointerMoved -= OnPointerMoved; + target.PointerReleased -= OnPointerReleased; + target.PointerCanceled -= OnPointerReleased; + target.PointerCaptureLost -= OnPointerReleased; + + if (!HasAnyCommand(target)) return; + + target.PointerPressed += OnPointerPressed; + target.PointerMoved += OnPointerMoved; + target.PointerReleased += OnPointerReleased; + target.PointerCanceled += OnPointerReleased; + target.PointerCaptureLost += OnPointerReleased; + } + + private static void OnPointerPressed(object sender, PointerRoutedEventArgs e) + { + if (sender is not FrameworkElement target) return; + + target.SetValue(IsDrawingProperty, true); + target.CapturePointer(e.Pointer); + + Invoke(GetStartCommand(target), PointIn(target, e)); + } + + private static void OnPointerMoved(object sender, PointerRoutedEventArgs e) + { + if (sender is not FrameworkElement target) return; + if (!IsDrawing(target)) return; + + Invoke(GetMoveCommand(target), PointIn(target, e)); + } + + private static void OnPointerReleased(object sender, PointerRoutedEventArgs e) + { + if (sender is not FrameworkElement target) return; + + // Capture loss and cancellation end the stroke too, so an interrupted drag cannot leave the + // editor stuck in drawing mode with a ghost preview on screen. + if (!IsDrawing(target)) return; + + target.SetValue(IsDrawingProperty, false); + + Invoke(GetEndCommand(target), PointIn(target, e)); + } + + private static bool IsDrawing(DependencyObject element) => element.GetValue(IsDrawingProperty) is true; + + private static bool HasAnyCommand(DependencyObject element) + => GetStartCommand(element) is not null + || GetMoveCommand(element) is not null + || GetEndCommand(element) is not null; + + private static Point PointIn(UIElement target, PointerRoutedEventArgs e) + => e.GetCurrentPoint(target).Position; + + private static void Invoke(ICommand? command, Point point) + { + if (command is not null && command.CanExecute(point)) command.Execute(point); + } +} diff --git a/Source/Trackify/Presentation/Components/PieceList.xaml b/Source/Trackify/Presentation/Components/PieceList.xaml new file mode 100644 index 0000000..c8c610b --- /dev/null +++ b/Source/Trackify/Presentation/Components/PieceList.xaml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/Trackify/Presentation/Components/PieceList.xaml.cs b/Source/Trackify/Presentation/Components/PieceList.xaml.cs new file mode 100644 index 0000000..bf8104c --- /dev/null +++ b/Source/Trackify/Presentation/Components/PieceList.xaml.cs @@ -0,0 +1,9 @@ +using Microsoft.UI.Xaml.Controls; + +namespace Trackify.Presentation.Components; + +/// Flat list of the laid parts. Binds to the hosting SecondViewModel. +public sealed partial class PieceList : UserControl +{ + public PieceList() => this.InitializeComponent(); +} diff --git a/Source/Trackify/Presentation/Components/SegmentInspector.xaml b/Source/Trackify/Presentation/Components/SegmentInspector.xaml index efcebc6..77409f1 100644 --- a/Source/Trackify/Presentation/Components/SegmentInspector.xaml +++ b/Source/Trackify/Presentation/Components/SegmentInspector.xaml @@ -11,7 +11,7 @@ - @@ -27,14 +27,14 @@ - + - + - - @@ -53,12 +53,12 @@ - + - + @@ -69,19 +69,19 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Source/Trackify/Presentation/Components/StrandList.xaml.cs b/Source/Trackify/Presentation/Components/StrandList.xaml.cs deleted file mode 100644 index de92b3b..0000000 --- a/Source/Trackify/Presentation/Components/StrandList.xaml.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Microsoft.UI.Xaml.Controls; - -namespace Trackify.Presentation.Components; - -/// Strand-grouped segment list (numbered rows, reorder/duplicate/delete). Binds to the hosting SecondViewModel. -public sealed partial class StrandList : UserControl -{ - public StrandList() => this.InitializeComponent(); -} diff --git a/Source/Trackify/Presentation/Components/TrackCanvas.xaml b/Source/Trackify/Presentation/Components/TrackCanvas.xaml index 1463b31..bfde902 100644 --- a/Source/Trackify/Presentation/Components/TrackCanvas.xaml +++ b/Source/Trackify/Presentation/Components/TrackCanvas.xaml @@ -2,18 +2,17 @@ x:Name="Root" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:local="using:Trackify.Presentation.Behaviors" + xmlns:behaviors="using:Trackify.Presentation.Behaviors" xmlns:widgets="using:Trackify.Presentation.Widgets" xmlns:vm="using:Trackify.Presentation.ViewModels" - xmlns:app="using:Trackify.Application.Catalog" xmlns:models="using:Trackify.Models.Trains" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" d:DataContext="{d:DesignInstance Type=vm:SecondViewModel}"> - + @@ -21,263 +20,248 @@ - - - - - - + + + + + - - + + - + + - - - - - - - - - - - - + + - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - - - - - - + + - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - + + + + - - - - - - - - - - - - - - - - - - - - - - -