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/.gitignore b/.gitignore index 38d5456..4e187a4 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ riderModule.iml # Sonar scanner working dir + the coverlet report sonar.yml feeds it. /.sonarqube/ /coverage/ +# Coverlet writes its report next to the test project on every -p:CollectCoverage run. +coverage.cobertura.xml +coverage.json +coverage.opencover.xml diff --git a/CLAUDE.md b/CLAUDE.md index 55829ac..a14550c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,19 +47,30 @@ belongs, `09-architecture-decisions.md` for settled decisions (don't re-open the `11-risks-and-technical-debt.md` for what is knowingly unfinished. Update the affected section in the same PR when the architecture changes. +**`docs/editor-view.md`** (English) and **`docs/editor-view.de.md`** (German) are the +operator-facing counterpart: what each page and every control does, screen by screen. Both quote +the app's German labels verbatim, since that is what is on screen; the English one glosses each. +**Keep the two in step** — a control or label change edits both. Their screenshot slots under +`docs/images/` are deliberately empty: the renderer can't be captured here (see "Build & verify"), +so they are filled from a real device. + ## What this is **Trackify** configures and controls **LEGO Powered Up** train hubs over **Bluetooth LE**, directly on-device — there is **no server/backend**. BLE talks the [LEGO Wireless Protocol (LWP) v3](https://lego.github.io/lego-ble-wireless-protocol-docs/) via [SharpBrick.PoweredUp](https://github.com/sharpbrick/powered-up). It's a **Clean Architecture** solution with two front-ends over one shared core. All projects live under `Source/` (the "Source" solution folder): -- `Trackify.Domain` — pure entities (`Train`, `TrackSegment`), enums, `SpeedFunction` math. Only dependency is the DI abstractions (contract-only), for its `AddTrackifyDomain()` entry point. +- `Trackify.Domain` — pure entities (`Train`, `TrackPart`, `TrackLink`), enums, the track-part catalogue + plan geometry (`Tracks/`), `SpeedFunction` math. Only dependency is the DI abstractions (contract-only), for its `AddTrackifyDomain()` entry point. - `Trackify.Application` — ports + use-cases (`ILegoService`/`ITrainControlService`/`ITrainService`/`ITrainRepository` + `DiscoveredHubDto`, `LegoinoCatalog`, `TrainControlService`, `TrainService`, `TrainDto`/`TrainMapping`, `LwpAddressingMapping`) **plus the per-platform `ILegoService` transports under `Services/`** (`DirectLegoService` android/ios, `WindowsLegoService`) wired by per-head `Add{Android,Ios,Windows}Lego` extensions (mirroring the CLI's `AddLinuxLego`). **Multi-targeted per BUILD host** (Windows: `+net10.0-android;net10.0-windows`; macOS: `+net10.0-android;net10.0-ios`; Linux: plain `net10.0` only). The plain `net10.0` flavor is transport-free (CLI/Pi carries BlueZ instead). - `Trackify.Infrastructure` — `SqliteTrainRepository` (EF Core + **SQLite** persistence, `trackify.db`; enums stored as names) **plus** the BlueZ onboard-radio `ILegoService` under `Ble/` (`Linux.Bluetooth` + SharpBrick). Referenced **only by the CLI** — it carries the Linux BLE stack, so the Uno app must not reference it. - - **Repository pattern**: entities derive from `Domain/Common/BaseEntity` (Guid `Id` + `DateCreated`/`DateUpdated`); repositories derive from the generic `Infrastructure/Persistence/BaseRepository` (default EF CRUD — `GetById/GetAll/Find/Add/AddRange/Update/Delete` over an `IDbContextFactory`) and implement a per-entity port that extends `Application/Common/IBaseRepository` (e.g. `ITrainRepository : IBaseRepository`; `SqliteTrainRepository` adds only the default-db-path helper). Schema is created with `EnsureCreated()` (no migrations) — **changing an entity's shape means deleting the dev `trackify.db`** (or adding EF migrations), since `EnsureCreated` won't migrate an existing file. + - **Repository pattern**: entities derive from `Domain/Common/BaseEntity` (Guid `Id` + `DateCreated`/`DateUpdated`); repositories derive from the generic `Infrastructure/Persistence/BaseRepository` (default EF CRUD — `GetById/GetAll/Find/Add/AddRange/Update/Delete` over an `IDbContextFactory`) and implement a per-entity port that extends `Application/Common/IBaseRepository` (e.g. `ITrainRepository : IBaseRepository`; `SqliteTrainRepository` adds only the default-db-path helper). Schema is applied with EF Core **migrations** (`Database.Migrate()` on first use; `dotnet-ef` is a local tool in `.config/dotnet-tools.json`), so changing an entity’s shape means **adding a migration** rather than deleting the dev `trackify.db`. A database created by the older `EnsureCreated()` has no `__EFMigrationsHistory` and has to be moved aside once — see arc42 ADR-18. - `Trackify` — the [Uno Platform](https://platform.uno) app (single project, multi-head = App / HMI / **Web = WASM head**). References **Domain + Application only**; the BLE transports flow from Application per head (`AddTrackifyApplication` calls the right `Add{Android,Ios,Windows}Lego`). The app's own `Services/` holds only `AndroidBluetoothPermissionService` (needs the `Activity`) + the desktop/wasm no-op registration. - `Trackify.Cli` — a **Spectre.Console.Cli** console app to deploy on a Linux server / Raspberry Pi (plain `net10.0` → BlueZ via `AddLinuxLego`'s runtime check). On a Windows dev box it additionally targets `net10.0-windows10.0.19041.0`, where the `WINDOWS` symbol makes Application compile in `AddWindowsLego` (WinRT) — so `discover`/`drive` work on Windows too (run that TFM; the plain `net10.0` flavor has no Windows transport). `RuntimeIdentifiers=linux-arm64` (on the net10.0 flavor) so arm64 restore/publish works from Rider. -- `Trackify.Tests` — xUnit (pure-logic + store round-trip). +- `Trackify.Tests` — xUnit (pure-logic + store round-trip), plain `net10.0`, the CI gate. +- `Trackify.Ui.Tests` — xUnit against the **app head's own code** (`SecondViewModel` drawing gestures, + `TrackPlanFitter`). Uses `Uno.Sdk` and targets `net10.0-windows10.0.19041.0`, because the app head has + no plain `net10.0` target to reference and the desktop head drags in WPF (`NETSDK1136`). **Windows + only, not in CI** — see arc42 ADR-13. - .NET 10, Uno.Sdk `6.5.36` (pinned in `global.json`; SDK pinned to `10.0.0` + `rollForward: latestMajor` → uses the newest installed major, net10 heads require the .NET 10 SDK). Update Uno in `global.json`, not in package props. - **CI** (GitHub Actions, `.github/workflows/`): `ci.yml` is the pre-merge gate on PRs to `master` — an ubuntu job builds the CLI + shared core and runs the tests. The Uno app is deliberately not built in CI (its 5 heads need workloads + macOS/Windows even with `-f `, because restore imports workloads for all TFMs); the Android head is covered by `android-apk.yml`. `android-apk.yml` builds the Android APK on windows-latest (JDK 17 + `android` workload, `-f net10.0-android`) on tag `v*` or manual dispatch; artifact `trackify-apk`. Both provision the .NET 8/9/10 SDKs. @@ -82,6 +93,10 @@ dotnet run --project Source/Trackify/Trackify.csproj -f net10.0-desktop # Shared core / CLI / tests dotnet build Source/Trackify.Cli/Trackify.Cli.csproj # CLI compiles here; real BLE only on a Pi dotnet test Test/Trackify.Tests/Trackify.Tests.csproj + +# App-head tests (SecondViewModel + TrackPlanFitter). Windows only: it references Source/Trackify, +# so it targets net10.0-windows10.0.19041.0 and is deliberately NOT in ci.yml (arc42 ADR-13). +dotnet test Test/Trackify.Ui.Tests/Trackify.Ui.Tests.csproj dotnet run --project Source/Trackify.Cli -- --help # trackify discover | list | connect | drive | stop | color ``` @@ -102,7 +117,7 @@ CLI deployment (Pi publish — no build flags; `trains.json`/store schema, syste - **Two selection styles, deliberately.** `AddLinuxLego` is a **runtime** check (`if (OperatingSystem.IsLinux())`) because the BlueZ types compile on every TFM. `AddAndroidLego`/`AddIosLego`/`AddWindowsLego` are **compile-time `#if`** per head, because `DirectLegoService`/`WindowsLegoService` reference SDK packages (Plugin.BLE, SharpBrick `.WinRT`) that only exist on those TFMs — they don't compile elsewhere, so a runtime check is impossible there. - **`SharpBrick` command building is NOT pure** — `LwpCommands.cs` (`Trackify.Infrastructure/Ble/`) builds SharpBrick typed messages (StartPower, SetRgbColor, connect-with-retry) for the BlueZ transport. The genuinely pure bits (RGB-LED port table, MAC format/parse) live once in `Application/Lego/LwpAddressing.cs`. -- **Uno app** (`Source/Trackify/`): `Presentation/` (MVVM, folder-per-concern, no business logic in pages), `Services/` (the platform `ILegoService` impls + `NativeBluetooth` permission glue), `Helpers/SpeedCurve.cs` (builds the presentation-only `SpeedProfileGraph` path data), `Models/Trains/` (`Train`/`TrackSegment` — currently `ObservableObject`; **Phase 4 will make them thin wrappers over the Domain configs**). +- **Uno app** (`Source/Trackify/`): `Presentation/` (MVVM, folder-per-concern, no business logic in pages), `Services/` (the platform `ILegoService` impls + `NativeBluetooth` permission glue), `Helpers/SpeedCurve.cs` (builds the presentation-only `SpeedProfileGraph` path data), `Models/Trains/` (`Train`/`TrackPiece`/`TrackAnchor` — currently `ObservableObject`; **Phase 4 will make them thin wrappers over the Domain configs**). - **Navigation & DI** — Uno.Extensions Hosting/Navigation. Routes/ViewMaps in `App.xaml.cs` → `RegisterRoutes`. - **CLI** (`Source/Trackify.Cli/`): Spectre.Console.Cli; `Program.cs` composes the layers (`AddTrackifyDomain/Application/Infrastructure` + Serilog) bridged to Spectre via the `Spectre.Console.Cli.Extensions.DependencyInjection` package (`DependencyInjectionRegistrar` — no hand-written registrar). Commands under `Commands/`, settings under `Commands/Settings/`. Store (SQLite) path overridable with `TRACKIFY_STORE`. Single `net10.0`; hub control only on Linux/Pi (BlueZ), a no-op elsewhere. @@ -131,3 +146,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 EF Core migrations under `Infrastructure/Persistence/Migrations` (arc42 ADR-18) | +| `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`. 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/README.md b/README.md index 5f53230..96a06a3 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ Full architecture documentation lives in **[docs/arc42/](docs/arc42/)**, followi | Project | Layer | Notes | |---|---|---| -| `Source/Trackify.Domain` | Domain | Entities (`Train`, `TrackSegment`), enums, `SpeedFunction` | +| `Source/Trackify.Domain` | Domain | Entities (`Train`, `TrackPart`, `TrackLink`), enums, `Tracks/` geometry, `SpeedFunction` | | `Source/Trackify.Application` | Application | Ports, `TrainControlService`, `LegoinoCatalog`; hosts the mobile/WinRT `ILegoService` impls (multi-targeted per build host) | | `Source/Trackify.Infrastructure` | Infrastructure | EF Core + SQLite store, BlueZ (Linux) transport | | `Source/Trackify` | Front-end | Uno app — heads: android, ios, browserwasm (**Web**), desktop, windows | @@ -180,7 +180,7 @@ Die vollständige Architekturdokumentation liegt in **[docs/arc42/](docs/arc42/) | Projekt | Schicht | Hinweise | |---|---|---| -| `Source/Trackify.Domain` | Domain | Entities (`Train`, `TrackSegment`), Enums, `SpeedFunction` | +| `Source/Trackify.Domain` | Domain | Entities (`Train`, `TrackPart`, `TrackLink`), Enums, `Tracks/` geometry, `SpeedFunction` | | `Source/Trackify.Application` | Application | Ports, `TrainControlService`, `LegoinoCatalog`; enthält die Mobile-/WinRT-`ILegoService`-Impls (multi-targeted je nach Build-Host) | | `Source/Trackify.Infrastructure` | Infrastructure | EF-Core-+-SQLite-Store, BlueZ-Transport (Linux) | | `Source/Trackify` | Front-End | Uno-App — Heads: android, ios, browserwasm (**Web**), desktop, windows | diff --git a/Source/Trackify.Application/Catalog/LegoinoCatalog.cs b/Source/Trackify.Application/Catalog/LegoinoCatalog.cs index 926c3af..c33fbc0 100644 --- a/Source/Trackify.Application/Catalog/LegoinoCatalog.cs +++ b/Source/Trackify.Application/Catalog/LegoinoCatalog.cs @@ -70,16 +70,6 @@ public static class LegoinoCatalog new(SensorActionType.ReverseDirection, "Richtung wechseln"), ]; - /// The Streckenplaner's "Teil anhängen" toolbar (Gerade/Kurve links/Kurve rechts/Weiche/Bahnhof). - public static readonly IReadOnlyList TrackParts = - [ - new(SegmentType.Straight, null, "Gerade"), - new(SegmentType.Curve, CurveDirection.Left, "Kurve links"), - new(SegmentType.Curve, CurveDirection.Right, "Kurve rechts"), - new(SegmentType.Switch, null, "Weiche"), - new(SegmentType.Station, null, "Bahnhof"), - ]; - public static HubOption Hub(HubType value) => Hubs.First(h => h.Value == value); public static DeviceOption Device(DeviceType value) => Devices.First(d => d.Value == value); public static ColorOption Color(LedColorType value) => Colors.First(c => c.Value == value); diff --git a/Source/Trackify.Application/Catalog/TrackPartOption.cs b/Source/Trackify.Application/Catalog/TrackPartOption.cs deleted file mode 100644 index a06b414..0000000 --- a/Source/Trackify.Application/Catalog/TrackPartOption.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Trackify.Application.Catalog; - -/// -/// Selectable track-part option for the Streckenplaner's "Teil anhängen" toolbar (Gerade/Kurve -/// links/Kurve rechts/Weiche/Bahnhof) — the segment plus, for a curve, which -/// way it bends. -/// -public sealed record TrackPartOption(SegmentType Type, CurveDirection? Curve, string Label); diff --git a/Source/Trackify.Application/DependencyInjection.cs b/Source/Trackify.Application/DependencyInjection.cs index 49d5b8c..57a20af 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; @@ -11,7 +12,7 @@ public static class DependencyInjection { /// /// Registers the Application use-case services (, - /// , ) and — filtered per platform right here in DI — the matching + /// , ) and — filtered per platform right here in DI — the matching /// transport via its Add…Lego helper (mirroring the CLI's /// AddLinuxLego): Android → AddAndroidLego, iOS → AddIosLego, Windows → /// AddWindowsLego. The plain net10.0 flavor registers none — the composition root decides @@ -21,7 +22,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.Application/Trains/ITrackPlanService.cs b/Source/Trackify.Application/Trains/ITrackPlanService.cs deleted file mode 100644 index 7ed4857..0000000 --- a/Source/Trackify.Application/Trains/ITrackPlanService.cs +++ /dev/null @@ -1,45 +0,0 @@ -namespace Trackify.Application.Trains; - -/// Result of inserting a Weiche: the updated segment list plus the new branch strand's id. -public sealed record SwitchInsertResult(IReadOnlyList Segments, Guid BranchStrandId); - -/// -/// Shared use-case for building a track plan: every "smart" rule from the Streckenplaner design -/// (appending inherits speed/direction/f(x) from the predecessor, a Weiche spawns its own branch -/// strand, templates lay out a whole loop in one step) lives here instead of the ViewModel — the same -/// separation gives train control. Operates on plain -/// lists; callers own persistence via . -/// -public interface ITrackPlanService -{ - /// - /// Appends a new part to 's open (last) end. The new segment inherits - /// max speed, direction, and accel/brake functions from the strand's current last segment (or, - /// for a fresh branch strand, from the Weiche it branches off). is - /// required when is . - /// - IReadOnlyList AppendPart( - IReadOnlyList segments, Guid strandId, SegmentType type, CurveDirection? curve = null); - - /// - /// Inserts a Weiche at 's open end and creates the branch strand it - /// peels off into (). The branch starts empty; the caller - /// decides whether to keep building on the trunk or switch the "active strand" to the branch. - /// - SwitchInsertResult InsertSwitch(IReadOnlyList segments, Guid strandId, CurveDirection branchDirection); - - /// Replaces every strand with a canned layout (Oval/Acht/Punkt-zu-Punkt), all on the main strand. - IReadOnlyList ApplyTemplate(TemplateType template); - - /// Duplicates one segment, inserting the copy immediately after it in the same strand. - IReadOnlyList Duplicate(IReadOnlyList segments, Guid segmentId); - - /// Swaps a segment with its neighbour in travel order ( = toward the start). - IReadOnlyList Reorder(IReadOnlyList segments, Guid segmentId, bool up); - - /// Removes a segment. Deleting a Weiche also removes the branch strand it anchors. - IReadOnlyList Delete(IReadOnlyList segments, Guid segmentId); - - /// Empties the plan entirely ("Leeren"). - IReadOnlyList Clear(); -} diff --git a/Source/Trackify.Application/Trains/ITrackSegmentRepository.cs b/Source/Trackify.Application/Trains/ITrackSegmentRepository.cs deleted file mode 100644 index f47d3e2..0000000 --- a/Source/Trackify.Application/Trains/ITrackSegmentRepository.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Trackify.Application.Common; - -namespace Trackify.Application.Trains; - -/// -/// Repository for the planned track segments — mirrors : the default -/// CRUD from is all the front-ends need today. -/// -public interface ITrackSegmentRepository : IBaseRepository; diff --git a/Source/Trackify.Application/Trains/TemplateType.cs b/Source/Trackify.Application/Trains/TemplateType.cs deleted file mode 100644 index fef3344..0000000 --- a/Source/Trackify.Application/Trains/TemplateType.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Trackify.Application.Trains; - -/// A canned segment layout can generate in one step. -public enum TemplateType -{ - Oval, - Acht, - PunktZuPunkt, -} diff --git a/Source/Trackify.Application/Trains/TrackPlanService.cs b/Source/Trackify.Application/Trains/TrackPlanService.cs deleted file mode 100644 index 3781180..0000000 --- a/Source/Trackify.Application/Trains/TrackPlanService.cs +++ /dev/null @@ -1,185 +0,0 @@ -namespace Trackify.Application.Trains; - -/// -public sealed class TrackPlanService : ITrackPlanService -{ - public IReadOnlyList AppendPart( - IReadOnlyList segments, Guid strandId, SegmentType type, CurveDirection? curve = null) - { - var strand = StrandSorted(segments, strandId); - var predecessor = strand.Count > 0 ? strand[^1] : FindAnchor(segments, strandId); - - var part = new TrackSegmentDto - { - Id = Guid.CreateVersion7(), - Name = NextName(segments, type, curve), - Type = type, - Curve = type == SegmentType.Curve ? curve ?? CurveDirection.Right : null, - MaxSpeed = predecessor?.MaxSpeed ?? 70, - Direction = predecessor?.Direction ?? TrackDirection.Forward, - AccelFn = predecessor?.AccelFn ?? SpeedFunctionType.EaseOut, - BrakeFn = predecessor?.BrakeFn ?? SpeedFunctionType.EaseIn, - StrandId = strandId, - Order = strand.Count, - }; - - return [.. segments, part]; - } - - public SwitchInsertResult InsertSwitch(IReadOnlyList segments, Guid strandId, CurveDirection branchDirection) - { - var appended = AppendPart(segments, strandId, SegmentType.Switch); - var switchSegment = appended[^1]; - var branchStrandId = Guid.CreateVersion7(); - - var updated = appended - .Select(s => s.Id == switchSegment.Id ? s with { BranchStrandId = branchStrandId, BranchDirection = branchDirection } : s) - .ToList(); - - return new SwitchInsertResult(updated, branchStrandId); - } - - public IReadOnlyList ApplyTemplate(TemplateType template) - { - var parts = template switch - { - TemplateType.Oval => OvalParts, - TemplateType.Acht => OvalParts.Concat(MirroredOvalParts), - TemplateType.PunktZuPunkt => PunktZuPunktParts, - _ => [], - }; - - var segments = new List(); - var order = 0; - foreach (var (type, curve) in parts) - { - segments.Add(new TrackSegmentDto - { - Id = Guid.CreateVersion7(), - Name = NextName(segments, type, curve), - Type = type, - Curve = curve, - MaxSpeed = 70, - StrandId = Guid.Empty, - Order = order++, - }); - } - - return segments; - } - - public IReadOnlyList Duplicate(IReadOnlyList segments, Guid segmentId) - { - var source = segments.First(s => s.Id == segmentId); - var strand = StrandSorted(segments, source.StrandId); - var index = strand.FindIndex(s => s.Id == segmentId); - - strand.Insert(index + 1, source with { Id = Guid.CreateVersion7(), Name = source.Name + " (Kopie)" }); - - return ReplaceStrand(segments, source.StrandId, strand); - } - - public IReadOnlyList Reorder(IReadOnlyList segments, Guid segmentId, bool up) - { - var source = segments.First(s => s.Id == segmentId); - var strand = StrandSorted(segments, source.StrandId); - var index = strand.FindIndex(s => s.Id == segmentId); - var swapWith = up ? index - 1 : index + 1; - - if (swapWith < 0 || swapWith >= strand.Count) return segments; - - (strand[index], strand[swapWith]) = (strand[swapWith], strand[index]); - - return ReplaceStrand(segments, source.StrandId, strand); - } - - public IReadOnlyList Delete(IReadOnlyList segments, Guid segmentId) - { - var source = segments.First(s => s.Id == segmentId); - var remaining = segments.Where(s => s.Id != segmentId).ToList(); - - if (source.Type == SegmentType.Switch && source.BranchStrandId is { } branchId) - { - remaining = RemoveStrandCascade(remaining, branchId); - } - - var strand = StrandSorted(remaining, source.StrandId); - return ReplaceStrand(remaining, source.StrandId, strand); - } - - public IReadOnlyList Clear() => []; - - // Every strand's open end to append to: its own last segment by Order, or — for a fresh, empty - // branch strand — the Weiche that anchors it (so the branch's first part still inherits speed/ - // direction/f(x), matching "erbt ... vom Vorgänger" even before the branch has segments of its own). - private static TrackSegmentDto? FindAnchor(IReadOnlyList segments, Guid strandId) - => strandId == Guid.Empty ? null : segments.FirstOrDefault(s => s.Type == SegmentType.Switch && s.BranchStrandId == strandId); - - private static List StrandSorted(IReadOnlyList segments, Guid strandId) - => [.. segments.Where(s => s.StrandId == strandId).OrderBy(s => s.Order)]; - - // Renumbers the given strand's Order to its list position and splices it back into the full set. - private static IReadOnlyList ReplaceStrand(IReadOnlyList segments, Guid strandId, List strand) - { - for (var i = 0; i < strand.Count; i++) strand[i] = strand[i] with { Order = i }; - return [.. segments.Where(s => s.StrandId != strandId), .. strand]; - } - - // Deleting a Weiche takes its whole branch with it, including any further Weichen nested inside. - private static List RemoveStrandCascade(List segments, Guid strandId) - { - var branchSegments = segments.Where(s => s.StrandId == strandId).ToList(); - var result = segments.Where(s => s.StrandId != strandId).ToList(); - - foreach (var segment in branchSegments) - { - if (segment.Type == SegmentType.Switch && segment.BranchStrandId is { } nestedBranchId) - { - result = RemoveStrandCascade(result, nestedBranchId); - } - } - - return result; - } - - private static string NextName(IReadOnlyList segments, SegmentType type, CurveDirection? curve) - => $"{PartLabel(type, curve)} {segments.Count(s => s.Type == type && s.Curve == curve) + 1}"; - - private static string PartLabel(SegmentType type, CurveDirection? curve) => type switch - { - SegmentType.Straight => "Gerade", - SegmentType.Curve => curve == CurveDirection.Left ? "Kurve links" : "Kurve rechts", - SegmentType.Switch => "Weiche", - SegmentType.Station => "Bahnhof", - _ => "Segment", - }; - - // A running oval: half straight, half station (the "top" straight split at its midpoint, matching - // the original hard-coded seed), a 180° turn (two 90° curve pieces), the "bottom" straight (also - // split in two), then the opposite 180° turn back to the start heading. - private static readonly (SegmentType Type, CurveDirection? Curve)[] OvalParts = - [ - (SegmentType.Straight, null), (SegmentType.Station, null), - (SegmentType.Curve, CurveDirection.Right), (SegmentType.Curve, CurveDirection.Right), - (SegmentType.Straight, null), (SegmentType.Straight, null), - (SegmentType.Curve, CurveDirection.Right), (SegmentType.Curve, CurveDirection.Right), - ]; - - // The second lobe, turning the other way. OvalParts ends back at the start pose, so this one has to - // *lead* with its 180° turn: leading with straights (like OvalParts does) would retrace the first - // lobe's opening straight and station piece-for-piece — two segments stacked at identical - // coordinates. Turning away first and closing with the straights instead puts the lobe on the far - // side of the shared point, which is what makes the pair read as a figure-eight. - private static readonly (SegmentType Type, CurveDirection? Curve)[] MirroredOvalParts = - [ - (SegmentType.Curve, CurveDirection.Left), (SegmentType.Curve, CurveDirection.Left), - (SegmentType.Straight, null), (SegmentType.Straight, null), - (SegmentType.Curve, CurveDirection.Left), (SegmentType.Curve, CurveDirection.Left), - (SegmentType.Straight, null), (SegmentType.Station, null), - ]; - - private static readonly (SegmentType Type, CurveDirection? Curve)[] PunktZuPunktParts = - [ - (SegmentType.Station, null), (SegmentType.Straight, null), (SegmentType.Straight, null), (SegmentType.Station, null), - ]; -} diff --git a/Source/Trackify.Application/Trains/TrackSegmentDto.cs b/Source/Trackify.Application/Trains/TrackSegmentDto.cs deleted file mode 100644 index 5021578..0000000 --- a/Source/Trackify.Application/Trains/TrackSegmentDto.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace Trackify.Application.Trains; - -/// -/// Data-transfer view of a saved track segment. Mirrors 's boundary role: the -/// Domain entity never leaks past the use-case layer. Map with . -/// -public sealed record TrackSegmentDto -{ - public Guid Id { get; init; } - public string Name { get; set; } = ""; - public SegmentType Type { get; set; } = SegmentType.Straight; - public int MaxSpeed { get; set; } = 70; - public TrackDirection Direction { get; set; } = TrackDirection.Forward; - public SpeedFunctionType AccelFn { get; set; } = SpeedFunctionType.EaseOut; - public SpeedFunctionType BrakeFn { get; set; } = SpeedFunctionType.EaseIn; - public SensorType Sensor { get; set; } = SensorType.None; - public SensorActionType Action { get; set; } = SensorActionType.Notify; - public int SlowTarget { get; set; } = 30; - public Guid StrandId { get; set; } = Guid.Empty; - public int Order { get; set; } - public CurveDirection? Curve { get; set; } - public Guid? BranchStrandId { get; set; } - public CurveDirection? BranchDirection { get; set; } - public SwitchRoute Route { get; set; } = SwitchRoute.Main; - public Guid? BranchLinkSegmentId { get; set; } -} diff --git a/Source/Trackify.Application/Trains/TrackSegmentMapping.cs b/Source/Trackify.Application/Trains/TrackSegmentMapping.cs deleted file mode 100644 index 5635053..0000000 --- a/Source/Trackify.Application/Trains/TrackSegmentMapping.cs +++ /dev/null @@ -1,47 +0,0 @@ -namespace Trackify.Application.Trains; - -/// Maps between the Domain entity and the boundary . -public static class TrackSegmentMapping -{ - public static TrackSegmentDto ToDto(this TrackSegment segment) => new() - { - Id = segment.Id, - Name = segment.Name, - Type = segment.Type, - MaxSpeed = segment.MaxSpeed, - Direction = segment.Direction, - AccelFn = segment.AccelFn, - BrakeFn = segment.BrakeFn, - Sensor = segment.Sensor, - Action = segment.Action, - SlowTarget = segment.SlowTarget, - StrandId = segment.StrandId, - Order = segment.Order, - Curve = segment.Curve, - BranchStrandId = segment.BranchStrandId, - BranchDirection = segment.BranchDirection, - Route = segment.Route, - BranchLinkSegmentId = segment.BranchLinkSegmentId, - }; - - public static TrackSegment ToEntity(this TrackSegmentDto dto) => new() - { - Id = dto.Id, - Name = dto.Name, - Type = dto.Type, - MaxSpeed = dto.MaxSpeed, - Direction = dto.Direction, - AccelFn = dto.AccelFn, - BrakeFn = dto.BrakeFn, - Sensor = dto.Sensor, - Action = dto.Action, - SlowTarget = dto.SlowTarget, - StrandId = dto.StrandId, - Order = dto.Order, - Curve = dto.Curve, - BranchStrandId = dto.BranchStrandId, - BranchDirection = dto.BranchDirection, - Route = dto.Route, - BranchLinkSegmentId = dto.BranchLinkSegmentId, - }; -} 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..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; }); @@ -63,6 +69,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/Enums/SegmentType.cs b/Source/Trackify.Domain/Enums/SegmentType.cs deleted file mode 100644 index a21c38f..0000000 --- a/Source/Trackify.Domain/Enums/SegmentType.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Trackify.Domain.Enums; - -public enum SegmentType -{ - Straight, - Curve, - Station, - Switch, -} diff --git a/Source/Trackify.Domain/Enums/SwitchRoute.cs b/Source/Trackify.Domain/Enums/SwitchRoute.cs index 93bdc0b..6648c34 100644 --- a/Source/Trackify.Domain/Enums/SwitchRoute.cs +++ b/Source/Trackify.Domain/Enums/SwitchRoute.cs @@ -1,6 +1,6 @@ namespace Trackify.Domain.Enums; -/// Which way a segment is currently thrown. +/// Which way a part is currently thrown. public enum SwitchRoute { Main, diff --git a/Source/Trackify.Domain/Enums/TrackPartKind.cs b/Source/Trackify.Domain/Enums/TrackPartKind.cs new file mode 100644 index 0000000..4f0bef4 --- /dev/null +++ b/Source/Trackify.Domain/Enums/TrackPartKind.cs @@ -0,0 +1,23 @@ +namespace Trackify.Domain.Enums; + +/// +/// A physical LEGO track part the planner can lay — 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/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/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/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/TrackJoinMismatch.cs b/Source/Trackify.Domain/Tracks/TrackJoinMismatch.cs new file mode 100644 index 0000000..adc7eff --- /dev/null +++ b/Source/Trackify.Domain/Tracks/TrackJoinMismatch.cs @@ -0,0 +1,28 @@ +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) — + /// so a loop one curve short reports -1 rather than 15. + /// + public int HeadingStepsOff => TrackMetrics.ShortestHeadingStep(Expected.Heading, Actual.Heading); +} 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/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 new file mode 100644 index 0000000..922a982 --- /dev/null +++ b/Source/Trackify.Domain/Tracks/TrackMetrics.cs @@ -0,0 +1,58 @@ +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; + + /// + /// 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/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/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/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/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.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/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/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/Source/Trackify.Domain/Trains/TrackSegment.cs b/Source/Trackify.Domain/Trains/TrackSegment.cs deleted file mode 100644 index fbe5752..0000000 --- a/Source/Trackify.Domain/Trains/TrackSegment.cs +++ /dev/null @@ -1,47 +0,0 @@ -namespace Trackify.Domain.Trains; - -/// -/// The persisted, transport-agnostic configuration of one track segment (drive behaviour + sensor). -/// Pure data: the canvas geometry/SVG and German labels that the planner renders live in the -/// presentation layer, not here. -/// -/// Segments form strands: an ordered chain ( + ) that a -/// part is always appended to at its open (last) end. A segment is -/// the trunk continuing straight through the switch point *and* the anchor a second, branch strand -/// () starts from — so a switch never needs its own geometry, only a -/// pointer to where its branch begins. -/// -public sealed record TrackSegment : BaseEntity -{ - public string Name { get; set; } = ""; - public SegmentType Type { get; set; } = SegmentType.Straight; - public int MaxSpeed { get; set; } = 70; - public TrackDirection Direction { get; set; } = TrackDirection.Forward; - public SpeedFunctionType AccelFn { get; set; } = SpeedFunctionType.EaseOut; - public SpeedFunctionType BrakeFn { get; set; } = SpeedFunctionType.EaseIn; - public SensorType Sensor { get; set; } = SensorType.None; - public SensorActionType Action { get; set; } = SensorActionType.Notify; - public int SlowTarget { get; set; } = 30; - - /// Which strand this segment belongs to. The main strand uses . - public Guid StrandId { get; set; } = Guid.Empty; - - /// Position within , 0-based in travel order. - public int Order { get; set; } - - /// Set when is : which way it bends. - public CurveDirection? Curve { get; set; } - - /// Switch-only: the strand id of the branch this switch creates. - public Guid? BranchStrandId { get; set; } - - /// Switch-only: which way the branch peels off from the trunk. - public CurveDirection? BranchDirection { get; set; } - - /// Switch-only: which route is currently thrown ("Gestellt auf" in the planner). - public SwitchRoute Route { get; set; } = SwitchRoute.Main; - - /// Switch-only, optional: the segment in another strand the branch's open end connects - /// back to, closing a loop ("Zweig-Ende anschließen an" in the planner). - public Guid? BranchLinkSegmentId { get; set; } -} diff --git a/Source/Trackify.Infrastructure/DependencyInjection.cs b/Source/Trackify.Infrastructure/DependencyInjection.cs index 3dd5782..b1648e9 100644 --- a/Source/Trackify.Infrastructure/DependencyInjection.cs +++ b/Source/Trackify.Infrastructure/DependencyInjection.cs @@ -24,7 +24,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..6208109 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(); + TrackifySchema.BringUpToDate(database); } public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) diff --git a/Source/Trackify.Infrastructure/Persistence/Migrations/20261015122519_InitialCreate.Designer.cs b/Source/Trackify.Infrastructure/Persistence/Migrations/20261015122519_InitialCreate.Designer.cs new file mode 100644 index 0000000..4077d5d --- /dev/null +++ b/Source/Trackify.Infrastructure/Persistence/Migrations/20261015122519_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("20261015122519_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/20261015122519_InitialCreate.cs b/Source/Trackify.Infrastructure/Persistence/Migrations/20261015122519_InitialCreate.cs new file mode 100644 index 0000000..3885244 --- /dev/null +++ b/Source/Trackify.Infrastructure/Persistence/Migrations/20261015122519_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/20261015125746_DropTrackSegments.Designer.cs b/Source/Trackify.Infrastructure/Persistence/Migrations/20261015125746_DropTrackSegments.Designer.cs new file mode 100644 index 0000000..681b063 --- /dev/null +++ b/Source/Trackify.Infrastructure/Persistence/Migrations/20261015125746_DropTrackSegments.Designer.cs @@ -0,0 +1,256 @@ +// +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("20261015125746_DropTrackSegments")] + partial class DropTrackSegments + { + /// + 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.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/20261015125746_DropTrackSegments.cs b/Source/Trackify.Infrastructure/Persistence/Migrations/20261015125746_DropTrackSegments.cs new file mode 100644 index 0000000..d86b89d --- /dev/null +++ b/Source/Trackify.Infrastructure/Persistence/Migrations/20261015125746_DropTrackSegments.cs @@ -0,0 +1,51 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Trackify.Infrastructure.Persistence.Migrations +{ + /// + public partial class DropTrackSegments : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TrackSegments"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "TrackSegments", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + AccelFn = table.Column(type: "TEXT", nullable: false), + Action = table.Column(type: "TEXT", nullable: false), + BrakeFn = table.Column(type: "TEXT", nullable: false), + BranchDirection = table.Column(type: "TEXT", nullable: true), + BranchLinkSegmentId = table.Column(type: "TEXT", nullable: true), + BranchStrandId = table.Column(type: "TEXT", nullable: true), + Curve = table.Column(type: "TEXT", nullable: true), + DateCreated = table.Column(type: "INTEGER", nullable: false), + DateUpdated = table.Column(type: "INTEGER", nullable: false), + Direction = table.Column(type: "TEXT", nullable: false), + MaxSpeed = table.Column(type: "INTEGER", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + Order = table.Column(type: "INTEGER", nullable: false), + Route = table.Column(type: "TEXT", nullable: false), + Sensor = table.Column(type: "TEXT", nullable: false), + SlowTarget = table.Column(type: "INTEGER", nullable: false), + StrandId = table.Column(type: "TEXT", nullable: false), + Type = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TrackSegments", x => x.Id); + }); + } + } +} diff --git a/Source/Trackify.Infrastructure/Persistence/Migrations/TrackifyDbContextModelSnapshot.cs b/Source/Trackify.Infrastructure/Persistence/Migrations/TrackifyDbContextModelSnapshot.cs new file mode 100644 index 0000000..ec5d73f --- /dev/null +++ b/Source/Trackify.Infrastructure/Persistence/Migrations/TrackifyDbContextModelSnapshot.cs @@ -0,0 +1,253 @@ +// +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.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..424c558 --- /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(); + TrackifySchema.BringUpToDate(database); + } + + 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/SqliteTrackSegmentRepository.cs b/Source/Trackify.Infrastructure/Persistence/SqliteTrackSegmentRepository.cs deleted file mode 100644 index 4401c71..0000000 --- a/Source/Trackify.Infrastructure/Persistence/SqliteTrackSegmentRepository.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace Trackify.Infrastructure.Persistence; - -/// -/// EF Core + SQLite repository for the planned track segments — the default CRUD comes from -/// , mirroring . Same -/// trackify.db as the trains, so both are readable from the CLI and the app. -/// -public sealed class SqliteTrackSegmentRepository(IDbContextFactory dbContextFactory) - : BaseRepository(dbContextFactory), ITrackSegmentRepository; diff --git a/Source/Trackify.Infrastructure/Persistence/TrackifyDbContext.cs b/Source/Trackify.Infrastructure/Persistence/TrackifyDbContext.cs index 1825ea8..9369d26 100644 --- a/Source/Trackify.Infrastructure/Persistence/TrackifyDbContext.cs +++ b/Source/Trackify.Infrastructure/Persistence/TrackifyDbContext.cs @@ -3,19 +3,44 @@ 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) { public DbSet Trains => Set(); - 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/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.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/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..0c69507 --- /dev/null +++ b/Source/Trackify/Presentation/Components/PieceList.xaml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 c0bd691..9be36fe 100644 --- a/Source/Trackify/Presentation/Components/SegmentInspector.xaml +++ b/Source/Trackify/Presentation/Components/SegmentInspector.xaml @@ -11,7 +11,7 @@ - @@ -26,7 +26,7 @@ - @@ -35,15 +35,15 @@ + Background="{Binding SelectedPiece.SpeedColor, Converter={StaticResource HexToBrushConverter}}" /> - + - - @@ -62,12 +62,12 @@ - + - + @@ -78,19 +78,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 @@ - - - - - - + + + + + - - + + - + + - - - - - - - - - - - - + + - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - - - - - - + + - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - + + + + - - - - - - - - - - - - - - - - - - - - - - -