@@ -19,8 +19,21 @@ import cz.vutbr.fit.interlockSim.objects.cells.InOut
1919import cz.vutbr.fit.interlockSim.objects.cells.RailSwitch
2020import cz.vutbr.fit.interlockSim.objects.cells.TrackBlockPart
2121import cz.vutbr.fit.interlockSim.objects.core.Cell
22+ import cz.vutbr.fit.interlockSim.util.PointF
2223import io.github.oshai.kotlinlogging.KotlinLogging
24+ import java.awt.BasicStroke
2325import 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
2538private 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