diff --git a/.idea/caches/deviceStreaming.xml b/.idea/caches/deviceStreaming.xml index af74dbf..5a2f866 100644 --- a/.idea/caches/deviceStreaming.xml +++ b/.idea/caches/deviceStreaming.xml @@ -25,6 +25,17 @@ diff --git a/src/main/kotlin/org/rowlandhall/meepmeep/MeepMeep.kt b/src/main/kotlin/org/rowlandhall/meepmeep/MeepMeep.kt index 8eee087..8f48516 100644 --- a/src/main/kotlin/org/rowlandhall/meepmeep/MeepMeep.kt +++ b/src/main/kotlin/org/rowlandhall/meepmeep/MeepMeep.kt @@ -9,12 +9,21 @@ import org.rowlandhall.meepmeep.core.entity.Entity import org.rowlandhall.meepmeep.core.entity.EntityEventListener import org.rowlandhall.meepmeep.core.entity.ThemedEntity import org.rowlandhall.meepmeep.core.entity.ZIndexManager +import org.rowlandhall.meepmeep.core.scaleInToPixel +import org.rowlandhall.meepmeep.core.toDegrees +import org.rowlandhall.meepmeep.core.toRadians +import org.rowlandhall.meepmeep.core.toScreenCoord import org.rowlandhall.meepmeep.core.ui.WindowFrame import org.rowlandhall.meepmeep.core.util.FieldUtil import org.rowlandhall.meepmeep.core.util.LoopManager import org.rowlandhall.meepmeep.roadrunner.entity.RoadRunnerBotEntity +import org.rowlandhall.meepmeep.roadrunner.trajectorysequence.sequencesegment.TrajectorySegment +import org.rowlandhall.meepmeep.roadrunner.trajectorysequence.sequencesegment.TurnSegment +import org.rowlandhall.meepmeep.roadrunner.trajectorysequence.sequencesegment.WaitSegment import org.rowlandhall.meepmeep.roadrunner.ui.TrajectoryProgressSliderMaster import java.awt.AlphaComposite +import java.awt.BasicStroke +import java.awt.Color import java.awt.Font import java.awt.Graphics2D import java.awt.Image @@ -26,9 +35,14 @@ import java.awt.event.KeyListener import java.awt.event.MouseEvent import java.awt.event.MouseListener import java.awt.event.MouseMotionListener +import java.awt.geom.Path2D import java.awt.image.BufferedImage +import java.io.File import javax.imageio.ImageIO import javax.swing.UIManager +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.min /** * The [MeepMeep] class is the main entry point for the Meep Meep @@ -36,8 +50,14 @@ import javax.swing.UIManager * the application window, rendering, and entity management. * * @constructor Creates a [MeepMeep] instance with specified window + * + * ``` * dimensions and optional fps. - * @property windowX The width of the application window. + * @property windowX + * ``` + * + * The width of the application window. + * * @property windowY The height of the application window. * @property fps The frames per second for the application loop. * @see [WindowFrame] @@ -50,9 +70,9 @@ import javax.swing.UIManager * @see [FieldUtil] */ @Suppress("unused", "MemberVisibilityCanBePrivate", "SpellCheckingInspection") -class MeepMeep @JvmOverloads constructor( - private val windowX: Int, private val windowY: Int, private val fps: Int = 60 -) { +class MeepMeep +@JvmOverloads +constructor(private val windowX: Int, private val windowY: Int, private val fps: Int = 60) { /** * Companion object to hold default entities and fonts used in the MeepMeep * application. @@ -134,9 +154,7 @@ class MeepMeep @JvmOverloads constructor( */ private val progressSliderMasterPanel: TrajectoryProgressSliderMaster by lazy { // Create a new instance of TrajectoryProgressSliderMaster - TrajectoryProgressSliderMaster( - this, FieldUtil.CANVAS_WIDTH.toInt(), 20 - ) + TrajectoryProgressSliderMaster(this, FieldUtil.CANVAS_WIDTH.toInt(), 20) } // TODO: Make custom dirty list that auto sorts @@ -148,19 +166,25 @@ class MeepMeep @JvmOverloads constructor( val classLoader = Thread.currentThread().contextClassLoader // Load Roboto Regular font from file - FONT_ROBOTO_REGULAR = Font.createFont( - Font.TRUETYPE_FONT, classLoader.getResourceAsStream("font/Roboto-Regular.ttf") - ) + FONT_ROBOTO_REGULAR = + Font.createFont( + Font.TRUETYPE_FONT, + classLoader.getResourceAsStream("font/Roboto-Regular.ttf") + ) // Load Roboto Bold font from file - FONT_ROBOTO_BOLD = Font.createFont( - Font.TRUETYPE_FONT, classLoader.getResourceAsStream("font/Roboto-Bold.ttf") - ) + FONT_ROBOTO_BOLD = + Font.createFont( + Font.TRUETYPE_FONT, + classLoader.getResourceAsStream("font/Roboto-Bold.ttf") + ) // Load Roboto Bold Italic font from file - FONT_ROBOTO_BOLD_ITALIC = Font.createFont( - Font.TRUETYPE_FONT, classLoader.getResourceAsStream("font/Roboto-BoldItalic.ttf") - ) + FONT_ROBOTO_BOLD_ITALIC = + Font.createFont( + Font.TRUETYPE_FONT, + classLoader.getResourceAsStream("font/Roboto-BoldItalic.ttf") + ) // Set the look and feel of the UI to the system's default UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()) @@ -176,14 +200,12 @@ class MeepMeep @JvmOverloads constructor( FieldUtil.CANVAS_HEIGHT = windowY.toDouble() // Initialize axes entity - DEFAULT_AXES_ENTITY = AxesEntity( - this, 0.8, colorManager.theme, FONT_ROBOTO_BOLD_ITALIC, 20f - ) + DEFAULT_AXES_ENTITY = + AxesEntity(this, 0.8, colorManager.theme, FONT_ROBOTO_BOLD_ITALIC, 20f) // Initialize compass entity - DEFAULT_COMPASS_ENTITY = CompassEntity( - this, colorManager.theme, 30.0, 30.0, Vector2d(-54.0, 54.0) - ) + DEFAULT_COMPASS_ENTITY = + CompassEntity(this, colorManager.theme, 30.0, 30.0, Vector2d(-54.0, 54.0)) // Add the progress slider panel to the canvas panel windowFrame.canvasPanel.add(progressSliderMasterPanel) @@ -192,61 +214,72 @@ class MeepMeep @JvmOverloads constructor( windowFrame.pack() // Add mouse motion listener to the canvas - canvas.addMouseMotionListener(object: MouseMotionListener { - override fun mouseDragged(p0: MouseEvent?) {} + canvas.addMouseMotionListener( + object: MouseMotionListener { + override fun mouseDragged(p0: MouseEvent?) {} - override fun mouseMoved(e: MouseEvent) { - canvasMouseX = e.x - canvasMouseY = e.y + override fun mouseMoved(e: MouseEvent) { + canvasMouseX = e.x + canvasMouseY = e.y + } } - }) + ) // Add key listener to the canvas - canvas.addKeyListener(object: KeyListener { - /** - * Invoked when a key has been typed. This event occurs when a key press is - * followed by a key release. - * - * @param p0 The KeyEvent that triggered this method. - */ - override fun keyTyped(p0: KeyEvent?) {} - - /** - * Invoked when a key has been pressed. This event occurs when a key press - * is detected. - * - * @param e The KeyEvent that triggered this method. - */ - override fun keyPressed(e: KeyEvent) { // Check if the 'C' or 'COPY' (Often `Ctrl/CMD + C`) key is pressed - if (e.keyCode == KeyEvent.VK_C || e.keyCode == KeyEvent.VK_COPY) { // Convert mouse coordinates from screen to field coordinates - val mouseToFieldCoords = FieldUtil.screenCoordsToFieldCoords( - Vector2d( - canvasMouseX.toDouble(), canvasMouseY.toDouble() - ) - ) - - // Format the coordinates as a string - val stringSelection = StringSelection( - "%.1f, %.1f".format( - mouseToFieldCoords.x, - mouseToFieldCoords.y, - ) - ) - - // Get the system clipboard and set the contents to the formatted coordinates - val clipboard = Toolkit.getDefaultToolkit().systemClipboard - clipboard.setContents(stringSelection, null) + canvas.addKeyListener( + object: KeyListener { + /** + * Invoked when a key has been typed. This event occurs when a key press is + * followed by a key release. + * + * @param p0 The KeyEvent that triggered this method. + */ + override fun keyTyped(p0: KeyEvent?) {} + + /** + * Invoked when a key has been pressed. This event occurs when a key press + * is detected. + * + * @param e The KeyEvent that triggered this method. + */ + override fun keyPressed( + e: KeyEvent + ) { // Check if the 'C' or 'COPY' (Often `Ctrl/CMD + C`) key is pressed + if (e.keyCode == KeyEvent.VK_C || e.keyCode == KeyEvent.VK_COPY + ) { // Convert mouse coordinates from screen to field coordinates + val mouseToFieldCoords = + FieldUtil.screenCoordsToFieldCoords( + Vector2d( + canvasMouseX.toDouble(), + canvasMouseY.toDouble() + ) + ) + + // Format the coordinates as a string + val stringSelection = + StringSelection( + "%.1f, %.1f".format( + mouseToFieldCoords.x, + mouseToFieldCoords.y, + ) + ) + + // Get the system clipboard and set the contents to the formatted + // coordinates + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(stringSelection, null) + } } - } - /** - * Invoked when a key has been released. This event occurs when a key - * release is detected. - * - * @param p0 The KeyEvent that triggered this method. - */ - override fun keyReleased(p0: KeyEvent?) {} - }) + /** + * Invoked when a key has been released. This event occurs when a key + * release is detected. + * + * @param p0 The KeyEvent that triggered this method. + */ + override fun keyReleased(p0: KeyEvent?) {} + } + ) // Set the z-index hierarchy for entities zIndexManager.setTagHierarchy( @@ -310,14 +343,16 @@ class MeepMeep @JvmOverloads constructor( } // Convert mouse coordinates from screen to field coordinates - val mouseToFieldCoords = FieldUtil.screenCoordsToFieldCoords( - Vector2d(canvasMouseX.toDouble(), canvasMouseY.toDouble()) - ) + val mouseToFieldCoords = + FieldUtil.screenCoordsToFieldCoords( + Vector2d(canvasMouseX.toDouble(), canvasMouseY.toDouble()) + ) // Draw the mouse coordinates g.font = FONT_ROBOTO_BOLD.deriveFont(14f) g.color = - if (colorManager.isDarkMode) ColorManager.COLOR_PALETTE.gray100 else ColorManager.COLOR_PALETTE.gray800 + if (colorManager.isDarkMode) ColorManager.COLOR_PALETTE.gray100 + else ColorManager.COLOR_PALETTE.gray800 g.drawString( "(%.1f, %.1f)".format(mouseToFieldCoords.x, mouseToFieldCoords.y), mouseCoordinateDisplayX, @@ -407,35 +442,74 @@ class MeepMeep @JvmOverloads constructor( fun setBackground(background: Background): MeepMeep { val classLoader = Thread.currentThread().contextClassLoader - val backgroundMap = Background.values().associateWith { bg -> - val path = when (bg) { - Background.GRID_BLUE -> "background/misc/field-grid-blue.jpg" - Background.GRID_GREEN -> "background/misc/field-grid-green.jpg" - Background.GRID_GRAY -> "background/misc/field-grid-gray.jpg" - Background.FIELD_SKYSTONE_OFFICIAL -> "background/season-2019-skystone/field-2019-skystone-official.png" - Background.FIELD_SKYSTONE_GF_DARK -> "background/season-2019-skystone/field-2019-skystone-gf-dark.png" - Background.FIELD_SKYSTONE_INNOV8RZ_LIGHT -> "background/season-2019-skystone/field-2019-skystone-innov8rz-light.jpg" - Background.FIELD_SKYSTONE_INNOV8RZ_DARK -> "background/season-2019-skystone/field-2019-skystone-innov8rz-dark.jpg" - Background.FIELD_SKYSTONE_STARWARS_DARK -> "background/season-2019-skystone/field-2019-skystone-starwars.png" - Background.FIELD_ULTIMATEGOAL_INNOV8RZ_DARK -> "background/season-2020-ultimategoal/field-2020-innov8rz-dark.jpg" - Background.FIELD_FREIGHTFRENZY_OFFICIAL -> "background/season-2021-freightfrenzy/field-2021-official.png" - Background.FIELD_FREIGHTFRENZY_ADI_DARK -> "background/season-2021-freightfrenzy/field-2021-adi-dark.png" - Background.FIELD_POWERPLAY_OFFICIAL -> "background/season-2022-powerplay/field-2022-official.png" - Background.FIELD_POWERPLAY_KAI_DARK -> "background/season-2022-powerplay/field-2022-kai-dark.png" - Background.FIELD_POWERPLAY_KAI_LIGHT -> "background/season-2022-powerplay/field-2022-kai-light.png" - Background.FIELD_CENTERSTAGE_OFFICIAL -> "background/season-2023-centerstage/field-2023-official.png" - Background.FIELD_CENTERSTAGE_JUICE_DARK -> "background/season-2023-centerstage/field-2023-juice-dark.png" - Background.FIELD_CENTERSTAGE_JUICE_LIGHT -> "background/season-2023-centerstage/field-2023-juice-light.png" - Background.FIELD_INTOTHEDEEP_OFFICIAL -> "background/season-2024-intothedeep/field-2024-official.png" - Background.FIELD_INTOTHEDEEP_JUICE_DARK -> "background/season-2024-intothedeep/field-2024-juice-dark.png" - Background.FIELD_INTOTHEDEEP_JUICE_LIGHT -> "background/season-2024-intothedeep/field-2024-juice-light.png" - Background.FIELD_INTOTHEDEEP_JUICE_GREYSCALE -> "background/season-2024-intothedeep/field-2024-juice-greyscale.png" - Background.FIELD_INTOTHEDEEP_JUICE_BLACK -> "background/season-2024-intothedeep/field-2024-juice-black.png" - } + val backgroundMap = + Background.values().associateWith { bg -> + val path = + when (bg) { + Background.GRID_BLUE -> "background/misc/field-grid-blue.jpg" + Background.GRID_GREEN -> "background/misc/field-grid-green.jpg" + Background.GRID_GRAY -> "background/misc/field-grid-gray.jpg" + Background.FIELD_SKYSTONE_OFFICIAL -> + "background/season-2019-skystone/field-2019-skystone-official.png" - val isDarkMode = path.contains("dark", ignoreCase = true) - Pair(path, isDarkMode) - } + Background.FIELD_SKYSTONE_GF_DARK -> + "background/season-2019-skystone/field-2019-skystone-gf-dark.png" + + Background.FIELD_SKYSTONE_INNOV8RZ_LIGHT -> + "background/season-2019-skystone/field-2019-skystone-innov8rz-light.jpg" + + Background.FIELD_SKYSTONE_INNOV8RZ_DARK -> + "background/season-2019-skystone/field-2019-skystone-innov8rz-dark.jpg" + + Background.FIELD_SKYSTONE_STARWARS_DARK -> + "background/season-2019-skystone/field-2019-skystone-starwars.png" + + Background.FIELD_ULTIMATEGOAL_INNOV8RZ_DARK -> + "background/season-2020-ultimategoal/field-2020-innov8rz-dark.jpg" + + Background.FIELD_FREIGHTFRENZY_OFFICIAL -> + "background/season-2021-freightfrenzy/field-2021-official.png" + + Background.FIELD_FREIGHTFRENZY_ADI_DARK -> + "background/season-2021-freightfrenzy/field-2021-adi-dark.png" + + Background.FIELD_POWERPLAY_OFFICIAL -> + "background/season-2022-powerplay/field-2022-official.png" + + Background.FIELD_POWERPLAY_KAI_DARK -> + "background/season-2022-powerplay/field-2022-kai-dark.png" + + Background.FIELD_POWERPLAY_KAI_LIGHT -> + "background/season-2022-powerplay/field-2022-kai-light.png" + + Background.FIELD_CENTERSTAGE_OFFICIAL -> + "background/season-2023-centerstage/field-2023-official.png" + + Background.FIELD_CENTERSTAGE_JUICE_DARK -> + "background/season-2023-centerstage/field-2023-juice-dark.png" + + Background.FIELD_CENTERSTAGE_JUICE_LIGHT -> + "background/season-2023-centerstage/field-2023-juice-light.png" + + Background.FIELD_INTOTHEDEEP_OFFICIAL -> + "background/season-2024-intothedeep/field-2024-official.png" + + Background.FIELD_INTOTHEDEEP_JUICE_DARK -> + "background/season-2024-intothedeep/field-2024-juice-dark.png" + + Background.FIELD_INTOTHEDEEP_JUICE_LIGHT -> + "background/season-2024-intothedeep/field-2024-juice-light.png" + + Background.FIELD_INTOTHEDEEP_JUICE_GREYSCALE -> + "background/season-2024-intothedeep/field-2024-juice-greyscale.png" + + Background.FIELD_INTOTHEDEEP_JUICE_BLACK -> + "background/season-2024-intothedeep/field-2024-juice-black.png" + } + + val isDarkMode = path.contains("dark", ignoreCase = true) + Pair(path, isDarkMode) + } // Get the path and dark mode boolean from the background map val (path, isDarkMode) = backgroundMap[background]!! @@ -471,24 +545,42 @@ class MeepMeep @JvmOverloads constructor( /** * Sets the compass image for the MeepMeep application. * - * @param compassImage The [CompassImage] enum representing the wanted compass image. - * @return The [MeepMeep] instance for method chaining. + * @param compassImage The [CompassImage] enum representing the wanted + * + * ``` + * compass image. + * @return + * ``` + * + * The [MeepMeep] instance for method chaining. */ fun setCompassImage(compassImage: CompassImage): MeepMeep { val classLoader = Thread.currentThread().contextClassLoader - val compassImageMap = CompassImage.values().associateWith { img -> - val path = when (img) { - CompassImage.SIMPLE -> if (colorManager.isDarkMode) "misc/simple-compass-white.png" else "misc/simple-compass-black.png" - CompassImage.SIMPLE_BLACK -> "misc/simple-compass-black.png" - CompassImage.SIMPLE_WHITE -> "misc/simple-compass-white.png" - CompassImage.COMPASS_ROSE -> if (colorManager.isDarkMode) "misc/compass-rose-white-text.png" else "misc/compass-rose-black-text.png" - CompassImage.COMPASS_ROSE_WHITE -> "misc/compass-rose-white-text.png" - CompassImage.COMPASS_ROSE_BLACK -> "misc/compass-rose-black-text.png" - } - - path - } + val compassImageMap = + CompassImage.values().associateWith { img -> + val path = + when (img) { + CompassImage.SIMPLE -> + if (colorManager.isDarkMode) "misc/simple-compass-white.png" + else "misc/simple-compass-black.png" + + CompassImage.SIMPLE_BLACK -> "misc/simple-compass-black.png" + CompassImage.SIMPLE_WHITE -> "misc/simple-compass-white.png" + CompassImage.COMPASS_ROSE -> + if (colorManager.isDarkMode) + "misc/compass-rose-white-text.png" + else "misc/compass-rose-black-text.png" + + CompassImage.COMPASS_ROSE_WHITE -> + "misc/compass-rose-white-text.png" + + CompassImage.COMPASS_ROSE_BLACK -> + "misc/compass-rose-black-text.png" + } + + path + } // Get the path and dark mode boolean from the compass image map val path = compassImageMap[compassImage]!! @@ -501,7 +593,8 @@ class MeepMeep @JvmOverloads constructor( /** * Sets the compass image for the MeepMeep application. * - * This method scales the provided [Image] and sets it as the compass image, allowing for custom compass images. + * This method scales the provided [Image] and sets it as the compass + * image, allowing for custom compass images. * * @param image The [Image] to be set as the compass image. * @return The [MeepMeep] instance for method chaining. @@ -524,9 +617,7 @@ class MeepMeep @JvmOverloads constructor( * @param x The x-coordinate for displaying the mouse coordinates. * @param y The y-coordinate for displaying the mouse coordinates. */ - fun setMouseCoordinateDisplayPosition( - x: Int, y: Int - ) { + fun setMouseCoordinateDisplayPosition(x: Int, y: Int) { // Update the x-coordinate for the mouse coordinate display mouseCoordinateDisplayX = x @@ -538,8 +629,13 @@ class MeepMeep @JvmOverloads constructor( * Sets the visibility of the FPS display. * * @param showFPS A boolean indicating whether the FPS display should be + * + * ``` * shown. - * @return The [MeepMeep] instance for method chaining. + * @return + * ``` + * + * The [MeepMeep] instance for method chaining. */ fun setShowFPS(showFPS: Boolean): MeepMeep { // Update the showFPS property @@ -557,15 +653,19 @@ class MeepMeep @JvmOverloads constructor( * * @param schemeLight The [ColorScheme] to be used for the light theme. * @param schemeDark The [ColorScheme] to be used for the dark theme. + * + * ``` * Defaults to the light theme if not provided. - * @return The [MeepMeep] instance for method chaining. + * @return + * ``` + * + * The [MeepMeep] instance for method chaining. + * * @see [ColorManager] * @see [refreshTheme] */ @JvmOverloads - fun setTheme( - schemeLight: ColorScheme, schemeDark: ColorScheme = schemeLight - ): MeepMeep { + fun setTheme(schemeLight: ColorScheme, schemeDark: ColorScheme = schemeLight): MeepMeep { // Set the light and dark themes in the ColorManager colorManager.setTheme(schemeLight, schemeDark) @@ -589,9 +689,7 @@ class MeepMeep @JvmOverloads constructor( */ private fun refreshTheme() { // Core Refresh: Update the theme for all entities that implement ThemedEntity - entityList.forEach { - if (it is ThemedEntity) it.switchScheme(colorManager.theme) - } + entityList.forEach { if (it is ThemedEntity) it.switchScheme(colorManager.theme) } // Update the background color of the main content pane and canvas panel windowFrame.contentPane.background = colorManager.theme.uiMainBG @@ -606,8 +704,14 @@ class MeepMeep @JvmOverloads constructor( * chaining. * * @param isDarkMode A boolean indicating whether dark mode should be + * + * ``` * enabled. - * @return The [MeepMeep] instance for method chaining. + * @return + * ``` + * + * The [MeepMeep] instance for method chaining. + * * @see [ColorManager] */ fun setDarkMode(isDarkMode: Boolean): MeepMeep { @@ -618,6 +722,277 @@ class MeepMeep @JvmOverloads constructor( return this } + /** + * Exports the current trajectory as an image for use in posters, + * portfolios, etc. + * + * @param filePath The path where the image should be saved. + * @return The [MeepMeep] instance for method chaining. + */ + fun exportTrajectoryImage(filePath: String): MeepMeep { + val exportImage = BufferedImage(windowX, windowY, BufferedImage.TYPE_INT_ARGB) + val g = exportImage.createGraphics() + + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY) + g.setRenderingHint( + RenderingHints.KEY_INTERPOLATION, + RenderingHints.VALUE_INTERPOLATION_BICUBIC + ) + g.setRenderingHint( + RenderingHints.KEY_COLOR_RENDERING, + RenderingHints.VALUE_COLOR_RENDER_QUALITY + ) + g.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE) + + // Draw the Field Background + bg?.let { background -> + val scaledBg = background.getScaledInstance(windowX, windowY, Image.SCALE_SMOOTH) + g.drawImage(scaledBg, 0, 0, null) + } + + // Define the shift vector + val shiftVector = Vector2d(0.0, 0.5) // Adjust this vector as needed + + // Constants for drawing + val strokeWidth = 5.0 + val turnIndicatorRadius = 7.5 + val arrowHeadLength = 1.5 + val arrowLinesAngle = 45.0.toRadians() + val endTrimDistance = 2.5 + val startCircleRadius = 7.0 + + entityList.forEach { entity -> + if (entity is RoadRunnerBotEntity) { + entity.currentTrajectorySequence?.let { sequence -> + // Stroke for all trajectory segments + g.stroke = BasicStroke( + strokeWidth.toFloat(), + BasicStroke.CAP_ROUND, + BasicStroke.JOIN_ROUND + ) + + // For each segment in the sequence + for (i in 0 until sequence.size()) { + when (val segment = sequence.get(i)) { + is TrajectorySegment -> { + // Draw the trajectory path + val path = Path2D.Double() // Create a new path + val trajectory = segment.trajectory // Get the trajectory + val endHeading = trajectory.end().heading // Get the end heading + var lastDrawnPoint: Vector2d? = null + + // Sample points along the trajectory + val pathLength = trajectory.path.length() + val numPoints = (pathLength / 0.1).toInt() + var firstPointDrawn = false + + for (j in 0..numPoints) { + val t = j.toDouble() / numPoints + val currentDistance = pathLength * t + + // Skip points that are within endTrimDistance from either end + if (currentDistance < endTrimDistance || currentDistance > pathLength - endTrimDistance) { + continue + } + + val point = trajectory.path[currentDistance] + val screenPoint = (point.vec() + shiftVector).toScreenCoord() + lastDrawnPoint = point.vec() + shiftVector + + if (!firstPointDrawn) { + path.moveTo(screenPoint.x, screenPoint.y) + firstPointDrawn = true + } else { + path.lineTo(screenPoint.x, screenPoint.y) + } + } + + // Draw the path + g.color = entity.colorScheme.botBodyColor + g.draw(path) + + // Draw starting point circle + if (i == 0) { + val startPoint = + (trajectory.start().vec() + shiftVector).toScreenCoord() + + val originalStroke = g.stroke + val originalColor = g.color + g.stroke = BasicStroke( + 3.0f, + BasicStroke.CAP_ROUND, + BasicStroke.JOIN_ROUND + ) + g.color = Color(34,139,34) + + g.drawOval( + (startPoint.x - startCircleRadius).toInt(), + (startPoint.y - startCircleRadius).toInt(), + (startCircleRadius * 2).toInt(), + (startCircleRadius * 2).toInt() + ) + + g.stroke = originalStroke + g.color = originalColor + } + + + + // Calculate & draw arrow endpoints + lastDrawnPoint?.let { lastPoint -> + val secondToLastPoint = trajectory.path[pathLength - endTrimDistance - 0.1].vec() + shiftVector + val directionVector = lastPoint - secondToLastPoint + val pathHeading = atan2(directionVector.y, directionVector.x) + + // Calculate arrow endpoints + val screenArrowEndVec1 = (lastPoint + Vector2d( + arrowHeadLength, + 0.0 + ).rotated(pathHeading - 180.0.toRadians() + arrowLinesAngle)).toScreenCoord() + val screenArrowEndVec2 = (lastPoint + Vector2d( + arrowHeadLength, + 0.0 + ).rotated(pathHeading - 180.0.toRadians() - arrowLinesAngle)).toScreenCoord() + + // Draw the arrow lines + g.drawLine( + lastPoint.toScreenCoord().x.toInt(), + lastPoint.toScreenCoord().y.toInt(), + screenArrowEndVec1.x.toInt(), + screenArrowEndVec1.y.toInt() + ) + g.drawLine( + lastPoint.toScreenCoord().x.toInt(), + lastPoint.toScreenCoord().y.toInt(), + screenArrowEndVec2.x.toInt(), + screenArrowEndVec2.y.toInt() + ) + } + } + + is TurnSegment -> { + // Constants matching TurnIndicatorEntity + val turnCircleRadius = 1.0 + val turnArrowLength = 1.5 + val turnArrowAngleAdjustment = (-12.5).toRadians() + + // Get the start pose and calculate start/end angles + val startPose = segment.startPose + val startAngle = startPose.heading + val endAngle = startPose.heading + segment.totalRotation + + // Calculate the diagonal angle for the arc + val diagonalAngle = (startAngle + endAngle) / 2 + + // Convert position to screen coordinates + val pos = (startPose.vec() + shiftVector).toScreenCoord() + + // Draw the turn circle + g.color = entity.colorScheme.trajectoryTurnColor + g.fillOval( + (pos.x - turnCircleRadius.scaleInToPixel() / 2).toInt(), + (pos.y - turnCircleRadius.scaleInToPixel() / 2).toInt(), + turnCircleRadius.scaleInToPixel().toInt(), + turnCircleRadius.scaleInToPixel().toInt() + ) + + // Draw the turn arc + g.drawArc( + (pos.x - turnIndicatorRadius.scaleInToPixel() / 2).toInt(), + (pos.y - turnIndicatorRadius.scaleInToPixel() / 2).toInt(), + turnIndicatorRadius.scaleInToPixel().toInt(), + turnIndicatorRadius.scaleInToPixel().toInt(), + min( + startAngle.toDegrees().toInt(), + diagonalAngle.toDegrees().toInt() + ), + abs( + endAngle.toDegrees().toInt() - diagonalAngle.toDegrees() + .toInt() + ) + ) + + // Calculate arrow point + val arrowPointVec = + Vector2d( + turnIndicatorRadius / 2, + 0.0 + ).rotated(diagonalAngle) + val translatedPoint = + ((startPose.vec() + arrowPointVec) + shiftVector).toScreenCoord() + + // Calculate arrow rotations + var arrow1Rotated = + diagonalAngle - 90.0.toRadians() + arrowLinesAngle + turnArrowAngleAdjustment + if (diagonalAngle < startAngle) arrow1Rotated = + 360.0.toRadians() - arrow1Rotated + + var arrow2Rotated = + diagonalAngle - 90.0.toRadians() - arrowLinesAngle + turnArrowAngleAdjustment + if (diagonalAngle < startAngle) arrow2Rotated = + 360.0.toRadians() - arrow2Rotated + + // Calculate arrow endpoints + val translatedArrowEndVec1 = + ((startPose.vec() + arrowPointVec) + Vector2d( + turnArrowLength, + 0.0 + ).rotated(arrow1Rotated) + shiftVector).toScreenCoord() + val translatedArrowEndVec2 = + ((startPose.vec() + arrowPointVec) + Vector2d( + turnArrowLength, + 0.0 + ).rotated(arrow2Rotated) + shiftVector).toScreenCoord() + + // Draw the arrow lines + g.drawLine( + translatedPoint.x.toInt(), + translatedPoint.y.toInt(), + translatedArrowEndVec1.x.toInt(), + translatedArrowEndVec1.y.toInt() + ) + g.drawLine( + translatedPoint.x.toInt(), + translatedPoint.y.toInt(), + translatedArrowEndVec2.x.toInt(), + translatedArrowEndVec2.y.toInt() + ) + } + + is WaitSegment -> { + // Constants for the wait circle + val waitCircleRadius = 1.0 + + // Get the wait pose + val waitPose = segment.startPose + + // Convert position to screen coordinates + val pos = (waitPose.vec() + shiftVector).toScreenCoord() + + // Draw the wait circle + g.color = entity.colorScheme.trajectoryMarkerColor + g.fillOval( + (pos.x - waitCircleRadius.scaleInToPixel() / 2).toInt(), + (pos.y - waitCircleRadius.scaleInToPixel() / 2).toInt(), + waitCircleRadius.scaleInToPixel().toInt(), + waitCircleRadius.scaleInToPixel().toInt() + ) + } + } + } + } + } + } + + g.dispose() + + val outputFile = File(filePath) + ImageIO.write(exportImage, filePath.substringAfterLast('.').uppercase(), outputFile) + + return this + } + /** * Adjusts the canvas dimensions to match the window size and updates all * entities with the new dimensions. @@ -670,7 +1045,7 @@ class MeepMeep @JvmOverloads constructor( * mouse events if applicable. It also adds the entity to the * [TrajectoryProgressSliderMaster] panel if it is a [RoadRunnerBotEntity], * and triggers the [EntityEventListener.onAddToEntityList] - * method if the entity implements [EntityEventListener]. + * method if the entity implements [EntityEventListener] . * * @param entity The [Entity] to be added to the application. * @return The [MeepMeep] instance for method chaining. @@ -790,8 +1165,14 @@ class MeepMeep @JvmOverloads constructor( * 0.0 (completely transparent) and 1.0 (completely opaque). * * @param alpha The alpha transparency level to set for the background + * + * ``` * image. - * @return The [MeepMeep] instance for method chaining. + * @return + * ``` + * + * The [MeepMeep] instance for method chaining. + * * @see [MeepMeep.setBackground] */ fun setBackgroundAlpha(alpha: Float): MeepMeep { @@ -852,7 +1233,7 @@ class MeepMeep @JvmOverloads constructor( enum class CompassImage { /** * Simple Compass type, automatically selects black or white text based on - * color scheme ([ColorManager.isDarkMode]). + * color scheme ( [ColorManager.isDarkMode]). */ SIMPLE, @@ -864,7 +1245,7 @@ class MeepMeep @JvmOverloads constructor( /** * Compass Rose type, automatically selects white or black text based on - * color scheme ([ColorManager.isDarkMode]). + * color scheme ( [ColorManager.isDarkMode]). */ COMPASS_ROSE, diff --git a/src/main/kotlin/org/rowlandhall/meepmeep/core/entity/BotEntity.kt b/src/main/kotlin/org/rowlandhall/meepmeep/core/entity/BotEntity.kt index 1dd47e1..3141140 100644 --- a/src/main/kotlin/org/rowlandhall/meepmeep/core/entity/BotEntity.kt +++ b/src/main/kotlin/org/rowlandhall/meepmeep/core/entity/BotEntity.kt @@ -46,7 +46,6 @@ open class BotEntity( override val meepMeep: MeepMeep, private var width: Double, private var height: Double, - var pose: Pose2d, private var colorScheme: ColorScheme, private val opacity: Double diff --git a/src/main/kotlin/org/rowlandhall/meepmeep/roadrunner/entity/TrajectorySequenceEntity.kt b/src/main/kotlin/org/rowlandhall/meepmeep/roadrunner/entity/TrajectorySequenceEntity.kt index cacfbc2..71922e6 100644 --- a/src/main/kotlin/org/rowlandhall/meepmeep/roadrunner/entity/TrajectorySequenceEntity.kt +++ b/src/main/kotlin/org/rowlandhall/meepmeep/roadrunner/entity/TrajectorySequenceEntity.kt @@ -1,15 +1,6 @@ package org.rowlandhall.meepmeep.roadrunner.entity import com.acmerobotics.roadrunner.geometry.Pose2d -import org.rowlandhall.meepmeep.MeepMeep -import org.rowlandhall.meepmeep.core.colorscheme.ColorScheme -import org.rowlandhall.meepmeep.core.entity.ThemedEntity -import org.rowlandhall.meepmeep.core.toScreenCoord -import org.rowlandhall.meepmeep.core.util.FieldUtil -import org.rowlandhall.meepmeep.roadrunner.trajectorysequence.TrajectorySequence -import org.rowlandhall.meepmeep.roadrunner.trajectorysequence.sequencesegment.TrajectorySegment -import org.rowlandhall.meepmeep.roadrunner.trajectorysequence.sequencesegment.TurnSegment -import org.rowlandhall.meepmeep.roadrunner.trajectorysequence.sequencesegment.WaitSegment import java.awt.BasicStroke import java.awt.Color import java.awt.Graphics2D @@ -19,12 +10,21 @@ import java.awt.Transparency import java.awt.geom.Path2D import java.awt.image.BufferedImage import kotlin.math.roundToInt +import org.rowlandhall.meepmeep.MeepMeep +import org.rowlandhall.meepmeep.core.colorscheme.ColorScheme +import org.rowlandhall.meepmeep.core.entity.ThemedEntity +import org.rowlandhall.meepmeep.core.toScreenCoord +import org.rowlandhall.meepmeep.core.util.FieldUtil +import org.rowlandhall.meepmeep.roadrunner.trajectorysequence.TrajectorySequence +import org.rowlandhall.meepmeep.roadrunner.trajectorysequence.sequencesegment.TrajectorySegment +import org.rowlandhall.meepmeep.roadrunner.trajectorysequence.sequencesegment.TurnSegment +import org.rowlandhall.meepmeep.roadrunner.trajectorysequence.sequencesegment.WaitSegment class TrajectorySequenceEntity( - override val meepMeep: MeepMeep, - private val trajectorySequence: TrajectorySequence, - private var colorScheme: ColorScheme, -): ThemedEntity { + override val meepMeep: MeepMeep, + val trajectorySequence: TrajectorySequence, + private var colorScheme: ColorScheme, +) : ThemedEntity { /** Tag for the trajectory sequence entity. */ override val tag = "TRAJECTORY_SEQUENCE_ENTITY" @@ -84,15 +84,11 @@ class TrajectorySequenceEntity( /** Redraws the entire trajectory path. */ private fun redrawPath() { // Clear previous turn indicator entities - turnEntityList.forEach { - meepMeep.requestToRemoveEntity(it) - } + turnEntityList.forEach { meepMeep.requestToRemoveEntity(it) } turnEntityList.clear() // Clear previous marker indicator entities - markerEntityList.forEach { - meepMeep.requestToRemoveEntity(it) - } + markerEntityList.forEach { meepMeep.requestToRemoveEntity(it) } markerEntityList.clear() // Get the default screen device and configuration @@ -103,9 +99,9 @@ class TrajectorySequenceEntity( // Create a compatible image for the trajectory sequence baseBufferedImage = config.createCompatibleImage( - canvasWidth.toInt(), - canvasHeight.toInt(), - Transparency.TRANSLUCENT, + canvasWidth.toInt(), + canvasHeight.toInt(), + Transparency.TRANSLUCENT, ) val gfx = baseBufferedImage.createGraphics() @@ -119,9 +115,9 @@ class TrajectorySequenceEntity( // Create strokes for the inner path val innerStroke = BasicStroke( - FieldUtil.scaleInchesToPixel(PATH_INNER_STROKE_WIDTH).toFloat(), - BasicStroke.CAP_BUTT, - BasicStroke.JOIN_ROUND, + FieldUtil.scaleInchesToPixel(PATH_INNER_STROKE_WIDTH).toFloat(), + BasicStroke.CAP_BUTT, + BasicStroke.JOIN_ROUND, ) var currentEndPose = trajectorySequence.start() @@ -135,7 +131,8 @@ class TrajectorySequenceEntity( // Get the trajectory from the segment val trajectory = step.trajectory - // Calculate the number of samples based on the trajectory length and sample resolution + // Calculate the number of samples based on the trajectory length and sample + // resolution val displacementSamples = (trajectory.path.length() / SAMPLE_RESOLUTION).roundToInt() @@ -157,23 +154,21 @@ class TrajectorySequenceEntity( // Update the current end pose to the end of the trajectory currentEndPose = step.trajectory.end() } - is TurnSegment -> { // Create a turn indicator entity for the turn segment val turnEntity = TurnIndicatorEntity( - meepMeep, - colorScheme, - currentEndPose.vec(), - currentEndPose.heading, - currentEndPose.heading + step.totalRotation, + meepMeep, + colorScheme, + currentEndPose.vec(), + currentEndPose.heading, + currentEndPose.heading + step.totalRotation, ) // Add the turn entity to the list and request to add it to MeepMeep turnEntityList.add(turnEntity) meepMeep.requestToAddEntity(turnEntity) } - is WaitSegment -> { // No action needed for WaitSegment } @@ -192,18 +187,21 @@ class TrajectorySequenceEntity( val pose = when (segment) { is WaitSegment -> segment.startPose - is TurnSegment -> segment.startPose.copy(heading = segment.motionProfile[marker.time].x) + is TurnSegment -> + segment.startPose.copy( + heading = segment.motionProfile[marker.time].x + ) else -> Pose2d() } // Create a new marker entity with the determined pose and add it to the list val markerEntity = MarkerIndicatorEntity( - meepMeep, - colorScheme, - pose, - marker.callback, - currentTime + marker.time, + meepMeep, + colorScheme, + pose, + marker.callback, + currentTime + marker.time, ) markerEntityList.add(markerEntity) meepMeep.requestToAddEntity(markerEntity) @@ -217,11 +215,11 @@ class TrajectorySequenceEntity( // Create a new marker entity with the determined pose and add it to the list val markerEntity = MarkerIndicatorEntity( - meepMeep, - colorScheme, - pose, - marker.callback, - currentTime + marker.time, + meepMeep, + colorScheme, + pose, + marker.callback, + currentTime + marker.time, ) markerEntityList.add(markerEntity) meepMeep.requestToAddEntity(markerEntity) @@ -236,10 +234,10 @@ class TrajectorySequenceEntity( gfx.color = colorScheme.trajectoryPathColor gfx.color = Color( - colorScheme.trajectoryPathColor.red, - colorScheme.trajectoryPathColor.green, - colorScheme.trajectoryPathColor.blue, - (PATH_UNFOCUSED_OPACITY * 255).toInt(), + colorScheme.trajectoryPathColor.red, + colorScheme.trajectoryPathColor.green, + colorScheme.trajectoryPathColor.blue, + (PATH_UNFOCUSED_OPACITY * 255).toInt(), ) gfx.draw(trajectoryDrawnPath) } @@ -260,9 +258,9 @@ class TrajectorySequenceEntity( // Create a compatible image for the current segment currentSegmentImage = config.createCompatibleImage( - canvasWidth.toInt(), - canvasHeight.toInt(), - Transparency.TRANSLUCENT, + canvasWidth.toInt(), + canvasHeight.toInt(), + Transparency.TRANSLUCENT, ) val gfx = currentSegmentImage!!.createGraphics() @@ -276,15 +274,15 @@ class TrajectorySequenceEntity( // Create stroke for the outer and inner paths val outerStroke = BasicStroke( - FieldUtil.scaleInchesToPixel(PATH_OUTER_STROKE_WIDTH).toFloat(), - BasicStroke.CAP_BUTT, - BasicStroke.JOIN_ROUND, + FieldUtil.scaleInchesToPixel(PATH_OUTER_STROKE_WIDTH).toFloat(), + BasicStroke.CAP_BUTT, + BasicStroke.JOIN_ROUND, ) val innerStroke = BasicStroke( - FieldUtil.scaleInchesToPixel(PATH_INNER_STROKE_WIDTH).toFloat(), - BasicStroke.CAP_BUTT, - BasicStroke.JOIN_ROUND, + FieldUtil.scaleInchesToPixel(PATH_INNER_STROKE_WIDTH).toFloat(), + BasicStroke.CAP_BUTT, + BasicStroke.JOIN_ROUND, ) // Get the trajectory from the current segment @@ -316,10 +314,10 @@ class TrajectorySequenceEntity( gfx.stroke = outerStroke gfx.color = Color( - colorScheme.trajectoryPathColor.red, - colorScheme.trajectoryPathColor.green, - colorScheme.trajectoryPathColor.blue, - (PATH_OUTER_OPACITY * 255).toInt(), + colorScheme.trajectoryPathColor.red, + colorScheme.trajectoryPathColor.green, + colorScheme.trajectoryPathColor.blue, + (PATH_OUTER_OPACITY * 255).toInt(), ) gfx.draw(trajectoryDrawnPath) @@ -330,8 +328,7 @@ class TrajectorySequenceEntity( } /** - * Updates the current segment of the trajectory sequence based on the - * progress. + * Updates the current segment of the trajectory sequence based on the progress. * * @param deltaTime The time elapsed since the last update. */ @@ -345,7 +342,8 @@ class TrajectorySequenceEntity( val seg = trajectorySequence.get(i) if (currentTime + seg.duration > trajectoryProgress!!) { - // If the current time plus the segment duration exceeds the trajectory progress, + // If the current time plus the segment duration exceeds the trajectory + // progress, // set the current segment to this segment if (seg is TrajectorySegment) currentSegment = seg @@ -374,9 +372,9 @@ class TrajectorySequenceEntity( * @param canvasHeight The height of the canvas. */ override fun render( - gfx: Graphics2D, - canvasWidth: Int, - canvasHeight: Int, + gfx: Graphics2D, + canvasWidth: Int, + canvasHeight: Int, ) { // Draw the base buffered image gfx.drawImage(baseBufferedImage, null, 0, 0) @@ -386,15 +384,14 @@ class TrajectorySequenceEntity( } /** - * Sets the dimensions of the canvas and redraws the path if the dimensions - * have changed. + * Sets the dimensions of the canvas and redraws the path if the dimensions have changed. * * @param canvasWidth The new width of the canvas. * @param canvasHeight The new height of the canvas. */ override fun setCanvasDimensions( - canvasWidth: Double, - canvasHeight: Double, + canvasWidth: Double, + canvasHeight: Double, ) { // Check if the canvas dimensions have changed if (this.canvasWidth != canvasWidth || this.canvasHeight != canvasHeight) { diff --git a/src/main/kotlin/org/rowlandhall/meepmeep/roadrunner/trajectorysequence/TrajectorySequenceBuilder.java b/src/main/kotlin/org/rowlandhall/meepmeep/roadrunner/trajectorysequence/TrajectorySequenceBuilder.java index 446e01b..aa5b35e 100644 --- a/src/main/kotlin/org/rowlandhall/meepmeep/roadrunner/trajectorysequence/TrajectorySequenceBuilder.java +++ b/src/main/kotlin/org/rowlandhall/meepmeep/roadrunner/trajectorysequence/TrajectorySequenceBuilder.java @@ -18,6 +18,7 @@ import com.acmerobotics.roadrunner.trajectory.constraints.TrajectoryAccelerationConstraint; import com.acmerobotics.roadrunner.trajectory.constraints.TrajectoryVelocityConstraint; import com.acmerobotics.roadrunner.util.Angle; + import org.rowlandhall.meepmeep.roadrunner.trajectorysequence.sequencesegment.SequenceSegment; import org.rowlandhall.meepmeep.roadrunner.trajectorysequence.sequencesegment.TrajectorySegment; import org.rowlandhall.meepmeep.roadrunner.trajectorysequence.sequencesegment.TurnSegment;