diff --git a/Features/README.md b/Features/README.md index a7ccb97..3cdbfaa 100644 --- a/Features/README.md +++ b/Features/README.md @@ -12,3 +12,5 @@ In diesem Ordner werden die Anforderungen für neue Features dokumentiert. ## Enthaltene Dateien - [`feature-vorlage.md`](./feature-vorlage.md) – Vorlage für neue Feature-Anforderungen. +- [`signale-erstellen-und-gleiskante-zuordnen.md`](./signale-erstellen-und-gleiskante-zuordnen.md) – Signale anlegen und einer Gleiskante zuordnen. +- [`lustige-giraffe-animation.md`](./lustige-giraffe-animation.md) – Lustige Giraffe zeichnet neue Elemente animiert. diff --git a/Features/lustige-giraffe-animation.md b/Features/lustige-giraffe-animation.md new file mode 100644 index 0000000..1b8477a --- /dev/null +++ b/Features/lustige-giraffe-animation.md @@ -0,0 +1,83 @@ +# Feature: Lustige Giraffe zeichnet neue Elemente + +## Übersicht + +- **Status:** Abgeschlossen +- **Autor:** Claude Code +- **Erstellt am:** 2026-07-09 +- **Letzte Änderung:** 2026-07-09 + +## Ziel / Problemstellung + +Das Anlegen neuer Elemente (Knoten, Kanten, Signale) soll spielerischer und +sichtbarer werden. Statt dass ein Element schlagartig erscheint, taucht eine +kleine, lustige Cartoon-Giraffe auf und „zeichnet" das neue Element vor den +Augen der Nutzerin animiert in die Fläche. Das gibt visuelles Feedback darüber, +was gerade entstanden ist, und macht den Editor sympathischer. + +## Anforderungen + +### Funktionale Anforderungen + +- [x] Beim Hinzufügen eines **Knotens** erscheint die Giraffe und zeichnet den + Knoten (wachsender Kreis) an der Klickposition. +- [x] Beim Hinzufügen einer **Kante** wird die Linie vom Start- zum Zielknoten + animiert gezogen. +- [x] Beim Hinzufügen eines **Signals** wird der Signalarm von der Gleiskante + bis zum Marker animiert gezeichnet. +- [x] Die Giraffe steht neben der „Stiftspitze" und wackelt leicht mit dem Kopf, + eine gestrichelte Linie verbindet ihre Schnauze mit der wachsenden Spitze. +- [x] Das Element wird erst nach Abschluss der Animation ins Modell übernommen, + sodass es tatsächlich gezeichnet erscheint statt sofort dazustehen. +- [x] Wird während einer laufenden Animation eine weitere Aktion ausgelöst + (schnelles Hinzufügen, Werkzeugwechsel, „Leeren", „Beispielnetz"), wird + die aktuelle Animation sauber abgeschlossen; kein Element geht verloren. + +### Nicht-funktionale Anforderungen + +- [x] Die Animation lebt vollständig im `view`-Paket; `model` und `geometry` + bleiben JavaFX-frei und testbar. +- [x] Die reine Zeit-/Interpolationsmathematik (`Easing.smoothStep`, + `Vec2.lerp`) liegt im `geometry`-Paket und ist per Unit-Test abgedeckt. +- [x] Architektur-Guardrails eingehalten (max. 3 Parameter, max. 500 LOC/Klasse); + `mvn verify` (Checkstyle, PMD, SpotBugs, JaCoCo) läuft grün. + +## Nutzer / Zielgruppe + +Alle, die den Gleisnetz-Editor bedienen und neue Netzelemente anlegen. + +## Ein- und Ausgaben + +- **Eingaben:** Mausklicks der Werkzeuge Knoten / Kante / Signal. +- **Ausgaben:** Kurze Zeichen-Animation (~1,1 s) auf dem Canvas; danach ist das + neue Element Teil des Netzes. + +## Akzeptanzkriterien + +- [x] Nach dem Anlegen jedes Elementtyps ist für kurze Zeit eine Giraffe zu + sehen, die das Element zeichnet, danach ist das Element vorhanden. +- [x] Die Domänenlogik bleibt ohne JavaFX-Abhängigkeit; alle Tests grün. +- [x] `mvn verify` ist erfolgreich. + +## Umsetzung (Kurzüberblick) + +- `geometry/Easing.java` – `smoothStep` für sanftes Ein-/Ausblenden der Bewegung. +- `geometry/Vec2.java` – `lerp` für das Wachsen entlang einer Strecke. +- `view/DrawTarget.java` – beschreibt Geometrie des neuen Elements (Kind + Start/Ende). +- `view/Giraffe.java` – zeichnet die Cartoon-Giraffe (Körper, Flecken, Hals, Kopf). +- `view/GiraffeAnimator.java` – `AnimationTimer`, der pro Frame das Netz neu + zeichnet, das wachsende Element und die Giraffe darüber legt und das Element am + Ende via Callback ins Modell übernimmt. +- `view/NetworkEditorController.java` – löst die Animation beim Anlegen von + Knoten/Kante/Signal aus. + +## Offene Fragen / Annahmen + +- Annahme: Eine feste Animationsdauer (~1,1 s) ist angenehm; bei Bedarf leicht + über `GiraffeAnimator.DURATION_NANOS` anpassbar. +- Die Animation ist rein kosmetisch und wird von JaCoCo (wie `view`/`App`) nicht + auf Coverage geprüft; die zugrunde liegende Mathematik ist getestet. + +## Referenzen + +- [`AGENTS.md`](../AGENTS.md) – Architektur, Guardrails, Definition of Done. diff --git a/src/main/java/com/example/vibeapp/geometry/Easing.java b/src/main/java/com/example/vibeapp/geometry/Easing.java new file mode 100644 index 0000000..3a09f06 --- /dev/null +++ b/src/main/java/com/example/vibeapp/geometry/Easing.java @@ -0,0 +1,22 @@ +package com.example.vibeapp.geometry; + +/** + * Pure easing helpers for time-based animations. UI-free (like the rest of this + * package) so the timing math stays unit-testable without the JavaFX toolkit. + */ +public final class Easing { + + private Easing() { + } + + /** + * Smooth ease-in/ease-out of a normalized progress value. The input is + * clamped to [0,1] and the classic smoothstep curve {@code 3t²-2t³} is + * applied: it returns 0 at 0, 1 at 1, is symmetric around 0.5 and has zero + * slope at both ends, so an animation accelerates and decelerates gently. + */ + public static double smoothStep(double t) { + double c = Math.max(0.0, Math.min(1.0, t)); + return c * c * (3.0 - 2.0 * c); + } +} diff --git a/src/main/java/com/example/vibeapp/geometry/Vec2.java b/src/main/java/com/example/vibeapp/geometry/Vec2.java index 5940004..c271ac2 100644 --- a/src/main/java/com/example/vibeapp/geometry/Vec2.java +++ b/src/main/java/com/example/vibeapp/geometry/Vec2.java @@ -13,4 +13,13 @@ public Vec2 subtract(Vec2 other) { public double length() { return Math.hypot(x, y); } + + /** + * Point fraction {@code t} of the way from this vector toward {@code target} + * ({@code t} = 0 returns this point, {@code t} = 1 returns {@code target}). + * Used to animate an element growing along a straight path. + */ + public Vec2 lerp(Vec2 target, double t) { + return new Vec2(x + (target.x - x) * t, y + (target.y - y) * t); + } } diff --git a/src/main/java/com/example/vibeapp/view/DrawTarget.java b/src/main/java/com/example/vibeapp/view/DrawTarget.java new file mode 100644 index 0000000..6035959 --- /dev/null +++ b/src/main/java/com/example/vibeapp/view/DrawTarget.java @@ -0,0 +1,26 @@ +package com.example.vibeapp.view; + +import java.util.Objects; + +import com.example.vibeapp.geometry.Vec2; + +/** + * Geometry of a freshly created element, handed to the {@link GiraffeAnimator} + * so the giraffe can trace it into existence. + * + *

