Skip to content

Commit 56a45a9

Browse files
CopilotbedaHovorkaCopilot
authored
Render animated trains as directional locomotive markers and remove leftover animated circle glyphs (#477)
* fix: address train renderer review feedback * chore: polish train renderer review cleanup * chore: finalize train renderer maintainability fixes * fix: address follow-up train renderer review comments * chore: polish final train renderer follow-up * Update desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/gridcanvas/AnimatedSimulationCellRenderer.kt * fix: remove duplicate train centering locals * fix: render trains with clearer locomotive silhouette * chore: clarify locomotive silhouette geometry * chore: document locomotive silhouette ratios * chore: polish locomotive silhouette review fixes * chore: document silhouette test assumptions * chore: clarify locomotive test diagnostics * fix: remove animated InOut circle glyph * fix: sharpen animated locomotive silhouette * chore: polish animated InOut regression test * test: clarify locomotive renderer assertions * chore: polish renderer comment constants --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: bedaHovorka <bedaHovorka@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent ef5f1b2 commit 56a45a9

2 files changed

Lines changed: 493 additions & 43 deletions

File tree

desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/gridcanvas/AnimatedSimulationCellRenderer.kt

Lines changed: 190 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,21 @@ import cz.vutbr.fit.interlockSim.objects.cells.InOut
1919
import cz.vutbr.fit.interlockSim.objects.cells.RailSwitch
2020
import cz.vutbr.fit.interlockSim.objects.cells.TrackBlockPart
2121
import cz.vutbr.fit.interlockSim.objects.core.Cell
22+
import cz.vutbr.fit.interlockSim.util.PointF
2223
import io.github.oshai.kotlinlogging.KotlinLogging
24+
import java.awt.BasicStroke
2325
import java.awt.Graphics2D
26+
import java.awt.RenderingHints
27+
import java.awt.Shape
28+
import java.awt.geom.AffineTransform
29+
import java.awt.geom.Path2D
30+
import kotlin.math.PI
31+
import kotlin.math.abs
32+
import kotlin.math.atan2
33+
import kotlin.math.cos
34+
import kotlin.math.round
35+
import kotlin.math.roundToInt
36+
import kotlin.math.sin
2437

2538
private val logger = KotlinLogging.logger {}
2639

@@ -92,6 +105,12 @@ class AnimatedSimulationCellRenderer(
92105
cellHeight: Int,
93106
private val animationController: AnimationController
94107
) : SimulationCellRenderer(cellWidth, cellHeight) {
108+
private val previousTrainLocations = mutableMapOf<Int, PointF>()
109+
private val previousTrainHeadings = mutableMapOf<Int, Double>()
110+
private val baseTrainShapeCache = mutableMapOf<TrainShapeKey, Shape>()
111+
private val rotatedTrainShapeCache = mutableMapOf<TrainRotationKey, Shape>()
112+
private val trainTranslationTransform = AffineTransform()
113+
95114
/**
96115
* Render track block part with occupancy state coloring.
97116
*
@@ -167,11 +186,7 @@ class AnimatedSimulationCellRenderer(
167186
g: Graphics2D,
168187
cell: InOut
169188
) {
170-
// Set color to light gray for InOut cells
171-
g.color = AnimationColors.DEFAULT_TRACK
172-
173-
// Delegate to parent for geometry rendering
174-
super.draw(g, cell)
189+
drawAnimatedInOut(g, cell.direction())
175190
}
176191

177192
/**
@@ -188,11 +203,7 @@ class AnimatedSimulationCellRenderer(
188203
g: Graphics2D,
189204
cell: DynamicInOut
190205
) {
191-
// Set color to light gray for InOut cells
192-
g.color = AnimationColors.DEFAULT_TRACK
193-
194-
// Delegate to parent for geometry rendering
195-
super.draw(g, cell)
206+
drawAnimatedInOut(g, cell.staticRef.direction())
196207
}
197208

198209
/**
@@ -276,19 +287,29 @@ class AnimatedSimulationCellRenderer(
276287
)
277288
}
278289

290+
private fun drawAnimatedInOut(
291+
g: Graphics2D,
292+
direction: Cell.Segment
293+
) {
294+
// Keep the entry/exit connection visible without reusing the legacy center circle
295+
// that looked like the previous train marker in animated mode.
296+
g.color = AnimationColors.DEFAULT_TRACK
297+
drawSegments(g, direction)
298+
}
299+
279300
/**
280301
* Draw a train overlay on the canvas.
281302
*
282-
* Trains are rendered as colored circles with white ID numbers and black borders
283-
* overlaid on top of the grid cells. This method should be called after all grid
284-
* cells have been rendered.
303+
* Trains are rendered as directional locomotive-like markers with white ID numbers
304+
* and black borders overlaid on top of the grid cells. This method should be called
305+
* after all grid cells have been rendered.
285306
*
286307
* ## Visual Design
287308
*
288-
* - **Train body:** Colored circle (12x12 pixels) at interpolated grid position
309+
* - **Train body:** Locomotive silhouette with cab, body, and pointed nose aligned to train movement
289310
* - **Origin-based colors:** Blue for trains from InOut B, orange for trains from InOut A
290-
* - **Border:** Black 2px stroke for visibility on all backgrounds
291-
* - **Train ID:** White text centered in the circle
311+
* - **Border:** Black stroke with width derived from train height and clamped for visibility
312+
* - **Train ID:** White text centered in the locomotive body
292313
* - **Multiple trains:** Positioned at different grid locations (no overlap if on different sections)
293314
* - **Color persistence:** Trains maintain their origin color throughout their entire journey
294315
*
@@ -312,14 +333,23 @@ class AnimatedSimulationCellRenderer(
312333
) {
313334
val gridLocation = trainState.frontGridLocation ?: return
314335

315-
// Convert grid coordinates to pixel coordinates (center of cell)
316-
// PointF provides continuous coordinates, round to nearest pixel for rendering
317-
val pixelX = (gridLocation.x * cellWidth + cellWidth / 2).toInt()
318-
val pixelY = (gridLocation.y * cellHeight + cellHeight / 2).toInt()
319-
320-
// Train size: 12x12 pixel circle (doubled from 6x6)
321-
val trainSize = 12
322-
val borderWidth = 2
336+
// Convert grid coordinates to pixel coordinates (center of cell).
337+
// PointF provides continuous coordinates, round to nearest pixel for rendering.
338+
val cellWidthHalf = cellWidth / 2.0
339+
val cellHeightHalf = cellHeight / 2.0
340+
val pixelX = (gridLocation.x * cellWidth + cellWidthHalf).roundToInt()
341+
val pixelY = (gridLocation.y * cellHeight + cellHeightHalf).roundToInt()
342+
val minCellSize = minOf(cellWidth, cellHeight).coerceAtLeast(1)
343+
val trainHeight = maxOf(MIN_TRAIN_HEIGHT_PIXELS, (minCellSize * TRAIN_HEIGHT_CELL_RATIO).roundToInt())
344+
val bodyLength =
345+
maxOf(
346+
trainHeight + MIN_BODY_LENGTH_EXTRA_PIXELS,
347+
(minCellSize * BODY_LENGTH_CELL_RATIO).roundToInt()
348+
)
349+
val noseLength = maxOf(MIN_NOSE_LENGTH_PIXELS, trainHeight / 2)
350+
val borderWidth = maxOf(1, trainHeight / 5)
351+
val heading = resolveTrainHeading(trainState.trainNumber, gridLocation)
352+
val trainShape = createTrainShape(pixelX, pixelY, bodyLength, trainHeight, noseLength, heading)
323353

324354
// Select body color based on origin InOut
325355
val bodyColor =
@@ -329,38 +359,40 @@ class AnimatedSimulationCellRenderer(
329359
AnimationColors.TRAIN_FROM_A // Orange (InOut A)
330360
}
331361

332-
// Draw train body (filled circle)
362+
// Draw train body.
363+
val oldStroke = g.stroke
364+
val oldFont = g.font
365+
val oldAntialiasing = g.getRenderingHint(RenderingHints.KEY_ANTIALIASING)
366+
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
333367
g.color = bodyColor
334-
g.fillOval(
335-
pixelX - trainSize / 2,
336-
pixelY - trainSize / 2,
337-
trainSize,
338-
trainSize
339-
)
368+
g.fill(trainShape)
340369

341-
// Draw black border (stroke)
370+
// Draw black border.
342371
g.color = AnimationColors.TRAIN_BORDER
343-
g.stroke = java.awt.BasicStroke(borderWidth.toFloat())
344-
g.drawOval(
345-
pixelX - trainSize / 2,
346-
pixelY - trainSize / 2,
347-
trainSize,
348-
trainSize
349-
)
372+
g.stroke = BasicStroke(borderWidth.toFloat())
373+
g.draw(trainShape)
350374

351-
// Draw train ID (white text centered)
375+
// Draw train ID centered in the body while keeping text horizontal for readability.
352376
g.color = AnimationColors.TRAIN_ID
377+
g.font = g.font.deriveFont(maxOf(8f, trainHeight * 0.8f))
353378
val idText = trainState.trainNumber.toString()
354379
val fontMetrics = g.fontMetrics
355380
val textWidth = fontMetrics.stringWidth(idText)
356381
val textHeight = fontMetrics.ascent
382+
val textOffset = bodyLength * BODY_TEXT_OFFSET_RATIO + noseLength * NOSE_TEXT_OFFSET_RATIO
383+
val textCenterX = pixelX - cos(heading) * textOffset
384+
val textCenterY = pixelY - sin(heading) * textOffset
357385

358-
// Center text in the circle
359386
g.drawString(
360387
idText,
361-
pixelX - textWidth / 2,
362-
pixelY + textHeight / 2 - 1 // Adjust for baseline
388+
(textCenterX - textWidth / 2).roundToInt(),
389+
(textCenterY + textHeight / 2 - 1).roundToInt()
363390
)
391+
392+
g.stroke = oldStroke
393+
g.font = oldFont
394+
val antialiasingToRestore = oldAntialiasing ?: RenderingHints.VALUE_ANTIALIAS_DEFAULT
395+
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, antialiasingToRestore)
364396
}
365397

366398
/**
@@ -385,10 +417,125 @@ class AnimatedSimulationCellRenderer(
385417
cellHeight: Int
386418
) {
387419
val state = animationController.getCurrentState()
420+
previousTrainLocations.keys.retainAll(state.trainStates.keys)
421+
previousTrainHeadings.keys.retainAll(state.trainStates.keys)
388422

389423
// Render each train
390424
for ((_, trainState) in state.trainStates) {
391425
drawTrain(g, trainState, cellWidth, cellHeight)
392426
}
393427
}
428+
429+
private fun resolveTrainHeading(
430+
trainNumber: Int,
431+
currentLocation: PointF
432+
): Double {
433+
val previousLocation = previousTrainLocations[trainNumber]
434+
val heading =
435+
previousLocation?.let { inferHeading(it, currentLocation) }
436+
?: previousTrainHeadings[trainNumber]
437+
?: DEFAULT_TRAIN_HEADING
438+
439+
previousTrainLocations[trainNumber] = currentLocation
440+
previousTrainHeadings[trainNumber] = heading
441+
442+
return heading
443+
}
444+
445+
private fun inferHeading(
446+
previousLocation: PointF,
447+
currentLocation: PointF
448+
): Double? {
449+
val dx = (currentLocation.x - previousLocation.x).toDouble()
450+
val dy = (currentLocation.y - previousLocation.y).toDouble()
451+
if (abs(dx) < HEADING_EPSILON && abs(dy) < HEADING_EPSILON) {
452+
return null
453+
}
454+
455+
return snapHeadingToNearestCompassDirection(atan2(dy, dx))
456+
}
457+
458+
private fun snapHeadingToNearestCompassDirection(angle: Double): Double =
459+
round(angle / SEGMENT_ANGLE_STEP) * SEGMENT_ANGLE_STEP
460+
461+
private fun createTrainShape(
462+
pixelX: Int,
463+
pixelY: Int,
464+
bodyLength: Int,
465+
trainHeight: Int,
466+
noseLength: Int,
467+
heading: Double
468+
): Shape {
469+
val rotatedShape = getRotatedTrainShape(bodyLength, trainHeight, noseLength, heading)
470+
trainTranslationTransform.setToIdentity()
471+
trainTranslationTransform.translate(pixelX.toDouble(), pixelY.toDouble())
472+
return trainTranslationTransform.createTransformedShape(rotatedShape)
473+
}
474+
475+
private fun getRotatedTrainShape(
476+
bodyLength: Int,
477+
trainHeight: Int,
478+
noseLength: Int,
479+
heading: Double
480+
): Shape {
481+
val shapeKey = TrainShapeKey(bodyLength, trainHeight, noseLength)
482+
val rotationKey = TrainRotationKey(shapeKey, heading)
483+
return rotatedTrainShapeCache.getOrPut(rotationKey) {
484+
val baseShape = getBaseTrainShape(shapeKey)
485+
AffineTransform.getRotateInstance(heading).createTransformedShape(baseShape)
486+
}
487+
}
488+
489+
private fun getBaseTrainShape(shapeKey: TrainShapeKey): Shape =
490+
baseTrainShapeCache.getOrPut(shapeKey) {
491+
val halfHeight = shapeKey.trainHeight / 2.0
492+
val cabHeight = shapeKey.trainHeight * CAB_HEIGHT_RATIO
493+
val cabHalfHeight = cabHeight / 2.0
494+
// bodyLength represents the full rectangular locomotive body behind the nose.
495+
// Split that footprint into a shorter, lower rear cab and a longer, taller main body section.
496+
val cabLength = shapeKey.bodyLength * CAB_LENGTH_RATIO
497+
val rearX = -(shapeKey.bodyLength + shapeKey.noseLength).toDouble()
498+
val cabFrontX = rearX + cabLength
499+
val noseBaseX = -shapeKey.noseLength.toDouble()
500+
501+
Path2D.Double().apply {
502+
moveTo(rearX, -cabHalfHeight)
503+
lineTo(cabFrontX, -cabHalfHeight)
504+
lineTo(cabFrontX, -halfHeight)
505+
lineTo(noseBaseX, -halfHeight)
506+
lineTo(0.0, 0.0)
507+
lineTo(noseBaseX, halfHeight)
508+
lineTo(cabFrontX, halfHeight)
509+
lineTo(cabFrontX, cabHalfHeight)
510+
lineTo(rearX, cabHalfHeight)
511+
closePath()
512+
}
513+
}
514+
515+
private data class TrainShapeKey(
516+
val bodyLength: Int,
517+
val trainHeight: Int,
518+
val noseLength: Int
519+
)
520+
521+
private data class TrainRotationKey(
522+
val shapeKey: TrainShapeKey,
523+
val heading: Double
524+
)
525+
526+
private companion object {
527+
const val HEADING_EPSILON = 0.001
528+
const val DEFAULT_TRAIN_HEADING = 0.0
529+
const val SEGMENT_ANGLE_STEP = PI / 4.0
530+
const val TRAIN_HEIGHT_CELL_RATIO = 0.55
531+
const val BODY_LENGTH_CELL_RATIO = 0.9
532+
const val MIN_TRAIN_HEIGHT_PIXELS = 8
533+
const val MIN_BODY_LENGTH_EXTRA_PIXELS = 6
534+
const val MIN_NOSE_LENGTH_PIXELS = 4
535+
const val BODY_TEXT_OFFSET_RATIO = 0.55
536+
const val NOSE_TEXT_OFFSET_RATIO = 0.2
537+
// Geometry ratios chosen by visual tuning so the marker reads as a locomotive at 16-20 px cell sizes.
538+
const val CAB_LENGTH_RATIO = 0.32 // Rear cab takes roughly one third of the rectangular body length.
539+
const val CAB_HEIGHT_RATIO = 0.6 // Reduced from 0.72 so the rear cab keeps a stronger visible step at 16px cells.
540+
}
394541
}

0 commit comments

Comments
 (0)