diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..da3e8db --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + +jobs: + build: + name: Build & verify (Java 21) + runs-on: ubuntu-latest + + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + cache: maven + + # Runs tests and all quality gates (Checkstyle, PMD, SpotBugs, JaCoCo). + # Headless: the app is never launched, so no display is required. + - name: Build and verify + run: mvn -B verify + + - name: Upload reports on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: build-reports + path: | + target/site + target/checkstyle-result.xml + target/pmd.xml + target/spotbugsXml.xml + target/surefire-reports + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 524f096..bac31be 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,14 @@ +# Maven build output +target/ + +# IDE files +.idea/ +*.iml +.vscode/ +.settings/ +.classpath +.project + # Compiled class file *.class diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a0243a5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,166 @@ +# AGENTS.md + +Guidance for AI coding agents (Claude Code and compatible tools) working in this +repository. Read this before making changes. + +## Project overview + +**Gleisnetz-Editor** is a desktop **JavaFX** application for drawing a +**topological rail network** and attaching domain data to it: + +- **Knoten / nodes** (`TrackNode`) — points in the network (stations, junctions). +- **Kanten / edges** (`TrackEdge`) — track edges connecting two nodes. +- **Fachdaten** such as **signals** (`Signal`) positioned by **linear + referencing**: a signal belongs to an edge and sits at a relative position + (0.0 = from-node … 1.0 = to-node) on a chosen side. It has no coordinates of + its own, so it stays correct when nodes move. + +The layout is **topological, not geographic**: node coordinates are schematic +(canvas pixels) and express connectivity, not real-world position. + +- **Language:** Java 21 (LTS) · **UI:** JavaFX 21 (FXML + Canvas + CSS) +- **Build:** Maven · **Tests:** JUnit 5 · **Modules:** JPMS + +## Architecture (keep these layers separate) + +``` +com.example.vibeapp App JavaFX entry point +com.example.vibeapp.model TrackNode, TrackEdge, Signal, Side, EdgeRef, + TrackNetwork pure domain model (no JavaFX imports) + SampleNetworks example topological Switzerland +com.example.vibeapp.geometry Vec2, Geometry linear-referencing math (no JavaFX) +com.example.vibeapp.view NetworkEditorController tool state + mouse input + NetworkRenderer draws the model on a Canvas +``` + +- **`model` and `geometry` are UI-free** and fully unit-tested — this is the + core invariant. Keep JavaFX types out of them so the domain logic stays + testable without the toolkit. +- **`view` is the only package that may import JavaFX.** The controller owns + transient editing state (active tool, drag/edge-pending selection) and turns + input into model operations; the renderer only draws. + +### Resources & module wiring + +``` +src/main/resources/com/example/vibeapp/ + network-editor.fxml toolbar + canvas + status bar (fx:controller = NetworkEditorController) + styles.css dark theme +src/main/java/module-info.java opens ...view to javafx.fxml; exports base package +``` + +## Architecture guardrails + +Hard limits that keep classes and methods small and readable. New and changed +code must respect them — refactor rather than exceed them. + +- **Methods: at most 3 parameters.** If a method needs more, introduce a + parameter object / small record, or split the method. The limit targets + methods; a simple data holder's constructor may carry the fields it needs, but + when it would exceed ~4 prefer a builder, a factory, or a `record`. +- **Classes: at most 500 lines of code (LOC).** A class that grows past this is + doing too much — split it by responsibility. + +These limits are meant to be enforced by static analysis in the build (see +[Quality tooling](#quality-tooling)); `mvn verify` should fail on a violation +rather than relying on review to catch it. + +## Commands + +Run from the repository root. + +| Task | Command | +| ------------------- | ------------------- | +| Build | `mvn compile` | +| Run the tests | `mvn test` | +| Full verify | `mvn verify` | +| Run the app | `mvn javafx:run` | +| Clean | `mvn clean` | + +> `mvn javafx:run` needs a graphical display. In a headless environment it will +> not open a window — use `mvn test` / `mvn verify` there. (A scene can be +> rendered headless via `Scene.snapshot` under `xvfb-run` with +> `-Dprism.order=sw` if a preview image is needed.) + +## Editing model (how the UI works) + +The toolbar selects a **tool**; clicks on the canvas act according to it: + +- **Auswahl (SELECT):** click a node and drag to move it. +- **Knoten (NODE):** click empty space to add a node. +- **Kante (EDGE):** click a start node, then a target node to connect them. +- **Signal (SIGNAL):** click on/near an edge; the signal is placed at the + nearest position along that edge (`Geometry.nearestParam`). + +`Beispielnetz` reloads the sample Switzerland; `Leeren` empties the network. + +## Conventions (priority: clean & structured) + +- **Domain logic stays in `model` / `geometry` with no JavaFX imports**, so it + can be unit-tested. Controllers only wire logic to the view. +- **Every non-trivial class or logic change gets a test** under + `src/test/java`, mirroring the package. Name them `Test`. +- **Geometry lives in `Geometry`** as pure, side-effect-free static methods — + add new linear-referencing math there and test it, don't inline it in the view. +- **FXML for layout, CSS for styling.** Don't hard-code colors/sizes in Java; + put them in `styles.css` and `*.fxml`. +- **Keep `module-info.java` correct.** New FXML-bound packages need an `opens` + entry; new third-party modules need a `requires` entry. +- **Style:** 4-space indent, one top-level class per file, standard Java naming, + short Javadoc on public classes and non-obvious methods. Small, focused commits. + +## Definition of done + +1. `mvn verify` passes (compiles cleanly, all tests green). +2. New or changed logic is covered by a test in `model` or `geometry`. +3. `module-info.java`, FXML `fx:controller`, and `pom.xml` `mainClass` stay + consistent with the code. +4. No unused imports, dead code, or leftover commented-out blocks. + +## How to extend + +- **New fachdatum type** (e.g. a balise or speed board): add a model class in + `model` referencing an edge + relative position (mirror `Signal`), store it in + `TrackNetwork`, render it in `NetworkRenderer`, add a tool in the controller. +- **New geometry** (e.g. curved edges, distance-in-meters): add pure methods to + `Geometry` (+ tests) before touching the view. +- **New screen:** add `*-view.fxml` + a controller in `view`, load it from `App`. +- **New dependency:** add it to `pom.xml` and a `requires` line in `module-info.java`. + +## Quality tooling + +Static analysis and coverage run in the `verify` phase, so the guardrails above +and common bug patterns fail the build automatically, not just in review. Run +everything with **`mvn verify`**. Config lives under `config/`. + +| Tool | Plugin | Enforces | Config | +| ---- | ------ | -------- | ------ | +| **Checkstyle** | `maven-checkstyle-plugin` | The guardrails: `ParameterNumber` max 3 (methods **and** constructors) + `FileLength` max 500 | `config/checkstyle/checkstyle.xml` | +| **PMD** | `maven-pmd-plugin` | Dead code + likely bugs (curated, small ruleset) | `config/pmd/ruleset.xml` | +| **SpotBugs** | `spotbugs-maven-plugin` | Bytecode bug patterns (effort Max, threshold Medium) | `config/spotbugs/exclude.xml` | +| **JaCoCo** | `jacoco-maven-plugin` | Test coverage ≥ 80 % line coverage on `model` + `geometry` (`view`/`App` excluded as they need the running UI) | inline in `pom.xml` | + +Conventions for the tooling: + +- **Keep rulesets small and meaningful** — every enabled rule must earn its + place. Checkstyle owns the parameter/size guardrails; PMD does **not** repeat + them (no overlap/conflict). +- **Suppress narrowly and with a documented reason; never weaken a guardrail + globally.** How to suppress each tool: + - Checkstyle: `@SuppressWarnings("checkstyle:ParameterNumber")` on the member, + with a comment saying why (see `Geometry.offsetPoint`, `NetworkRenderer.render`). + - PMD: a `violationSuppressXPath` in the ruleset (e.g. `@FXML` methods are + reflectively invoked, so they are exempt from `UnusedPrivateMethod`). + - SpotBugs: a `` in `config/spotbugs/exclude.xml`. +- **`module-info.java` is excluded from Checkstyle** (it is not a class). +- The two known guardrail exceptions (`offsetPoint`, `render`, both 5 params) + are suppressed intentionally; a future refactor may fold their arguments into + value objects (`Segment`, a view-state object). + +## Things to avoid + +- Do not import JavaFX into `model` or `geometry`. +- Do not give fachdaten absolute coordinates — reference them to an edge. +- Do not put geometry math or magic values directly in controllers. +- Do not commit build output (`target/`) or IDE files — they are gitignored. +- Do not add a dependency without updating `module-info.java`. diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml new file mode 100644 index 0000000..05a59f4 --- /dev/null +++ b/config/checkstyle/checkstyle.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/config/pmd/ruleset.xml b/config/pmd/ruleset.xml new file mode 100644 index 0000000..578ac43 --- /dev/null +++ b/config/pmd/ruleset.xml @@ -0,0 +1,31 @@ + + + + + Curated, high-signal PMD rules. Kept small on purpose: PMD catches likely + bugs and dead code here; the parameter/size guardrails are owned by + Checkstyle so the two tools do not overlap or conflict. + + + + + + + + + + + + + + + + + + + diff --git a/config/spotbugs/exclude.xml b/config/spotbugs/exclude.xml new file mode 100644 index 0000000..2459151 --- /dev/null +++ b/config/spotbugs/exclude.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..86322ca --- /dev/null +++ b/pom.xml @@ -0,0 +1,198 @@ + + + 4.0.0 + + com.example + vibeapp + 0.1.0-SNAPSHOT + jar + + Vibe Coding App + A JavaFX frontend application. + + + UTF-8 + 21 + 21.0.5 + 5.11.3 + com.example.vibeapp.App + + + 3.6.0 + 10.21.4 + 3.26.0 + 4.8.6.6 + 0.8.12 + 0.80 + + + + + org.openjfx + javafx-controls + ${javafx.version} + + + org.openjfx + javafx-fxml + ${javafx.version} + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + + + org.openjfx + javafx-maven-plugin + 0.0.8 + + ${mainClass} + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + ${checkstyle.plugin.version} + + + com.puppycrawl.tools + checkstyle + ${checkstyle.version} + + + + config/checkstyle/checkstyle.xml + true + warning + false + + **/module-info.java + + + + checkstyle-guardrails + verify + + check + + + + + + + + org.apache.maven.plugins + maven-pmd-plugin + ${pmd.plugin.version} + + 21 + true + + config/pmd/ruleset.xml + + + + + pmd-check + verify + + check + + + + + + + + com.github.spotbugs + spotbugs-maven-plugin + ${spotbugs.plugin.version} + + Max + Medium + config/spotbugs/exclude.xml + + + + spotbugs-check + verify + + check + + + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco.plugin.version} + + + com/example/vibeapp/App.class + com/example/vibeapp/view/** + + + + + jacoco-prepare-agent + + prepare-agent + + + + jacoco-report + verify + + report + + + + jacoco-check + verify + + check + + + + + BUNDLE + + + LINE + COVEREDRATIO + ${coverage.minimum} + + + + + + + + + + + diff --git a/src/main/java/com/example/vibeapp/App.java b/src/main/java/com/example/vibeapp/App.java new file mode 100644 index 0000000..fd04b73 --- /dev/null +++ b/src/main/java/com/example/vibeapp/App.java @@ -0,0 +1,32 @@ +package com.example.vibeapp; + +import java.io.IOException; +import java.util.Objects; + +import javafx.application.Application; +import javafx.fxml.FXMLLoader; +import javafx.scene.Scene; +import javafx.stage.Stage; + +/** + * Application entry point. Bootstraps the JavaFX runtime, loads the network + * editor view and shows the primary window. + */ +public class App extends Application { + + @Override + public void start(Stage stage) throws IOException { + FXMLLoader loader = new FXMLLoader(getClass().getResource("network-editor.fxml")); + Scene scene = new Scene(loader.load(), 900, 640); + scene.getStylesheets().add( + Objects.requireNonNull(getClass().getResource("styles.css")).toExternalForm()); + + stage.setTitle("Gleisnetz-Editor"); + stage.setScene(scene); + stage.show(); + } + + public static void main(String[] args) { + launch(args); + } +} diff --git a/src/main/java/com/example/vibeapp/geometry/Geometry.java b/src/main/java/com/example/vibeapp/geometry/Geometry.java new file mode 100644 index 0000000..568d8f9 --- /dev/null +++ b/src/main/java/com/example/vibeapp/geometry/Geometry.java @@ -0,0 +1,72 @@ +package com.example.vibeapp.geometry; + +import com.example.vibeapp.model.Side; + +/** + * Pure geometric helpers for linear referencing along straight track edges. + * + *