{@code start} and {@code end} bound the stroke that gets drawn: a + * {@link Kind#NODE} uses the same point for both, a {@link Kind#EDGE} spans its + * two nodes, and a {@link Kind#SIGNAL} runs from its base on the edge to its + * offset marker. + */ +record DrawTarget(Kind kind, Vec2 start, Vec2 end) { + + DrawTarget { + Objects.requireNonNull(kind, "kind must not be null"); + Objects.requireNonNull(start, "start must not be null"); + Objects.requireNonNull(end, "end must not be null"); + } + + /** The kind of element being drawn, which selects how it is animated. */ + enum Kind { NODE, EDGE, SIGNAL } +} diff --git a/src/main/java/com/example/vibeapp/view/Giraffe.java b/src/main/java/com/example/vibeapp/view/Giraffe.java new file mode 100644 index 0000000..d28421f --- /dev/null +++ b/src/main/java/com/example/vibeapp/view/Giraffe.java @@ -0,0 +1,116 @@ +package com.example.vibeapp.view; + +import java.util.Objects; + +import com.example.vibeapp.geometry.Vec2; + +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.paint.Color; + +/** + * Draws a small cartoon giraffe on a canvas — the friendly mascot that appears + * and "draws" newly added elements. Pure rendering: it holds a graphics context + * and paints on request; a bob {@code phase} gives its head a bit of life. The + * giraffe faces right, toward the element it is drawing. + */ +final class Giraffe { + + private static final Color COAT = Color.web("#f2c14e"); + private static final Color COAT_DARK = Color.web("#e0a92e"); + private static final Color SPOT = Color.web("#a86a2c"); + private static final Color HOOF = Color.web("#5b4327"); + private static final Color MANE = Color.web("#b5651d"); + private static final Color EYE = Color.web("#2b2118"); + + private final GraphicsContext gc; + + Giraffe(GraphicsContext gc) { + this.gc = Objects.requireNonNull(gc, "gc must not be null"); + } + + /** + * Draws the giraffe standing on {@code feet} (its bottom-centre point), with + * {@code phase} (radians) driving a gentle head bob so it looks alive while + * it is drawing. The snout ends up near {@code feet} + (23, -48). + */ + void draw(Vec2 feet, double phase) { + double bob = Math.sin(phase) * 2.0; + double bodyCy = feet.y() - 26.0; + drawLegs(feet, bodyCy); + drawTail(feet.x() - 12.0, bodyCy); + drawBody(feet.x(), bodyCy); + drawNeckAndHead(feet.x(), bodyCy, bob); + } + + private void drawLegs(Vec2 feet, double bodyCy) { + double top = bodyCy + 6.0; + double footY = feet.y(); + double[] xs = {feet.x() - 8.0, feet.x() - 3.0, feet.x() + 4.0, feet.x() + 9.0}; + gc.setLineWidth(3.5); + gc.setStroke(COAT); + for (double x : xs) { + gc.strokeLine(x, top, x, footY); + } + gc.setStroke(HOOF); + for (double x : xs) { + gc.strokeLine(x, footY - 2.5, x, footY); + } + } + + private void drawBody(double cx, double cy) { + gc.setFill(COAT); + gc.fillOval(cx - 13.0, cy - 8.0, 26.0, 16.0); + gc.setFill(SPOT); + gc.fillOval(cx - 8.0, cy - 4.0, 4.0, 4.0); + gc.fillOval(cx - 1.0, cy - 1.0, 5.0, 5.0); + gc.fillOval(cx + 5.0, cy - 5.0, 4.0, 4.0); + gc.fillOval(cx + 3.0, cy + 2.0, 3.5, 3.5); + } + + private void drawTail(double x, double cy) { + gc.setStroke(COAT_DARK); + gc.setLineWidth(1.5); + gc.strokeLine(x, cy, x - 5.0, cy + 8.0); + gc.setFill(MANE); + gc.fillOval(x - 7.0, cy + 7.0, 3.5, 4.5); + } + + private void drawNeckAndHead(double cx, double cy, double bob) { + double neckBaseX = cx + 7.0; + double neckBaseY = cy - 6.0; + double headX = cx + 15.0 + bob; + double headY = cy - 24.0 + bob * 0.5; + gc.setStroke(COAT); + gc.setLineWidth(6.0); + gc.strokeLine(neckBaseX, neckBaseY, headX, headY + 4.0); + gc.setStroke(MANE); + gc.setLineWidth(2.0); + gc.strokeLine(neckBaseX - 2.0, neckBaseY, headX - 2.0, headY + 4.0); + drawHead(headX, headY); + } + + private void drawHead(double x, double y) { + // Ossicones (the little horns) with knobbed tips. + gc.setStroke(COAT_DARK); + gc.setLineWidth(1.5); + gc.strokeLine(x - 2.0, y - 2.0, x - 3.0, y - 7.0); + gc.strokeLine(x + 3.0, y - 2.0, x + 3.0, y - 7.0); + gc.setFill(MANE); + gc.fillOval(x - 4.5, y - 9.0, 3.0, 3.0); + gc.fillOval(x + 1.5, y - 9.0, 3.0, 3.0); + // Head and snout (facing right). + gc.setFill(COAT); + gc.fillOval(x - 5.0, y - 3.0, 10.0, 8.0); + gc.fillOval(x + 2.0, y - 1.0, 8.0, 6.0); + // Ear on the far side. + gc.setFill(COAT_DARK); + gc.fillOval(x - 7.0, y - 3.0, 4.0, 2.5); + // Eye, nostril and a friendly smile. + gc.setFill(EYE); + gc.fillOval(x - 1.0, y - 1.0, 2.0, 2.0); + gc.fillOval(x + 8.0, y + 1.5, 1.3, 1.3); + gc.setStroke(EYE); + gc.setLineWidth(0.8); + gc.strokeLine(x + 6.0, y + 3.5, x + 9.0, y + 3.0); + } +} diff --git a/src/main/java/com/example/vibeapp/view/GiraffeAnimator.java b/src/main/java/com/example/vibeapp/view/GiraffeAnimator.java new file mode 100644 index 0000000..e2f723a --- /dev/null +++ b/src/main/java/com/example/vibeapp/view/GiraffeAnimator.java @@ -0,0 +1,176 @@ +package com.example.vibeapp.view; + +import java.util.Objects; + +import com.example.vibeapp.geometry.Easing; +import com.example.vibeapp.geometry.Vec2; + +import javafx.animation.AnimationTimer; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.paint.Color; + +/** + * Plays a short, playful animation whenever a new element is created: a cartoon + * {@link Giraffe} appears and traces the element into existence, and only once + * the stroke is complete is the element committed to the model. + * + *

View-only and owns no domain state. Each frame it lets the caller repaint + * the network beneath ({@code baseRedraw}) — which does not yet contain the new + * element — then draws the growing preview and the giraffe on top. The commit + * runs from {@link #complete()}, so an interrupted animation never loses its + * element (see {@link #finishNow()}). + */ +final class GiraffeAnimator { + + private static final double DURATION_NANOS = 1_100_000_000.0; + private static final double BOB_NANOS = 90_000_000.0; + private static final double FADE = 0.15; + private static final Color TRACE = Color.web("#f9e2af"); + + private final GraphicsContext gc; + private final Runnable baseRedraw; + private final Giraffe giraffe; + private final AnimationTimer timer; + + private DrawTarget target; + private Runnable onComplete; + private long startNanos = -1L; + + GiraffeAnimator(GraphicsContext gc, Runnable baseRedraw) { + this.gc = Objects.requireNonNull(gc, "gc must not be null"); + this.baseRedraw = Objects.requireNonNull(baseRedraw, "baseRedraw must not be null"); + this.giraffe = new Giraffe(gc); + this.timer = new AnimationTimer() { + @Override + public void handle(long now) { + frame(now); + } + }; + } + + /** + * Animates the drawing of {@code target}; once the giraffe finishes, + * {@code onComplete} runs to commit the element. If another animation is + * still running it is finished first so its element is not lost. + */ + void play(DrawTarget target, Runnable onComplete) { + Objects.requireNonNull(target, "target must not be null"); + Objects.requireNonNull(onComplete, "onComplete must not be null"); + finishNow(); + this.target = target; + this.onComplete = onComplete; + this.startNanos = -1L; + timer.start(); + } + + /** If an animation is in progress, end it immediately and commit its element. */ + void finishNow() { + if (target != null) { + complete(); + } + } + + private void frame(long now) { + if (startNanos < 0L) { + startNanos = now; + } + double elapsed = now - startNanos; + double t = Easing.smoothStep(elapsed / DURATION_NANOS); + baseRedraw.run(); + Vec2 tip = drawGrowingElement(t); + drawGiraffe(tip, t, elapsed); + if (elapsed >= DURATION_NANOS) { + complete(); + } + } + + private void complete() { + timer.stop(); + Runnable commit = onComplete; + target = null; + onComplete = null; + startNanos = -1L; + if (commit != null) { + commit.run(); + } + } + + /** Draws the element grown to progress {@code t} and returns the pen tip. */ + private Vec2 drawGrowingElement(double t) { + return switch (target.kind()) { + case NODE -> drawGrowingNode(t); + case EDGE -> drawGrowingEdge(t); + case SIGNAL -> drawGrowingSignal(t); + }; + } + + private Vec2 drawGrowingNode(double t) { + Vec2 c = target.start(); + double r = NetworkRenderer.NODE_RADIUS * t; + gc.setFill(NetworkRenderer.NODE); + gc.fillOval(c.x() - r, c.y() - r, 2 * r, 2 * r); + double ring = NetworkRenderer.NODE_RADIUS + 5.0 * (1.0 - t); + gc.setStroke(TRACE); + gc.setLineWidth(2.0); + gc.strokeOval(c.x() - ring, c.y() - ring, 2 * ring, 2 * ring); + return c; + } + + private Vec2 drawGrowingEdge(double t) { + Vec2 a = target.start(); + Vec2 tip = a.lerp(target.end(), t); + gc.setStroke(NetworkRenderer.EDGE); + gc.setLineWidth(2.5); + gc.strokeLine(a.x(), a.y(), tip.x(), tip.y()); + return tip; + } + + private Vec2 drawGrowingSignal(double t) { + Vec2 base = target.start(); + Vec2 tip = base.lerp(target.end(), t); + gc.setStroke(NetworkRenderer.SIGNAL); + gc.setLineWidth(1.0); + gc.strokeLine(base.x(), base.y(), tip.x(), tip.y()); + double r = NetworkRenderer.SIGNAL_RADIUS * t; + gc.setFill(NetworkRenderer.SIGNAL); + gc.fillOval(tip.x() - r, tip.y() - r, 2 * r, 2 * r); + return tip; + } + + private void drawGiraffe(Vec2 tip, double t, double elapsed) { + Vec2 feet = giraffeFeet(tip); + gc.save(); + gc.setGlobalAlpha(fade(t)); + drawPenGuide(feet, tip); + giraffe.draw(feet, elapsed / BOB_NANOS); + gc.restore(); + } + + /** Dashed "pen" line from the giraffe's snout to the growing tip, plus a sparkle. */ + private void drawPenGuide(Vec2 feet, Vec2 tip) { + Vec2 snout = new Vec2(feet.x() + 23.0, feet.y() - 48.0); + gc.setStroke(TRACE); + gc.setLineWidth(1.0); + gc.setLineDashes(3.0, 3.0); + gc.strokeLine(snout.x(), snout.y(), tip.x(), tip.y()); + gc.setLineDashes(null); + gc.setFill(TRACE); + gc.fillOval(tip.x() - 2.0, tip.y() - 2.0, 4.0, 4.0); + } + + /** Stands the giraffe just left of and below the tip, kept inside the canvas. */ + private Vec2 giraffeFeet(Vec2 tip) { + double w = gc.getCanvas().getWidth(); + double h = gc.getCanvas().getHeight(); + double x = Math.max(22.0, Math.min(w - 16.0, tip.x() - 30.0)); + double y = Math.max(52.0, Math.min(h - 6.0, tip.y() + 42.0)); + return new Vec2(x, y); + } + + /** Fades the giraffe in over the first slice of the animation and out over the last. */ + private static double fade(double t) { + double in = Math.min(1.0, t / FADE); + double out = Math.min(1.0, (1.0 - t) / FADE); + return Math.max(0.0, Math.min(in, out)); + } +} diff --git a/src/main/java/com/example/vibeapp/view/NetworkEditorController.java b/src/main/java/com/example/vibeapp/view/NetworkEditorController.java index c135a9c..7fbc567 100644 --- a/src/main/java/com/example/vibeapp/view/NetworkEditorController.java +++ b/src/main/java/com/example/vibeapp/view/NetworkEditorController.java @@ -50,6 +50,7 @@ private enum Tool { SELECT, NODE, EDGE, SIGNAL } private TrackNetwork network = SampleNetworks.switzerland(); private NetworkRenderer renderer; + private GiraffeAnimator animator; private Tool tool = Tool.SELECT; private TrackNode draggedNode; @@ -59,6 +60,14 @@ private enum Tool { SELECT, NODE, EDGE, SIGNAL } @FXML private void initialize() { renderer = new NetworkRenderer(canvas.getGraphicsContext2D()); + animator = new GiraffeAnimator(canvas.getGraphicsContext2D(), this::redraw); + // Stop a running animation timer once the canvas leaves the scene, so it + // does not keep firing in the background after the window is closed. + canvas.sceneProperty().addListener((obs, oldScene, newScene) -> { + if (newScene == null) { + animator.finishNow(); + } + }); // 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 @@ -99,6 +108,8 @@ private void onToolChanged(Toggle old, Toggle now) { toolGroup.selectToggle(old); return; } + // Commit any element still being drawn before switching context. + animator.finishNow(); tool = (Tool) now.getUserData(); pendingEdgeNode = null; highlightedNodeId = null; @@ -126,6 +137,9 @@ private void onMousePressed(MouseEvent event) { if (event.getButton() != MouseButton.PRIMARY) { return; } + // Commit any element still being drawn before hit-testing, so a fast + // second click sees the pending element and cannot create a duplicate. + animator.finishNow(); double x = event.getX(); double y = event.getY(); switch (tool) { @@ -152,7 +166,9 @@ 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); + Vec2 p = new Vec2(x, y); + animateDraw(new DrawTarget(DrawTarget.Kind.NODE, p, p), + () -> network.addNode(x, y)); } } @@ -166,8 +182,13 @@ private void handleEdgeClick(double x, double y) { if (pendingEdgeNode == null) { pendingEdgeNode = node; } else if (!pendingEdgeNode.id().equals(node.id())) { - network.addEdge(pendingEdgeNode.id(), node.id()); + Vec2 a = new Vec2(pendingEdgeNode.x(), pendingEdgeNode.y()); + Vec2 b = new Vec2(node.x(), node.y()); + String fromId = pendingEdgeNode.id(); + String toId = node.id(); pendingEdgeNode = null; + animateDraw(new DrawTarget(DrawTarget.Kind.EDGE, a, b), + () -> network.addEdge(fromId, toId)); } } @@ -194,10 +215,32 @@ private void handleSignalClick(double x, double y) { } if (closestEdge != null) { - network.addSignal(closestEdge.id(), closestParam, selectedSide()); + animateSignal(closestEdge, closestParam); } } + private void animateSignal(TrackEdge edge, double param) { + TrackNode from = network.node(edge.fromNodeId()).orElseThrow(); + TrackNode to = network.node(edge.toNodeId()).orElseThrow(); + Vec2 a = new Vec2(from.x(), from.y()); + Vec2 b = new Vec2(to.x(), to.y()); + Side side = selectedSide(); + Vec2 base = Geometry.pointAt(a, b, param); + Vec2 marker = Geometry.offsetPoint(a, b, param, NetworkRenderer.SIGNAL_OFFSET, side); + String edgeId = edge.id(); + animateDraw(new DrawTarget(DrawTarget.Kind.SIGNAL, base, marker), + () -> network.addSignal(edgeId, param, side)); + } + + /** Plays the giraffe draw animation, then commits the new element to the model. */ + private void animateDraw(DrawTarget target, Runnable commit) { + animator.play(target, () -> { + commit.run(); + updateStatus(); + redraw(); + }); + } + private Optional findNodeAt(double x, double y) { TrackNode closest = null; double closestDistance = NODE_HIT_RADIUS; @@ -213,12 +256,14 @@ private Optional findNodeAt(double x, double y) { @FXML private void onLoadSample() { + animator.finishNow(); network = SampleNetworks.switzerland(); resetInteraction(); } @FXML private void onClear() { + animator.finishNow(); network.clear(); resetInteraction(); } diff --git a/src/main/java/com/example/vibeapp/view/NetworkRenderer.java b/src/main/java/com/example/vibeapp/view/NetworkRenderer.java index 37f0081..6dc82cb 100644 --- a/src/main/java/com/example/vibeapp/view/NetworkRenderer.java +++ b/src/main/java/com/example/vibeapp/view/NetworkRenderer.java @@ -18,13 +18,15 @@ 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; + // Package-private: the giraffe draw animation previews new elements and needs + // to match the renderer's geometry and palette exactly. + static final double SIGNAL_OFFSET = 20.0; + 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"); + static final Color EDGE = Color.web("#7f849c"); + static final Color NODE = Color.web("#89b4fa"); + 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"); diff --git a/src/test/java/com/example/vibeapp/geometry/EasingTest.java b/src/test/java/com/example/vibeapp/geometry/EasingTest.java new file mode 100644 index 0000000..c014457 --- /dev/null +++ b/src/test/java/com/example/vibeapp/geometry/EasingTest.java @@ -0,0 +1,39 @@ +package com.example.vibeapp.geometry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class EasingTest { + + private static final double EPS = 1e-9; + + @Test + void endpointsAreExact() { + assertEquals(0.0, Easing.smoothStep(0.0), EPS); + assertEquals(1.0, Easing.smoothStep(1.0), EPS); + } + + @Test + void clampsOutsideUnitInterval() { + assertEquals(0.0, Easing.smoothStep(-0.5), EPS); + assertEquals(1.0, Easing.smoothStep(2.0), EPS); + } + + @Test + void midpointIsHalf() { + assertEquals(0.5, Easing.smoothStep(0.5), EPS); + } + + @Test + void easesInThenOutSymmetrically() { + double early = Easing.smoothStep(0.25); + double late = Easing.smoothStep(0.75); + // Eased-in: slower than linear near the start, faster near the end. + assertTrue(early < 0.25, "expected eased-in value below linear"); + assertTrue(late > 0.75, "expected eased-out value above linear"); + // Smoothstep is point-symmetric about (0.5, 0.5). + assertEquals(1.0, early + late, EPS); + } +} diff --git a/src/test/java/com/example/vibeapp/geometry/Vec2Test.java b/src/test/java/com/example/vibeapp/geometry/Vec2Test.java new file mode 100644 index 0000000..1be1291 --- /dev/null +++ b/src/test/java/com/example/vibeapp/geometry/Vec2Test.java @@ -0,0 +1,37 @@ +package com.example.vibeapp.geometry; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class Vec2Test { + + private static final double EPS = 1e-9; + + @Test + void subtractComponentwise() { + Vec2 d = new Vec2(7, 4).subtract(new Vec2(2, 1)); + assertEquals(5.0, d.x(), EPS); + assertEquals(3.0, d.y(), EPS); + } + + @Test + void lengthIsEuclidean() { + assertEquals(5.0, new Vec2(3, 4).length(), EPS); + } + + @Test + void lerpAtEndpointsReturnsStartAndEnd() { + Vec2 a = new Vec2(0, 0); + Vec2 b = new Vec2(10, 20); + assertEquals(a, a.lerp(b, 0.0)); + assertEquals(b, a.lerp(b, 1.0)); + } + + @Test + void lerpInterpolatesMidpoint() { + Vec2 m = new Vec2(0, 0).lerp(new Vec2(10, 20), 0.5); + assertEquals(5.0, m.x(), EPS); + assertEquals(10.0, m.y(), EPS); + } +}