All methods are side-effect free and independent of JavaFX, which keeps the + * math that positions fachdaten (e.g. signals) on edges fully unit-testable. + */ +public final class Geometry { + + private Geometry() { + } + + /** + * Point at parameter {@code t} in [0,1] along the segment {@code a}→{@code b} + * (0 = a, 1 = b). + */ + public static Vec2 pointAt(Vec2 a, Vec2 b, double t) { + return new Vec2(a.x() + (b.x() - a.x()) * t, + a.y() + (b.y() - a.y()) * t); + } + + /** + * Parameter {@code t} in [0,1] of the point on segment {@code a}→{@code b} + * closest to {@code p}, clamped to the segment ends. Returns 0 for a + * degenerate (zero-length) segment. + */ + public static double nearestParam(Vec2 p, Vec2 a, Vec2 b) { + double dx = b.x() - a.x(); + double dy = b.y() - a.y(); + double lengthSq = dx * dx + dy * dy; + if (lengthSq == 0.0) { + return 0.0; + } + double t = ((p.x() - a.x()) * dx + (p.y() - a.y()) * dy) / lengthSq; + return Math.max(0.0, Math.min(1.0, t)); + } + + /** Shortest distance from {@code p} to the segment {@code a}→{@code b}. */ + public static double distanceToSegment(Vec2 p, Vec2 a, Vec2 b) { + return p.subtract(pointAt(a, b, nearestParam(p, a, b))).length(); + } + + /** + * Point at parameter {@code t} along {@code a}→{@code b}, shifted + * perpendicular to the segment by {@code offset} to the given side (relative + * to the a→b direction). Used to place edge attachments beside the track. + * + *

Screen coordinates (y grows downward): {@code LEFT} of travel direction + * points to smaller y. + */ + // Guardrail exception: 5 parameters. Kept as a focused geometric primitive + // for now; a future refactor may fold (a, b) into a Segment value object. + @SuppressWarnings("checkstyle:ParameterNumber") + public static Vec2 offsetPoint(Vec2 a, Vec2 b, double t, double offset, Side side) { + Vec2 base = pointAt(a, b, t); + double dx = b.x() - a.x(); + double dy = b.y() - a.y(); + double len = Math.hypot(dx, dy); + if (len == 0.0) { + return base; + } + // Left-hand normal relative to travel direction. + double nx = dy / len; + double ny = -dx / len; + double sign = (side == Side.LEFT) ? 1.0 : -1.0; + return new Vec2(base.x() + nx * offset * sign, + base.y() + ny * offset * sign); + } +} diff --git a/src/main/java/com/example/vibeapp/geometry/Vec2.java b/src/main/java/com/example/vibeapp/geometry/Vec2.java new file mode 100644 index 0000000..5940004 --- /dev/null +++ b/src/main/java/com/example/vibeapp/geometry/Vec2.java @@ -0,0 +1,16 @@ +package com.example.vibeapp.geometry; + +/** + * Minimal immutable 2D vector, deliberately free of any UI-framework types so + * geometry logic stays unit-testable without the JavaFX toolkit. + */ +public record Vec2(double x, double y) { + + public Vec2 subtract(Vec2 other) { + return new Vec2(x - other.x, y - other.y); + } + + public double length() { + return Math.hypot(x, y); + } +} diff --git a/src/main/java/com/example/vibeapp/model/EdgeRef.java b/src/main/java/com/example/vibeapp/model/EdgeRef.java new file mode 100644 index 0000000..e21c70e --- /dev/null +++ b/src/main/java/com/example/vibeapp/model/EdgeRef.java @@ -0,0 +1,24 @@ +package com.example.vibeapp.model; + +/** + * A linear reference onto a track edge: the edge, a relative position along it + * (0.0 = from-node … 1.0 = to-node) and the side. + * + *

This is the reusable "where on the network" value object shared by + * edge-referenced fachdaten such as {@link Signal}. Immutable; the position is + * clamped to [0,1] on construction. + */ +public record EdgeRef(String edgeId, double position, Side side) { + + public EdgeRef { + position = Math.max(0.0, Math.min(1.0, position)); + } + + public EdgeRef withPosition(double newPosition) { + return new EdgeRef(edgeId, newPosition, side); + } + + public EdgeRef withSide(Side newSide) { + return new EdgeRef(edgeId, position, newSide); + } +} diff --git a/src/main/java/com/example/vibeapp/model/SampleNetworks.java b/src/main/java/com/example/vibeapp/model/SampleNetworks.java new file mode 100644 index 0000000..fb87b34 --- /dev/null +++ b/src/main/java/com/example/vibeapp/model/SampleNetworks.java @@ -0,0 +1,40 @@ +package com.example.vibeapp.model; + +/** + * Factory for demo networks. Provides a small, schematic topological map of + * Switzerland as a starting point — positions are illustrative, not geographic. + */ +public final class SampleNetworks { + + private SampleNetworks() { + } + + /** + * A small topological Swiss network: a handful of stations, connecting + * edges and one example signal referenced to an edge. + */ + public static TrackNetwork switzerland() { + TrackNetwork net = new TrackNetwork(); + + TrackNode basel = net.addNode("Basel SBB", 170, 70); + TrackNode olten = net.addNode("Olten", 250, 210); + TrackNode zuerich = net.addNode("Zürich HB", 480, 150); + TrackNode luzern = net.addNode("Luzern", 400, 320); + TrackNode bern = net.addNode("Bern", 210, 360); + TrackNode lausanne = net.addNode("Lausanne", 120, 480); + TrackNode chur = net.addNode("Chur", 660, 330); + + net.addEdge(basel.id(), olten.id()); + net.addEdge(olten.id(), zuerich.id()); + net.addEdge(olten.id(), bern.id()); + net.addEdge(olten.id(), luzern.id()); + net.addEdge(zuerich.id(), luzern.id()); + net.addEdge(zuerich.id(), chur.id()); + TrackEdge bernLausanne = net.addEdge(bern.id(), lausanne.id()); + + // Example fachdatum: a signal 40 % along the Bern–Lausanne edge, right side. + net.addSignal(bernLausanne.id(), 0.4, Side.RIGHT); + + return net; + } +} diff --git a/src/main/java/com/example/vibeapp/model/Side.java b/src/main/java/com/example/vibeapp/model/Side.java new file mode 100644 index 0000000..ac2d4d3 --- /dev/null +++ b/src/main/java/com/example/vibeapp/model/Side.java @@ -0,0 +1,15 @@ +package com.example.vibeapp.model; + +/** + * Side of a track edge, relative to its direction (from-node → to-node). + * Used to place edge-referenced fachdaten such as signals. + */ +public enum Side { + LEFT, + RIGHT; + + /** The opposite side, e.g. for toggling a signal from right to left. */ + public Side opposite() { + return this == LEFT ? RIGHT : LEFT; + } +} diff --git a/src/main/java/com/example/vibeapp/model/Signal.java b/src/main/java/com/example/vibeapp/model/Signal.java new file mode 100644 index 0000000..35c4cb1 --- /dev/null +++ b/src/main/java/com/example/vibeapp/model/Signal.java @@ -0,0 +1,58 @@ +package com.example.vibeapp.model; + +/** + * A signal (Fachdatum) positioned by linear referencing via an + * {@link EdgeRef}: it belongs to a specific edge and sits at a relative position + * along it, on a given {@link Side}. It has no coordinates of its own — its + * screen position is derived from the edge geometry, so it stays correct when + * nodes are moved. + */ +public final class Signal { + + private final String id; + private EdgeRef reference; + private String label; + + public Signal(String id, EdgeRef reference) { + this.id = id; + this.reference = reference; + this.label = id; + } + + public String id() { + return id; + } + + /** The linear reference (edge + position + side) locating this signal. */ + public EdgeRef reference() { + return reference; + } + + public String edgeId() { + return reference.edgeId(); + } + + public double position() { + return reference.position(); + } + + public Side side() { + return reference.side(); + } + + public void setPosition(double position) { + this.reference = reference.withPosition(position); + } + + public void setSide(Side side) { + this.reference = reference.withSide(side); + } + + public String label() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } +} diff --git a/src/main/java/com/example/vibeapp/model/TrackEdge.java b/src/main/java/com/example/vibeapp/model/TrackEdge.java new file mode 100644 index 0000000..9d850a1 --- /dev/null +++ b/src/main/java/com/example/vibeapp/model/TrackEdge.java @@ -0,0 +1,44 @@ +package com.example.vibeapp.model; + +/** + * A track edge (Gleiskante) connecting two nodes. The direction from-node + * → to-node defines the reference axis along which fachdaten are placed + * (see {@link EdgeRef}). + * + *

The label defaults to the id and can be set afterwards via + * {@link #setLabel(String)}. + */ +public final class TrackEdge { + + private final String id; + private final String fromNodeId; + private final String toNodeId; + private String label; + + public TrackEdge(String id, String fromNodeId, String toNodeId) { + this.id = id; + this.fromNodeId = fromNodeId; + this.toNodeId = toNodeId; + this.label = id; + } + + public String id() { + return id; + } + + public String fromNodeId() { + return fromNodeId; + } + + public String toNodeId() { + return toNodeId; + } + + public String label() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } +} diff --git a/src/main/java/com/example/vibeapp/model/TrackNetwork.java b/src/main/java/com/example/vibeapp/model/TrackNetwork.java new file mode 100644 index 0000000..79e7ad1 --- /dev/null +++ b/src/main/java/com/example/vibeapp/model/TrackNetwork.java @@ -0,0 +1,120 @@ +package com.example.vibeapp.model; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * The topological track network: nodes, edges and edge-referenced fachdaten + * (currently signals). Pure model with no UI dependency, so it can be built and + * verified in tests. + * + *

Ids are generated per type ({@code N1}, {@code E1}, {@code S1}, …) via + * internal counters, keeping references stable and human-readable. + */ +public final class TrackNetwork { + + private final Map nodes = new LinkedHashMap<>(); + private final Map edges = new LinkedHashMap<>(); + private final Map signals = new LinkedHashMap<>(); + + private int nodeCounter; + private int edgeCounter; + private int signalCounter; + + // --- Nodes --- + + /** Adds a node at the given schematic position with an auto-generated id/label. */ + public TrackNode addNode(double x, double y) { + String id = "N" + (++nodeCounter); + TrackNode node = new TrackNode(id, x, y); + nodes.put(id, node); + return node; + } + + /** Adds a node with an explicit label at the given schematic position. */ + public TrackNode addNode(String label, double x, double y) { + TrackNode node = addNode(x, y); + node.setLabel(label); + return node; + } + + public Optional node(String id) { + return Optional.ofNullable(nodes.get(id)); + } + + public Collection nodes() { + return Collections.unmodifiableCollection(nodes.values()); + } + + // --- Edges --- + + /** + * Adds an edge between two existing nodes. + * + * @throws IllegalArgumentException if either node id is unknown, or if both + * ids are equal (a self-loop is invalid) + */ + public TrackEdge addEdge(String fromNodeId, String toNodeId) { + if (Objects.equals(fromNodeId, toNodeId)) { + throw new IllegalArgumentException("Edge cannot be a self-loop on node: " + fromNodeId); + } + if (!nodes.containsKey(fromNodeId) || !nodes.containsKey(toNodeId)) { + throw new IllegalArgumentException( + "Edge references unknown node: " + fromNodeId + " -> " + toNodeId); + } + String id = "E" + (++edgeCounter); + TrackEdge edge = new TrackEdge(id, fromNodeId, toNodeId); + edges.put(id, edge); + return edge; + } + + public Optional edge(String id) { + return Optional.ofNullable(edges.get(id)); + } + + public Collection edges() { + return Collections.unmodifiableCollection(edges.values()); + } + + // --- Signals --- + + /** + * Adds a signal referenced to an existing edge. + * + * @param position relative position along the edge, clamped to [0,1] + * @throws IllegalArgumentException if the edge id is unknown + */ + public Signal addSignal(String edgeId, double position, Side side) { + if (!edges.containsKey(edgeId)) { + throw new IllegalArgumentException("Signal references unknown edge: " + edgeId); + } + String id = "S" + (++signalCounter); + Signal signal = new Signal(id, new EdgeRef(edgeId, position, side)); + signals.put(id, signal); + return signal; + } + + public Collection signals() { + return Collections.unmodifiableCollection(signals.values()); + } + + // --- Whole-network operations --- + + public boolean isEmpty() { + return nodes.isEmpty() && edges.isEmpty() && signals.isEmpty(); + } + + /** Removes all nodes, edges and signals and resets id generation. */ + public void clear() { + nodes.clear(); + edges.clear(); + signals.clear(); + nodeCounter = 0; + edgeCounter = 0; + signalCounter = 0; + } +} diff --git a/src/main/java/com/example/vibeapp/model/TrackNode.java b/src/main/java/com/example/vibeapp/model/TrackNode.java new file mode 100644 index 0000000..96177bf --- /dev/null +++ b/src/main/java/com/example/vibeapp/model/TrackNode.java @@ -0,0 +1,50 @@ +package com.example.vibeapp.model; + +/** + * A node (Knoten) in the topological track network — for example a station or a + * junction. Its coordinates are schematic (canvas pixels), not geographic: the + * layout expresses topology, not real-world position. + * + *

The label defaults to the id and can be set afterwards via + * {@link #setLabel(String)}. + */ +public final class TrackNode { + + private final String id; + private String label; + private double x; + private double y; + + public TrackNode(String id, double x, double y) { + this.id = id; + this.label = id; + this.x = x; + this.y = y; + } + + public String id() { + return id; + } + + public String label() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public double x() { + return x; + } + + public double y() { + return y; + } + + /** Moves the node to a new schematic position. */ + public void moveTo(double x, double y) { + this.x = x; + this.y = y; + } +} diff --git a/src/main/java/com/example/vibeapp/view/NetworkEditorController.java b/src/main/java/com/example/vibeapp/view/NetworkEditorController.java new file mode 100644 index 0000000..ed8bd35 --- /dev/null +++ b/src/main/java/com/example/vibeapp/view/NetworkEditorController.java @@ -0,0 +1,229 @@ +package com.example.vibeapp.view; + +import java.util.Optional; + +import com.example.vibeapp.geometry.Geometry; +import com.example.vibeapp.geometry.Vec2; +import com.example.vibeapp.model.SampleNetworks; +import com.example.vibeapp.model.Side; +import com.example.vibeapp.model.TrackEdge; +import com.example.vibeapp.model.TrackNetwork; +import com.example.vibeapp.model.TrackNode; + +import javafx.fxml.FXML; +import javafx.scene.canvas.Canvas; +import javafx.scene.control.Label; +import javafx.scene.control.Toggle; +import javafx.scene.control.ToggleButton; +import javafx.scene.control.ToggleGroup; +import javafx.scene.input.MouseButton; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.Pane; + +/** + * Controller for the track-network editor. + * + *

Holds the transient editing state (active tool, drag/edge-pending + * selection) and translates mouse input into operations on the {@link + * TrackNetwork}. All drawing is delegated to {@link NetworkRenderer} and all + * geometry to {@link Geometry}, so this class stays focused on interaction. + */ +public class NetworkEditorController { + + /** The active editing tool selected in the toolbar. */ + private enum Tool { SELECT, NODE, EDGE, SIGNAL } + + private static final double NODE_HIT_RADIUS = 12.0; + private static final double EDGE_HIT_DISTANCE = 8.0; + + @FXML private Pane canvasHolder; + @FXML private Canvas canvas; + @FXML private ToggleGroup toolGroup; + @FXML private ToggleButton selectButton; + @FXML private ToggleButton nodeButton; + @FXML private ToggleButton edgeButton; + @FXML private ToggleButton signalButton; + @FXML private Label statusLabel; + + private TrackNetwork network = SampleNetworks.switzerland(); + private NetworkRenderer renderer; + + private Tool tool = Tool.SELECT; + private TrackNode draggedNode; + private TrackNode pendingEdgeNode; + private String highlightedNodeId; + + @FXML + private void initialize() { + renderer = new NetworkRenderer(canvas.getGraphicsContext2D()); + + // A Canvas is not resizable, so as a managed child it would pin the + // holder's minimum size and stop the window from shrinking. Take it out + // of layout and drive its size from the holder manually instead. + canvas.setManaged(false); + canvasHolder.widthProperty().addListener((obs, old, now) -> { + canvas.setWidth(now.doubleValue()); + redraw(); + }); + canvasHolder.heightProperty().addListener((obs, old, now) -> { + canvas.setHeight(now.doubleValue()); + redraw(); + }); + + selectButton.setUserData(Tool.SELECT); + nodeButton.setUserData(Tool.NODE); + edgeButton.setUserData(Tool.EDGE); + signalButton.setUserData(Tool.SIGNAL); + toolGroup.selectedToggleProperty().addListener((obs, old, now) -> onToolChanged(old, now)); + toolGroup.selectToggle(selectButton); + + canvas.setOnMousePressed(this::onMousePressed); + canvas.setOnMouseDragged(this::onMouseDragged); + canvas.setOnMouseReleased(event -> draggedNode = null); + + updateStatus(); + redraw(); + } + + private void onToolChanged(Toggle old, Toggle now) { + // Never allow a fully deselected toolbar. + if (now == null) { + toolGroup.selectToggle(old); + return; + } + tool = (Tool) now.getUserData(); + pendingEdgeNode = null; + highlightedNodeId = null; + updateStatus(); + redraw(); + } + + private void onMousePressed(MouseEvent event) { + // Ignore secondary/middle clicks so they can't create elements by accident. + if (event.getButton() != MouseButton.PRIMARY) { + return; + } + double x = event.getX(); + double y = event.getY(); + switch (tool) { + case NODE -> addNodeOnEmptySpace(x, y); + case EDGE -> handleEdgeClick(x, y); + case SIGNAL -> handleSignalClick(x, y); + case SELECT -> { + draggedNode = findNodeAt(x, y).orElse(null); + highlightedNodeId = draggedNode != null ? draggedNode.id() : null; + } + } + updateStatus(); + redraw(); + } + + private void onMouseDragged(MouseEvent event) { + if (tool == Tool.SELECT && draggedNode != null) { + draggedNode.moveTo(event.getX(), event.getY()); + redraw(); + } + } + + private void addNodeOnEmptySpace(double x, double y) { + // Per the editing model, the NODE tool only adds nodes on empty space — + // clicking an existing node must not stack an overlapping duplicate. + if (findNodeAt(x, y).isEmpty()) { + network.addNode(x, y); + } + } + + private void handleEdgeClick(double x, double y) { + Optional hit = findNodeAt(x, y); + if (hit.isEmpty()) { + pendingEdgeNode = null; + return; + } + TrackNode node = hit.get(); + if (pendingEdgeNode == null) { + pendingEdgeNode = node; + } else if (!pendingEdgeNode.id().equals(node.id())) { + network.addEdge(pendingEdgeNode.id(), node.id()); + pendingEdgeNode = null; + } + } + + private void handleSignalClick(double x, double y) { + Vec2 p = new Vec2(x, y); + TrackEdge closestEdge = null; + double closestDistance = EDGE_HIT_DISTANCE; + double closestParam = 0.0; + + for (TrackEdge edge : network.edges()) { + TrackNode from = network.node(edge.fromNodeId()).orElse(null); + TrackNode to = network.node(edge.toNodeId()).orElse(null); + if (from == null || to == null) { + continue; + } + Vec2 a = new Vec2(from.x(), from.y()); + Vec2 b = new Vec2(to.x(), to.y()); + double distance = Geometry.distanceToSegment(p, a, b); + if (distance <= closestDistance) { + closestDistance = distance; + closestParam = Geometry.nearestParam(p, a, b); + closestEdge = edge; + } + } + + if (closestEdge != null) { + network.addSignal(closestEdge.id(), closestParam, Side.RIGHT); + } + } + + private Optional findNodeAt(double x, double y) { + TrackNode closest = null; + double closestDistance = NODE_HIT_RADIUS; + for (TrackNode node : network.nodes()) { + double distance = Math.hypot(node.x() - x, node.y() - y); + if (distance <= closestDistance) { + closestDistance = distance; + closest = node; + } + } + return Optional.ofNullable(closest); + } + + @FXML + private void onLoadSample() { + network = SampleNetworks.switzerland(); + resetInteraction(); + } + + @FXML + private void onClear() { + network.clear(); + resetInteraction(); + } + + private void resetInteraction() { + pendingEdgeNode = null; + draggedNode = null; + highlightedNodeId = null; + updateStatus(); + redraw(); + } + + private void redraw() { + renderer.render(network, canvas.getWidth(), canvas.getHeight(), + highlightedNodeId, + pendingEdgeNode != null ? pendingEdgeNode.id() : null); + } + + private void updateStatus() { + String hint = switch (tool) { + case SELECT -> "Auswahl: Knoten anklicken und ziehen, um ihn zu verschieben."; + case NODE -> "Knoten: In die Fläche klicken, um einen Knoten zu setzen."; + case EDGE -> pendingEdgeNode == null + ? "Kante: Startknoten anklicken." + : "Kante: Zielknoten anklicken (Start: " + pendingEdgeNode.label() + ")."; + case SIGNAL -> "Signal: Auf eine Gleiskante klicken, um ein Signal zu setzen."; + }; + statusLabel.setText(String.format("%s | Knoten: %d Kanten: %d Signale: %d", + hint, network.nodes().size(), network.edges().size(), network.signals().size())); + } +} diff --git a/src/main/java/com/example/vibeapp/view/NetworkRenderer.java b/src/main/java/com/example/vibeapp/view/NetworkRenderer.java new file mode 100644 index 0000000..37f0081 --- /dev/null +++ b/src/main/java/com/example/vibeapp/view/NetworkRenderer.java @@ -0,0 +1,116 @@ +package com.example.vibeapp.view; + +import com.example.vibeapp.geometry.Geometry; +import com.example.vibeapp.geometry.Vec2; +import com.example.vibeapp.model.Signal; +import com.example.vibeapp.model.TrackEdge; +import com.example.vibeapp.model.TrackNetwork; +import com.example.vibeapp.model.TrackNode; + +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.paint.Color; +import javafx.scene.text.Font; + +/** + * Draws a {@link TrackNetwork} onto a JavaFX canvas. Pure rendering — it reads + * the model and some transient highlight ids, but owns no editing state. + */ +final class NetworkRenderer { + + static final double NODE_RADIUS = 9.0; + private static final double SIGNAL_OFFSET = 20.0; + private static final double SIGNAL_RADIUS = 5.0; + + private static final Color BACKGROUND = Color.web("#11111b"); + private static final Color EDGE = Color.web("#7f849c"); + private static final Color NODE = Color.web("#89b4fa"); + private static final Color SIGNAL = Color.web("#f38ba8"); + private static final Color TEXT = Color.web("#cdd6f4"); + private static final Color HIGHLIGHT = Color.web("#f9e2af"); + private static final Color PENDING = Color.web("#a6e3a1"); + + private final GraphicsContext gc; + + NetworkRenderer(GraphicsContext gc) { + this.gc = gc; + this.gc.setFont(Font.font(12)); + } + + // Guardrail exception: 5 parameters. A future refactor may bundle the + // canvas size and highlight ids into a small view-state value object. + @SuppressWarnings("checkstyle:ParameterNumber") + void render(TrackNetwork network, double width, double height, + String highlightedNodeId, String pendingEdgeNodeId) { + gc.setFill(BACKGROUND); + gc.fillRect(0, 0, width, height); + + drawEdges(network); + drawSignals(network); + drawNodes(network, highlightedNodeId, pendingEdgeNodeId); + } + + private void drawEdges(TrackNetwork network) { + gc.setStroke(EDGE); + gc.setLineWidth(2.5); + for (TrackEdge edge : network.edges()) { + TrackNode from = network.node(edge.fromNodeId()).orElse(null); + TrackNode to = network.node(edge.toNodeId()).orElse(null); + if (from == null || to == null) { + continue; + } + gc.strokeLine(from.x(), from.y(), to.x(), to.y()); + } + } + + private void drawSignals(TrackNetwork network) { + for (Signal signal : network.signals()) { + TrackEdge edge = network.edge(signal.edgeId()).orElse(null); + if (edge == null) { + continue; + } + TrackNode from = network.node(edge.fromNodeId()).orElse(null); + TrackNode to = network.node(edge.toNodeId()).orElse(null); + if (from == null || to == null) { + continue; + } + Vec2 a = new Vec2(from.x(), from.y()); + Vec2 b = new Vec2(to.x(), to.y()); + Vec2 base = Geometry.pointAt(a, b, signal.position()); + Vec2 marker = Geometry.offsetPoint(a, b, signal.position(), SIGNAL_OFFSET, signal.side()); + + gc.setStroke(SIGNAL); + gc.setLineWidth(1.0); + gc.strokeLine(base.x(), base.y(), marker.x(), marker.y()); + + gc.setFill(SIGNAL); + gc.fillOval(marker.x() - SIGNAL_RADIUS, marker.y() - SIGNAL_RADIUS, + 2 * SIGNAL_RADIUS, 2 * SIGNAL_RADIUS); + + gc.setFill(TEXT); + gc.fillText(signal.label(), marker.x() + SIGNAL_RADIUS + 3, marker.y() + 4); + } + } + + private void drawNodes(TrackNetwork network, String highlightedNodeId, String pendingEdgeNodeId) { + for (TrackNode node : network.nodes()) { + gc.setFill(NODE); + gc.fillOval(node.x() - NODE_RADIUS, node.y() - NODE_RADIUS, + 2 * NODE_RADIUS, 2 * NODE_RADIUS); + + if (node.id().equals(pendingEdgeNodeId)) { + gc.setStroke(PENDING); + gc.setLineWidth(3.0); + gc.strokeOval(node.x() - NODE_RADIUS, node.y() - NODE_RADIUS, + 2 * NODE_RADIUS, 2 * NODE_RADIUS); + } else if (node.id().equals(highlightedNodeId)) { + gc.setStroke(HIGHLIGHT); + gc.setLineWidth(3.0); + gc.strokeOval(node.x() - NODE_RADIUS, node.y() - NODE_RADIUS, + 2 * NODE_RADIUS, 2 * NODE_RADIUS); + } + + gc.setFill(TEXT); + gc.fillText(node.label(), node.x() + NODE_RADIUS + 4, node.y() + 4); + } + } +} diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java new file mode 100644 index 0000000..bde95c9 --- /dev/null +++ b/src/main/java/module-info.java @@ -0,0 +1,9 @@ +module com.example.vibeapp { + requires javafx.controls; + requires javafx.fxml; + + // Allow JavaFX to reflectively access the FXML controller. + opens com.example.vibeapp.view to javafx.fxml; + + exports com.example.vibeapp; +} diff --git a/src/main/resources/com/example/vibeapp/network-editor.fxml b/src/main/resources/com/example/vibeapp/network-editor.fxml new file mode 100644 index 0000000..a982fc1 --- /dev/null +++ b/src/main/resources/com/example/vibeapp/network-editor.fxml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +