nozzles;
+
+ // Track current element context
+ private String currentSection;
+
+ /**
+ * Creates a params handler with a new default LoadableParams instance.
+ */
+ public ParamsHandler() {
+ this(new LoadableParams());
+ }
+
+ /**
+ * Creates a params handler that populates the specified LoadableParams
+ * instance.
+ *
+ * @param params the parameter object to populate during parsing
+ */
+ public ParamsHandler(LoadableParams params) {
+ this.params = params;
+ this.currentValue = new StringBuilder();
+ this.nozzles = new ArrayList<>();
+ }
+
+ /**
+ * Returns the populated parameters after parsing completes.
+ *
+ * @return the loaded simulation parameters
+ */
+ public LoadableParams getParams() {
+ return params;
+ }
+
+ @Override
+ public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException {
+ currentValue.setLength(0);
+
+ switch (qName) {
+ case "simulation":
+ currentSection = "user";
+ break;
+
+ case "grid":
+ currentSection = "grid";
+ break;
+
+ case "arrow":
+ currentSection = "arrow";
+
+ // Parse arrow attributes
+ params.arrowDensity = Float.parseFloat(attributes.getValue("density"));
+ params.maxArrowLength = Float.parseFloat(attributes.getValue("maxLength"));
+ params.arrowHeadLength = Float.parseFloat(attributes.getValue("headLength"));
+ params.arrowHeadAngle = Float.parseFloat(attributes.getValue("headAngle"));
+ params.velocityScale = Float.parseFloat(attributes.getValue("velocityScale"));
+ params.arrowColor = new Color(
+ Integer.parseInt(attributes.getValue("colorR")),
+ Integer.parseInt(attributes.getValue("colorG")),
+ Integer.parseInt(attributes.getValue("colorB")),
+ Integer.parseInt(attributes.getValue("colorA")));
+
+ break;
+
+ case "nozzles":
+ currentSection = "nozzles";
+ break;
+
+ case "nozzle":
+ nozzles.add(new Nozzle(
+ Nozzle.Side.valueOf(attributes.getValue("side")),
+ Float.parseFloat(attributes.getValue("position")),
+ Float.parseFloat(attributes.getValue("radius")),
+ Float.parseFloat(attributes.getValue("length")),
+ Float.parseFloat(attributes.getValue("directionDegrees")),
+ Float.parseFloat(attributes.getValue("forceMagnitude")),
+ Float.parseFloat(attributes.getValue("density"))));
+ break;
+ }
+ }
+
+ @Override
+ public void endElement(String uri, String localName, String qName) throws SAXException {
+ String value = currentValue.toString().trim();
+
+ // Parse based on current section
+ if ("user".equals(currentSection)) {
+ parseUserInteractable(qName, value);
+ } else if ("grid".equals(currentSection)) {
+ parseGrid(qName, value);
+ } else if ("nozzles".equals(currentSection)) {
+ if ("nozzles".equals(qName)) {
+ params.nozzles = nozzles;
+ }
+ }
+ }
+
+ private void parseUserInteractable(String qName, String value) {
+ switch (qName) {
+ case "iterations":
+ params.iterations = Integer.parseInt(value);
+ break;
+ case "viscosity":
+ params.viscosity = Float.parseFloat(value);
+ break;
+ case "diffusion":
+ params.diffusion = Float.parseFloat(value);
+ break;
+ case "vorticity":
+ params.vorticity = Float.parseFloat(value);
+ break;
+ case "dissipation":
+ params.dissipation = Float.parseFloat(value);
+ break;
+ case "interactionRadius":
+ params.interactionRadius = Float.parseFloat(value);
+ break;
+ }
+ }
+
+ private void parseGrid(String qName, String value) {
+ switch (qName) {
+ case "width":
+ params.width = Integer.parseInt(value);
+ break;
+ case "height":
+ params.height = Integer.parseInt(value);
+ break;
+ }
+ }
+
+ @Override
+ public void characters(char[] ch, int start, int length) throws SAXException {
+ currentValue.append(ch, start, length);
+ }
+}
diff --git a/src/main/java/dev/fluidsim/render/ArrowRenderer.java b/src/main/java/dev/fluidsim/render/ArrowRenderer.java
new file mode 100644
index 0000000..9d4b865
--- /dev/null
+++ b/src/main/java/dev/fluidsim/render/ArrowRenderer.java
@@ -0,0 +1,171 @@
+package dev.fluidsim.render;
+
+import dev.fluidsim.engine.ShaderProgram;
+import dev.fluidsim.engine.Texture;
+import org.joml.Matrix4f;
+
+import java.nio.FloatBuffer;
+
+import static org.lwjgl.opengl.GL33.*;
+import static org.lwjgl.system.MemoryUtil.*;
+
+/**
+ * Renders velocity field arrows using GPU instancing.
+ * Samples velocity textures at grid positions and draws directional arrows.
+ */
+public class ArrowRenderer {
+ private static final float ARROW_DENSITY = 0.05f;
+ private static final float MAX_ARROW_LENGTH = 15.0f;
+ private static final float ARROWHEAD_LENGTH = 4.0f;
+ private static final float ARROWHEAD_ANGLE = 25.0f; // degrees
+ private static final float VELOCITY_SCALE = 0.5f;
+
+ private ShaderProgram arrowShader;
+ private int VAO;
+ private int templateVBO; // Arrow template vertices
+ private int instanceVBO; // Instance positions
+
+ private int instanceCount;
+
+ /**
+ * Initializes the arrow renderer with shader program, VAO, and VBOs for
+ * template and instance data.
+ */
+ public ArrowRenderer() {
+ arrowShader = ShaderProgram.load("/shaders/arrow.vert", "/shaders/arrow.frag");
+
+ VAO = glGenVertexArrays();
+ glBindVertexArray(VAO);
+
+ // Create arrow template (3 lines: main line + 2 arrowhead lines)
+ // Positions relative to origin, will be scaled and rotated per instance
+
+ var rad = Math.toRadians(ARROWHEAD_ANGLE);
+
+ float[] arrowTemplate = {
+ // Main line (from origin to 1.0)
+ 0.0f, 0.0f,
+ 1.0f, 0.0f,
+
+ // Left arrowhead line
+ 1.0f, 0.0f,
+ 1.0f - ARROWHEAD_LENGTH / MAX_ARROW_LENGTH * (float) Math.cos(rad),
+ ARROWHEAD_LENGTH / MAX_ARROW_LENGTH * (float) Math.sin(rad),
+
+ // Right arrowhead line
+ 1.0f, 0.0f,
+ 1.0f - ARROWHEAD_LENGTH / MAX_ARROW_LENGTH * (float) Math.cos(rad),
+ -ARROWHEAD_LENGTH / MAX_ARROW_LENGTH * (float) Math.sin(rad)
+ };
+
+ // Upload arrow template
+ templateVBO = glGenBuffers();
+ glBindBuffer(GL_ARRAY_BUFFER, templateVBO);
+ glBufferData(GL_ARRAY_BUFFER, arrowTemplate, GL_STATIC_DRAW);
+ glVertexAttribPointer(0, 2, GL_FLOAT, false, 2 * Float.BYTES, 0);
+ glEnableVertexAttribArray(0);
+
+ // Create instance VBO (will be filled per frame)
+ instanceVBO = glGenBuffers();
+ glBindBuffer(GL_ARRAY_BUFFER, instanceVBO);
+ glVertexAttribPointer(1, 2, GL_FLOAT, false, 2 * Float.BYTES, 0);
+ glEnableVertexAttribArray(1);
+ glVertexAttribDivisor(1, 1); // This attribute advances once per instance
+
+ glBindBuffer(GL_ARRAY_BUFFER, 0);
+ glBindVertexArray(0);
+ }
+
+ /**
+ * Renders velocity field arrows by sampling velocity textures at grid
+ * positions.
+ *
+ * @param velocityXTexture the x-component velocity field texture
+ * @param velocityYTexture the y-component velocity field texture
+ * @param width screen width in pixels
+ * @param height screen height in pixels
+ */
+ public void renderArrows(Texture velocityXTexture, Texture velocityYTexture, int width, int height) {
+ int gridWidth = velocityXTexture.getWidth();
+ int gridHeight = velocityXTexture.getHeight();
+
+ int arrowGridWidth = (int) (gridWidth * ARROW_DENSITY);
+ int arrowGridHeight = (int) (gridHeight * ARROW_DENSITY);
+
+ // Generate instance positions
+ instanceCount = arrowGridWidth * arrowGridHeight;
+ FloatBuffer instanceData = memAllocFloat(instanceCount * 2);
+
+ int stepX = gridWidth / arrowGridWidth;
+ int stepY = gridHeight / arrowGridHeight;
+
+ for (int j = 0; j < arrowGridHeight; j++) {
+ for (int i = 0; i < arrowGridWidth; i++) {
+ float gridX = i * stepX + stepX / 2.0f;
+ float gridY = j * stepY + stepY / 2.0f;
+
+ // Pass grid coordinates (will convert to screen space in shader)
+ instanceData.put(gridX);
+ instanceData.put(gridY);
+ }
+ }
+ instanceData.flip();
+
+ // Upload instance data
+ glBindBuffer(GL_ARRAY_BUFFER, instanceVBO);
+ glBufferData(GL_ARRAY_BUFFER, instanceData, GL_DYNAMIC_DRAW);
+ glBindBuffer(GL_ARRAY_BUFFER, 0);
+
+ memFree(instanceData);
+
+ // Set up rendering state
+ glEnable(GL_BLEND);
+ glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
+ glLineWidth(2.0f);
+
+ // Create orthographic projection matrix
+ Matrix4f projection = new Matrix4f().ortho(0, width, height, 0, -1, 1);
+ float[] projectionData = new float[16];
+ projection.get(projectionData);
+
+ // Render arrows with instancing
+ arrowShader.use();
+
+ velocityXTexture.bind(0);
+ velocityYTexture.bind(1);
+
+ arrowShader.setUniform("uProjection",
+ projectionData[0], projectionData[1], projectionData[2], projectionData[3],
+ projectionData[4], projectionData[5], projectionData[6], projectionData[7],
+ projectionData[8], projectionData[9], projectionData[10], projectionData[11],
+ projectionData[12], projectionData[13], projectionData[14], projectionData[15]);
+
+ arrowShader.setUniform("uVelocityX", 0);
+ arrowShader.setUniform("uVelocityY", 1);
+ arrowShader.setUniform("uTexelSize", 1.0f / gridWidth, 1.0f / gridHeight);
+ arrowShader.setUniform("uGridSize", (float) gridWidth, (float) gridHeight);
+ arrowShader.setUniform("uScreenSize", (float) width, (float) height);
+ arrowShader.setUniform("uMaxArrowLength", MAX_ARROW_LENGTH);
+ arrowShader.setUniform("uVelocityScale", VELOCITY_SCALE);
+ arrowShader.setUniform("uColor", 1.0f, 1.0f, 1.0f);
+
+ glBindVertexArray(VAO);
+ glDrawArraysInstanced(GL_LINES, 0, 6, instanceCount); // 6 vertices per arrow, multiple instances
+ glBindVertexArray(0);
+
+ arrowShader.unuse();
+
+ glDisable(GL_BLEND);
+ glLineWidth(1.0f);
+ }
+
+ /**
+ * Releases OpenGL resources including VAO, VBOs, and shader program.
+ */
+ public void cleanup() {
+ glDeleteVertexArrays(VAO);
+ glDeleteBuffers(templateVBO);
+ glDeleteBuffers(instanceVBO);
+ arrowShader.delete();
+ }
+}
diff --git a/src/main/java/dev/fluidsim/render/Renderer.java b/src/main/java/dev/fluidsim/render/Renderer.java
new file mode 100644
index 0000000..1c4227b
--- /dev/null
+++ b/src/main/java/dev/fluidsim/render/Renderer.java
@@ -0,0 +1,78 @@
+package dev.fluidsim.render;
+
+import dev.fluidsim.engine.QuadMesh;
+import dev.fluidsim.engine.ShaderProgram;
+import dev.fluidsim.simulation.Constants;
+import dev.fluidsim.simulation.Sim;
+
+import static org.lwjgl.opengl.GL33.*;
+
+/**
+ * Main renderer for fluid simulation visualization.
+ * Composites density/velocity fields and optional arrow overlay.
+ */
+public class Renderer {
+ private ShaderProgram displayShader;
+ private QuadMesh quad;
+ private ArrowRenderer arrowRenderer;
+
+ /**
+ * Creates a renderer with display shader, fullscreen quad, and arrow renderer.
+ */
+ public Renderer() {
+ displayShader = ShaderProgram.load("/shaders/quad.vert", "/shaders/display.frag");
+ quad = new QuadMesh();
+ arrowRenderer = new ArrowRenderer();
+ }
+
+ /**
+ * Renders the simulation state to the screen framebuffer.
+ *
+ * @param simulation the simulation to render
+ * @param width viewport width in pixels
+ * @param height viewport height in pixels
+ * @param showVelocity if true, visualizes velocity field instead of density
+ * @param showArrows if true, renders velocity arrows on top
+ */
+ public void renderToScreen(Sim simulation, int width, int height, boolean showVelocity, boolean showArrows) {
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
+ glViewport(0, 0, width, height);
+ glClear(GL_COLOR_BUFFER_BIT);
+
+ displayShader.use();
+
+ var velocityXTexture = simulation.getVelocityXTexture();
+ var velocityYTexture = simulation.getVelocityYTexture();
+
+ simulation.getDensityTexture().bind(0);
+ velocityXTexture.bind(1);
+ velocityYTexture.bind(2);
+ simulation.getObjectTexture().bind(3);
+
+ displayShader.setUniform("uDensity", 0);
+ displayShader.setUniform("uVelocityX", 1);
+ displayShader.setUniform("uVelocityY", 2);
+ displayShader.setUniform("uObstacle", 3);
+ displayShader.setUniform("uMinDensity", Constants.MIN_DENSITY);
+ displayShader.setUniform("uMaxDensity", Constants.MAX_DENSITY);
+ displayShader.setUniform("uShowVelocity", showVelocity ? 1 : 0);
+
+ quad.draw();
+
+ displayShader.unuse();
+
+ if (showArrows) {
+ arrowRenderer.renderArrows(velocityXTexture, velocityYTexture, width, height);
+ }
+ }
+
+ /**
+ * Releases all rendering resources including shaders, quad mesh, and arrow
+ * renderer.
+ */
+ public void cleanup() {
+ displayShader.delete();
+ quad.delete();
+ arrowRenderer.cleanup();
+ }
+}
diff --git a/src/main/java/dev/fluidsim/simulation/Cell.java b/src/main/java/dev/fluidsim/simulation/Cell.java
deleted file mode 100644
index 2e96daf..0000000
--- a/src/main/java/dev/fluidsim/simulation/Cell.java
+++ /dev/null
@@ -1,8 +0,0 @@
-package dev.fluidsim.simulation;
-
-public class Cell {
- private double flowUp = 0;
- private double flowDown = 0;
- private double flowLeft = 0;
- private double flowRight = 0;
-}
diff --git a/src/main/java/dev/fluidsim/simulation/Constants.java b/src/main/java/dev/fluidsim/simulation/Constants.java
new file mode 100644
index 0000000..92504cf
--- /dev/null
+++ b/src/main/java/dev/fluidsim/simulation/Constants.java
@@ -0,0 +1,51 @@
+package dev.fluidsim.simulation;
+
+import java.awt.Color;
+
+/**
+ * Default constant values for fluid simulation parameters.
+ * Defines grid dimensions, simulation physics constants, and arrow rendering
+ * properties.
+ */
+public interface Constants {
+ /** minimum density value for clamping */
+ static final float MIN_DENSITY = 0.0f;
+ /** maximum density value for clamping */
+ static final float MAX_DENSITY = 1.0f;
+
+ /** default grid width in pixels */
+ public static final int WIDTH = 1000;
+ /** default grid height in pixels */
+ public static final int HEIGHT = 1000;
+
+ /** default number of solver iterations per step */
+ public static final int ITERATIONS = 20;
+ /** default fluid viscosity coefficient */
+ public static final float VISCOSITY = 0.004f;
+ /** default density diffusion rate */
+ public static final float DIFFUSION = 0.002f;
+ /** default vorticity confinement strength */
+ public static final float VORTICITY = 25.0f;
+ /** default velocity dissipation factor (0.9998 = very slow decay) */
+ public static final float DISSIPATION = 0.9998f;
+ /** default mouse interaction radius in pixels */
+ public static final float INTERACTION_RADIUS = 200.0f;
+ /** default velocity field visualization toggle */
+ public static final boolean VELOCITY_VIEW = false;
+ /** default arrow visualization toggle */
+ public static final boolean SHOW_ARROWS = false;
+
+ /** default spacing between arrows (as fraction of grid size) */
+ public static final float ARROW_DENSITY = 0.1f;
+ /** default maximum arrow length in pixels */
+ public static final float MAX_ARROW_LENGTH = 30.0f;
+ /** default arrowhead length in pixels */
+ public static final float ARROWHEAD_LENGTH = 5.0f;
+ /** default arrowhead angle in radians */
+ public static final float ARROWHEAD_ANGLE = 0.5f;
+ /** default velocity to arrow length scale factor */
+ public static final float VELOCITY_SCALE = 1.0f;
+
+ /** default arrow color (white, fully opaque) */
+ public static final Color ARROW_COLOR = new Color(255, 255, 255, 255);
+}
diff --git a/src/main/java/dev/fluidsim/simulation/GridUtil.java b/src/main/java/dev/fluidsim/simulation/GridUtil.java
deleted file mode 100644
index ab17c32..0000000
--- a/src/main/java/dev/fluidsim/simulation/GridUtil.java
+++ /dev/null
@@ -1,100 +0,0 @@
-package dev.fluidsim.simulation;
-
-import java.util.Locale;
-import java.util.Random;
-
-public class GridUtil {
- /**
- * Formats a 1D float array into a 2D grid string with borders.
- *
- * @param data The 1D array containing the 2D grid data (in row-major order).
- * @param width The number of columns in the grid.
- * @return A string representing the formatted grid.
- */
- public static String print(float[] data, int cols, int rows) {
- StringBuilder sb = new StringBuilder();
-
- // --- 2. First Pass: Find max width of formatted numbers ---
- int maxFormattedWidth = 0;
- for (float val : data) {
- String formattedVal = String.format("%.2f", val);
- maxFormattedWidth = Math.max(maxFormattedWidth, formattedVal.length());
- }
-
- // --- 3. Define Cell Format and Separator Line ---
-
- var cellFormat = " %" + maxFormattedWidth + ".2f ";
- var cellWidth = cellFormat.formatted(0.0f).length();
-
- String separatorLine = createSeparator(cols, cellWidth);
-
- for (int r = 0; r < rows; r++) {
- sb.append(separatorLine);
-
- for (int c = 0; c < cols; c++) {
- sb.append("|");
- sb.append(String.format(cellFormat, data[c + (cols + 2) * r]));
- }
-
- sb.append("|\n");
- }
-
- sb.append(separatorLine);
- return sb.toString();
- }
-
- /**
- * Formats a 1D float array into a 2D grid string with borders.
- *
- * @param data The 1D array containing the 2D grid data (in row-major order).
- * @param width The number of columns in the grid.
- * @return A string representing the formatted grid.
- */
- public static String toCSV(float[] data, int cols, int rows) {
- StringBuilder sb = new StringBuilder();
- Locale.setDefault(Locale.GERMANY);
-
- for (int r = 0; r < rows; r++) {
- for (int c = 0; c < cols; c++) {
- sb.append(String.format("%.5f", data[c + (cols + 2) * r]));
- if (c != cols - 1)
- sb.append(";");
- }
-
- sb.append("\n");
- }
-
- return sb.toString();
- }
-
- /**
- * Helper function to create a row separator string like "+----+----+"
- */
- private static String createSeparator(int cols, int cellWidth) {
- StringBuilder separator = new StringBuilder();
- for (int c = 0; c < cols; c++) {
- separator.append("+");
- for (int i = 0; i < cellWidth; i++)
- separator.append("-");
- }
-
- separator.append("+\n");
- return separator.toString();
- }
-
- private static Random rand = new Random();
-
- public static void addRandomValues(float array[], float min, float max) {
- for (int i = 1; i < array.length - 1; i++) {
- array[i] = rand.nextFloat(min, max);
- }
- }
-
- public static void addValueTo(float array[], int rows, int cols, int i, int j, int width, int height, float value) {
- for (int y = j; y < j + height; y++) {
- for (int x = i; x < i + width; x++) {
- array[x + (cols + 2) * y] = value;
- }
- }
- }
-}
\ No newline at end of file
diff --git a/src/main/java/dev/fluidsim/simulation/Nozzle.java b/src/main/java/dev/fluidsim/simulation/Nozzle.java
new file mode 100644
index 0000000..942cbbb
--- /dev/null
+++ b/src/main/java/dev/fluidsim/simulation/Nozzle.java
@@ -0,0 +1,237 @@
+package dev.fluidsim.simulation;
+
+/**
+ * A nozzle that continuously emits fluid at a specific location with a given
+ * direction and force.
+ * Can be placed on the sides of the simulation area.
+ *
+ * Example usage:
+ *
+ *
+ * // Create a nozzle on the left side, at center position, pointing right
+ * Nozzle leftNozzle = new Nozzle(
+ * Nozzle.Side.LEFT, // Place on left side
+ * 0.5f, // At center (50% of the way up)
+ * 30.0f, // 30 pixel radius
+ * 200.0f, // 200 pixel beam length
+ * 0.0f, // 0 degrees (pointing right)
+ * 500.0f, // Force magnitude
+ * 0.5f // Density amount
+ * );
+ * simulation.addNozzle(leftNozzle);
+ *
+ * // Create a nozzle on the bottom, pointing diagonally up-right
+ * Nozzle bottomNozzle = new Nozzle(
+ * Nozzle.Side.BOTTOM, // Place on bottom side
+ * 0.3f, // 30% from left edge
+ * 40.0f, // 40 pixel radius
+ * 150.0f, // 150 pixel beam length
+ * 45.0f, // 45 degrees (diagonal up-right)
+ * 300.0f, // Force magnitude
+ * 0.8f // Density amount
+ * );
+ * simulation.addNozzle(bottomNozzle);
+ *
+ */
+public class Nozzle implements SimObject {
+ /**
+ * The side of the simulation area where a nozzle can be placed.
+ */
+ public enum Side {
+ /** left edge of simulation */
+ LEFT,
+ /** right edge of simulation */
+ RIGHT,
+ /** top edge of simulation */
+ TOP,
+ /** bottom edge of simulation */
+ BOTTOM
+ }
+
+ private final Side side;
+ private final float position; // Position along the side (0.0 to 1.0)
+ private final float radius; // Base area radius (similar to interaction radius)
+ private final float length; // Length of the beam in the direction of emission
+ private final float forceX; // X component of force direction
+ private final float forceY; // Y component of force direction
+ private final float forceMagnitude;// Magnitude of the force
+ private final float density; // Density amount to emit
+
+ private float worldX; // Actual X position in grid coordinates
+ private float worldY; // Actual Y position in grid coordinates
+ private float directionRadians; // Direction in radians for beam calculation
+
+ /**
+ * Create a nozzle at a specific side of the simulation area
+ *
+ * @param side Which side to place the nozzle (LEFT, RIGHT, TOP,
+ * BOTTOM)
+ * @param position Position along the side (0.0 to 1.0, where 0.5 is
+ * center)
+ * @param radius Base area radius for emission
+ * @param length Length of the beam in pixels
+ * @param directionDegrees Direction angle in degrees (0 = right, 90 = up, 180 =
+ * left, 270 = down)
+ * @param forceMagnitude Magnitude of the force
+ * @param density Density amount to emit
+ */
+ public Nozzle(Side side, float position, float radius, float length, float directionDegrees,
+ float forceMagnitude, float density) {
+ this.side = side;
+ this.position = Math.max(0.0f, Math.min(1.0f, position));
+ this.radius = radius;
+ this.length = length;
+ this.forceMagnitude = forceMagnitude;
+ this.density = density;
+
+ // Convert direction angle to force components
+ this.directionRadians = (float) Math.toRadians(directionDegrees);
+ this.forceX = (float) Math.cos(directionRadians) * forceMagnitude;
+ this.forceY = (float) Math.sin(directionRadians) * forceMagnitude;
+ }
+
+ /**
+ * Initialize nozzle world position based on grid dimensions
+ */
+ @Override
+ public void initialize(int gridWidth, int gridHeight) {
+ switch (side) {
+ case LEFT:
+ worldX = 0;
+ worldY = position * gridHeight;
+ break;
+ case RIGHT:
+ worldX = gridWidth;
+ worldY = position * gridHeight;
+ break;
+ case TOP:
+ worldX = position * gridWidth;
+ worldY = gridHeight;
+ break;
+ case BOTTOM:
+ worldX = position * gridWidth;
+ worldY = 0;
+ break;
+ }
+ }
+
+ /**
+ * Apply this nozzle's force and density to the simulation along a beam
+ */
+ @Override
+ public void apply(Sim simulation) {
+ // Calculate normalized direction
+ float dirX = (float) Math.cos(directionRadians);
+ float dirY = (float) Math.sin(directionRadians);
+
+ // Calculate number of points along the beam (space them by radius distance)
+ int numPoints = Math.max(1, (int) (length / radius) + 1);
+
+ // Apply force at multiple points along the beam
+ for (int i = 0; i < numPoints; i++) {
+ float t = i / (float) Math.max(1, numPoints - 1); // 0 to 1
+ float distance = t * length;
+
+ float x = worldX + dirX * distance;
+ float y = worldY + dirY * distance;
+
+ simulation.addForceAndDensity(x, y, forceX, forceY, density, radius);
+ }
+ }
+
+ /**
+ * Returns the side where this nozzle is placed.
+ *
+ * @return the placement side
+ */
+ public Side getSide() {
+ return side;
+ }
+
+ /**
+ * Returns the position along the side.
+ *
+ * @return position from 0.0 (start) to 1.0 (end)
+ */
+ public float getPosition() {
+ return position;
+ }
+
+ /**
+ * Returns the base emission radius.
+ *
+ * @return radius in pixels
+ */
+ public float getRadius() {
+ return radius;
+ }
+
+ /**
+ * Returns the emission beam length.
+ *
+ * @return length in pixels
+ */
+ public float getLength() {
+ return length;
+ }
+
+ /**
+ * Returns the x-component of the force vector.
+ *
+ * @return force in x direction
+ */
+ public float getForceX() {
+ return forceX;
+ }
+
+ /**
+ * Returns the y-component of the force vector.
+ *
+ * @return force in y direction
+ */
+ public float getForceY() {
+ return forceY;
+ }
+
+ /**
+ * Returns the total force magnitude.
+ *
+ * @return force magnitude
+ */
+ public float getForceMagnitude() {
+ return forceMagnitude;
+ }
+
+ /**
+ * Returns the density emission amount.
+ *
+ * @return density value
+ */
+ public float getDensity() {
+ return density;
+ }
+
+ /**
+ * Returns the world x-coordinate after initialization.
+ *
+ * @return x position in grid coordinates
+ */
+ public float getWorldX() {
+ return worldX;
+ }
+
+ /**
+ * Returns the world y-coordinate after initialization.
+ *
+ * @return y position in grid coordinates
+ */
+ public float getWorldY() {
+ return worldY;
+ }
+
+ @Override
+ public String toString() {
+ return String.format("Nozzle[side=%s, pos=%.2f, force=(%.2f,%.2f), density=%.2f]",
+ side, position, forceX, forceY, density);
+ }
+}
diff --git a/src/main/java/dev/fluidsim/simulation/Sim.java b/src/main/java/dev/fluidsim/simulation/Sim.java
index 71c01e9..39de225 100644
--- a/src/main/java/dev/fluidsim/simulation/Sim.java
+++ b/src/main/java/dev/fluidsim/simulation/Sim.java
@@ -1,454 +1,779 @@
package dev.fluidsim.simulation;
-import java.util.stream.IntStream;
+import dev.fluidsim.engine.*;
-public class Sim {
- public enum Boundary {
- None,
- Vertical,
- Horizontal,
- }
-
- private static final float RAND_MIN = 1f;
- private static final float RAND_MAX = 10f;
-
- float[] mVelocityX, mVelocityY,
- mSourceVelocityX, mSourceVelocityY,
- mDensity, mSourceDensity;
-
- private final int mRows;
- private final int mCols;
- private final int mSize;
-
- private final int mIterations;
- private final float mViscosity;
- private final float mDiffusion;
- private final float mVorticity;
-
- private float mMinDensity = Float.MAX_VALUE;
- private float mMaxDensity = 0;
-
- public Sim(int rows, int cols) {
- mSize = (rows + 2) * (cols + 2);
-
- mVelocityX = new float[mSize];
- mVelocityY = new float[mSize];
- mSourceVelocityX = new float[mSize];
- mSourceVelocityY = new float[mSize];
- mDensity = new float[mSize];
- mSourceDensity = new float[mSize];
-
- mRows = rows;
- mCols = cols;
-
- mIterations = 15;
- mViscosity = 1.5f;
- mDiffusion = 0.2f;
- mVorticity = 15f;
-
- // GridUtil.addRandomValues(mSourceDensity, RAND_MIN, RAND_MAX);
- // GridUtil.addRandomValues(mSourceVelocityX, -RAND_MAX * .5f, RAND_MAX * .5f);
- // GridUtil.addRandomValues(mSourceVelocityY, -RAND_MAX * .5f, RAND_MAX * .5f);
-
- // GridUtil.addValueTo(mSourceDensity, rows, cols, cols / 4, rows / 4, cols / 2,
- // rows / 2, 10f);
-
- // for (int i = 1; i <= mRows; i += 25) {
- // for (int k = 0; k < 3; k++) {
- // for (int j = 1; j < 5; j++) {
- // mSourceDensity[at(j, i + k)] = 10f;
- // // mSourceVelocityX[at(j, i + k)] = 5f;
- // }
- // }
- // }
- }
-
- // Add a force and density to the simulation
- public void addForceAndDensity(int x, int y, float powerX, float powerY, float density) {
- // % of the cell affected
- float brushSize = 0.2f;
-
- // Add the forces and density to every cell next to the one given
- int size = (int) (20 * brushSize * 0.5f);
-
- for (int i = -size; i < size; ++i) {
- for (int j = -size; j < size; ++j) {
- int posX = x + i;
- int posY = y + j;
-
- if (posX < 0 || posX > mCols || posY < 0 || posY > mRows)
- continue;
-
- int deltaX = (i < 0) ? -i : i;
- int deltaY = (j < 0) ? -j : j;
-
- if (deltaX + deltaY < size) {
- mSourceDensity[at(posX, posY)] += density / (1 + deltaX + deltaY);
- mSourceVelocityX[at(posX, posY)] += powerX / (1 + deltaX + deltaY);
- mSourceVelocityY[at(posX, posY)] += powerY / (1 + deltaX + deltaY);
- }
- }
- }
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.lwjgl.opengl.GL11.glViewport;
+import static org.lwjgl.opengl.GL30.GL_FRAMEBUFFER;
+import static org.lwjgl.opengl.GL30.glBindFramebuffer;
+/**
+ * GPU-accelerated Navier-Stokes fluid simulation using OpenGL shaders.
+ * Implements velocity advection, diffusion, pressure projection, and vorticity
+ * confinement.
+ */
+public class Sim {
+ private final int width;
+ private final int height;
+ private final SimParams params;
+
+ // Object data
+ private final List simObjects = new ArrayList<>();
+ private Texture objectTexture;
+ private volatile String requestObjectLoadPath = null;
+ private volatile boolean requestObjectClear = false;
+
+ // Framebuffers
+ private Framebuffer velocityX_read, velocityX_write;
+ private Framebuffer velocityY_read, velocityY_write;
+ private Framebuffer density_read, density_write;
+ private Framebuffer pressure_read, pressure_write;
+ private Framebuffer divergence;
+ private Framebuffer curl;
+
+ // Source buffers
+ private Framebuffer sourceVelocityX;
+ private Framebuffer sourceVelocityY;
+ private Framebuffer sourceDensity;
+
+ // Shader programs
+ private ShaderProgram advectShader;
+ private ShaderProgram diffuseShader;
+ private ShaderProgram divergenceShader;
+ private ShaderProgram pressureShader;
+ private ShaderProgram gradientShader;
+ private ShaderProgram vorticityShader;
+ private ShaderProgram vorticityForceShader;
+ private ShaderProgram boundaryShader;
+ private ShaderProgram addSourceShader;
+ private ShaderProgram splatShader;
+ private ShaderProgram clearShader;
+
+ private QuadMesh quad;
+
+ /**
+ * Creates a new fluid simulation with specified grid dimensions and parameters.
+ *
+ * @param width grid width in cells
+ * @param height grid height in cells
+ * @param params simulation parameters controlling physics behavior
+ */
+ public Sim(int width, int height, SimParams params) {
+ this.width = width;
+ this.height = height;
+ this.params = params;
+
+ initFramebuffers();
+ initShaders();
+
+ // Initialize empty obstacle (no walls)
+ objectTexture = ObstacleLoader.empty(width, height);
+
+ quad = new QuadMesh();
+
+ // Clear all buffers
+ clearAllBuffers();
}
- // Add a force and density to the simulation
- public void clearSourceInRadius(int x, int y) {
- // Add the forces and density to every cell next to the one given
- int size = (int) (Math.sqrt(mRows * y + mCols * x) * 0.2);
+ private void initFramebuffers() {
+ velocityX_read = new Framebuffer(width, height);
+ velocityX_write = new Framebuffer(width, height);
+ velocityY_read = new Framebuffer(width, height);
+ velocityY_write = new Framebuffer(width, height);
- for (int i = clamp(0, mCols, x - size); i < clamp(0, mCols, x + size); ++i) {
- for (int j = clamp(0, mRows, y - size); j < clamp(0, mRows, y + size); ++j) {
- mSourceDensity[at(i, j)] = 0;
- mSourceVelocityX[at(i, j)] = 0;
- mSourceVelocityY[at(i, j)] = 0;
- }
- }
+ density_read = new Framebuffer(width, height);
+ density_write = new Framebuffer(width, height);
+
+ pressure_read = new Framebuffer(width, height);
+ pressure_write = new Framebuffer(width, height);
+
+ divergence = new Framebuffer(width, height);
+ curl = new Framebuffer(width, height);
+
+ sourceVelocityX = new Framebuffer(width, height);
+ sourceVelocityY = new Framebuffer(width, height);
+ sourceDensity = new Framebuffer(width, height);
}
- public void clearDensity(int x, int y) {
- // Add the forces and density to every cell next to the one given
- int size = (int) (Math.sqrt(mRows * y + mCols * x) * 0.1);
+ private void initShaders() {
+ advectShader = ShaderProgram.load("/shaders/quad.vert", "/shaders/advect.frag");
+ diffuseShader = ShaderProgram.load("/shaders/quad.vert", "/shaders/diffuse.frag");
+ divergenceShader = ShaderProgram.load("/shaders/quad.vert", "/shaders/divergence.frag");
+ pressureShader = ShaderProgram.load("/shaders/quad.vert", "/shaders/pressure.frag");
+ gradientShader = ShaderProgram.load("/shaders/quad.vert", "/shaders/gradient.frag");
+ vorticityShader = ShaderProgram.load("/shaders/quad.vert", "/shaders/vorticity.frag");
+ vorticityForceShader = ShaderProgram.load("/shaders/quad.vert", "/shaders/vorticity_force.frag");
+ boundaryShader = ShaderProgram.load("/shaders/quad.vert", "/shaders/boundary.frag");
+ addSourceShader = ShaderProgram.load("/shaders/quad.vert", "/shaders/add_source.frag");
+ splatShader = ShaderProgram.load("/shaders/quad.vert", "/shaders/splat.frag");
+ clearShader = ShaderProgram.load("/shaders/quad.vert", "/shaders/clear.frag");
+ }
- for (int i = clamp(0, mCols, x - size); i < clamp(0, mCols, x + size); ++i) {
- for (int j = clamp(0, mRows, y - size); j < clamp(0, mRows, y + size); ++j) {
- mDensity[at(i, j)] = 0;
- }
+ private void clearAllBuffers() {
+ clearShader.use();
+ clearShader.setUniform("uClearColor", 0.0f, 0.0f, 0.0f, 0.0f);
+
+ Framebuffer[] buffers = {
+ velocityX_read, velocityX_write, velocityY_read, velocityY_write,
+ density_read, density_write, pressure_read, pressure_write,
+ divergence, curl, sourceVelocityX, sourceVelocityY, sourceDensity
+ };
+
+ for (Framebuffer fb : buffers) {
+ fb.bind();
+ glViewport(0, 0, width, height);
+ quad.draw();
}
+
+ clearShader.unuse();
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
}
+ /**
+ * Advances the simulation by one time step, applying all physics stages.
+ *
+ * @param deltaTime time step in seconds (unused, simulation uses fixed time
+ * step)
+ */
public void update(float deltaTime) {
- updateVelocity(mViscosity, deltaTime);
- updateDensity(mDiffusion, deltaTime);
- vorticityConfinement(mVorticity, deltaTime);
- }
+ glViewport(0, 0, width, height);
- public int at(int i, int j) {
- return i + (mCols + 2) * j;
- }
+ // Handle object loading/clearing requests
+ processObjectRequest();
+ updateObjects();
- public float[] getDensity() {
- return mDensity;
- }
+ // Add sources
+ addSource(velocityX_read, sourceVelocityX, velocityX_write, deltaTime);
+ Framebuffer temp = velocityX_read;
+ velocityX_read = velocityX_write;
+ velocityX_write = temp;
+
+ addSource(velocityY_read, sourceVelocityY, velocityY_write, deltaTime);
+ temp = velocityY_read;
+ velocityY_read = velocityY_write;
+ velocityY_write = temp;
+
+ addSource(density_read, sourceDensity, density_write, deltaTime);
+ temp = density_read;
+ density_read = density_write;
+ density_write = temp;
+
+ // Diffuse velocity
+ diffuseVelocity(deltaTime);
+
+ // Project (make incompressible)
+ project();
+
+ // Advect velocity
+ advectVelocity(deltaTime);
+
+ // Project again
+ project();
+
+ // Apply vorticity confinement
+ vorticityConfinement(deltaTime);
- public float getDensity(int i, int j) {
- return mDensity[at(i, j)];
+ // Diffuse density
+ diffuseDensity(deltaTime);
+
+ // Advect density
+ advectDensity(deltaTime);
+
+ // Clear source buffers for next frame
+ clearSourceBuffers();
}
- public int getCellCountY() {
- return mRows;
+ private void addSource(Framebuffer target, Framebuffer source, Framebuffer output, float dt) {
+ output.bind();
+ addSourceShader.use();
+
+ target.getTexture().bind(0);
+ source.getTexture().bind(1);
+
+ addSourceShader.setUniform("uTarget", 0);
+ addSourceShader.setUniform("uSource", 1);
+ addSourceShader.setUniform("uDeltaTime", dt);
+
+ quad.draw();
+
+ addSourceShader.unuse();
+ output.unbind();
}
- public int getCellCountX() {
- return mCols;
+ private void diffuseVelocity(float dt) {
+ float alpha = dt * params.viscosity * width * height / 10000.0f;
+ float beta = 1.0f + 4.0f * alpha;
+
+ // Diffuse X component
+ for (int i = 0; i < params.iterations; i++) {
+ jacobi(velocityX_write, velocityX_read, velocityX_read, alpha, beta);
+ Framebuffer temp = velocityX_read;
+ velocityX_read = velocityX_write;
+ velocityX_write = temp;
+ }
+
+ setBounds(velocityX_read, velocityX_write, 1); // horizontal boundary
+ Framebuffer temp = velocityX_read;
+ velocityX_read = velocityX_write;
+ velocityX_write = temp;
+
+ // Diffuse Y component
+ for (int i = 0; i < params.iterations; i++) {
+ jacobi(velocityY_write, velocityY_read, velocityY_read, alpha, beta);
+ temp = velocityY_read;
+ velocityY_read = velocityY_write;
+ velocityY_write = temp;
+ }
+
+ setBounds(velocityY_read, velocityY_write, 2); // vertical boundary
+ temp = velocityY_read;
+ velocityY_read = velocityY_write;
+ velocityY_write = temp;
}
- public float getMinDensity() {
- return mMinDensity;
+ private void diffuseDensity(float dt) {
+ float alpha = dt * params.diffusion * width * height / 10000.0f;
+ float beta = 1.0f + 4.0f * alpha;
+
+ for (int i = 0; i < params.iterations; i++) {
+ jacobi(density_write, density_read, density_read, alpha, beta, 1);
+ Framebuffer temp = density_read;
+ density_read = density_write;
+ density_write = temp;
+ }
+ setBounds(density_read, density_write, 0); // no boundary flip
+ Framebuffer temp = density_read;
+ density_read = density_write;
+ density_write = temp;
}
- public float getMaxDensity() {
- return mMaxDensity;
+ private void jacobi(Framebuffer output, Framebuffer source, Framebuffer original, float alpha, float beta) {
+ jacobi(output, source, original, alpha, beta, 0);
}
- public float[] getVelocityX() {
- return mVelocityX;
+ private void jacobi(Framebuffer output, Framebuffer source, Framebuffer original, float alpha, float beta,
+ int isDensity) {
+ output.bind();
+ diffuseShader.use();
+
+ source.getTexture().bind(0);
+ original.getTexture().bind(1);
+
+ diffuseShader.setUniform("uSource", 0);
+ diffuseShader.setUniform("uOriginal", 1);
+ diffuseShader.setUniform("uTexelSize", 1.0f / width, 1.0f / height);
+ diffuseShader.setUniform("uAlpha", alpha);
+ diffuseShader.setUniform("uBeta", beta);
+ diffuseShader.setUniform("uIsDensity", isDensity);
+
+ quad.draw();
+
+ diffuseShader.unuse();
+ output.unbind();
}
- public float[] getVelocityY() {
- return mVelocityY;
+ private void project() {
+ Framebuffer temp;
+
+ // Compute divergence
+ divergence.bind();
+ divergenceShader.use();
+
+ velocityX_read.getTexture().bind(0);
+ velocityY_read.getTexture().bind(1);
+ objectTexture.bind(2);
+
+ divergenceShader.setUniform("uVelocityX", 0);
+ divergenceShader.setUniform("uVelocityY", 1);
+ divergenceShader.setUniform("uObstacle", 2);
+ divergenceShader.setUniform("uTexelSize", 1.0f / width, 1.0f / height);
+
+ quad.draw();
+
+ divergenceShader.unuse();
+ divergence.unbind();
+
+ // Clear pressure
+ clearBuffer(pressure_read);
+
+ // Solve for pressure using Jacobi iteration
+ for (int i = 0; i < params.iterations; i++) {
+ pressure_write.bind();
+ pressureShader.use();
+
+ pressure_read.getTexture().bind(0);
+ divergence.getTexture().bind(1);
+ objectTexture.bind(2);
+
+ pressureShader.setUniform("uPressure", 0);
+ pressureShader.setUniform("uDivergence", 1);
+ pressureShader.setUniform("uObstacle", 2);
+ pressureShader.setUniform("uTexelSize", 1.0f / width, 1.0f / height);
+
+ quad.draw();
+
+ pressureShader.unuse();
+ pressure_write.unbind();
+
+ temp = pressure_read;
+ pressure_read = pressure_write;
+ pressure_write = temp;
+ }
+
+ setBounds(pressure_read, pressure_write, 0);
+ temp = pressure_read;
+ pressure_read = pressure_write;
+ pressure_write = temp;
+
+ // Subtract pressure gradient from velocity
+ subtractGradient(velocityX_read, velocityX_write, 1);
+ temp = velocityX_read;
+ velocityX_read = velocityX_write;
+ velocityX_write = temp;
+ setBounds(velocityX_read, velocityX_write, 1);
+ temp = velocityX_read;
+ velocityX_read = velocityX_write;
+ velocityX_write = temp;
+
+ subtractGradient(velocityY_read, velocityY_write, 0);
+ temp = velocityY_read;
+ velocityY_read = velocityY_write;
+ velocityY_write = temp;
+ setBounds(velocityY_read, velocityY_write, 2);
+ temp = velocityY_read;
+ velocityY_read = velocityY_write;
+ velocityY_write = temp;
}
- public float getVelocityX(int i, int j) {
- return mVelocityX[at(i, j)];
+ private void subtractGradient(Framebuffer velocity, Framebuffer output, int isXComponent) {
+ output.bind();
+ gradientShader.use();
+
+ velocity.getTexture().bind(0);
+ pressure_read.getTexture().bind(1);
+ objectTexture.bind(2);
+
+ gradientShader.setUniform("uVelocity", 0);
+ gradientShader.setUniform("uPressure", 1);
+ gradientShader.setUniform("uObstacle", 2);
+ gradientShader.setUniform("uTexelSize", 1.0f / width, 1.0f / height);
+ gradientShader.setUniform("uIsXComponent", isXComponent);
+
+ quad.draw();
+
+ gradientShader.unuse();
+ output.unbind();
}
- public float getVelocityY(int i, int j) {
- return mVelocityY[at(i, j)];
+ private void advectVelocity(float dt) {
+ advect(velocityX_write, velocityX_read, velocityX_read, velocityY_read, dt);
+ Framebuffer temp = velocityX_read;
+ velocityX_read = velocityX_write;
+ velocityX_write = temp;
+ setBounds(velocityX_read, velocityX_write, 1);
+ temp = velocityX_read;
+ velocityX_read = velocityX_write;
+ velocityX_write = temp;
+
+ advect(velocityY_write, velocityY_read, velocityX_read, velocityY_read, dt);
+ temp = velocityY_read;
+ velocityY_read = velocityY_write;
+ velocityY_write = temp;
+ setBounds(velocityY_read, velocityY_write, 2);
+ temp = velocityY_read;
+ velocityY_read = velocityY_write;
+ velocityY_write = temp;
}
- public int getSize() {
- return mSize;
+ private void advectDensity(float dt) {
+ advect(density_write, density_read, velocityX_read, velocityY_read, dt, 1);
+
+ Framebuffer temp = density_read;
+ density_read = density_write;
+ density_write = temp;
+
+ setBounds(density_read, density_write, 0);
+ temp = density_read;
+ density_read = density_write;
+ density_write = temp;
}
- private int clamp(int min, int max, int value) {
- return Math.min(Math.max(value, min), max);
+ private void advect(Framebuffer output, Framebuffer source, Framebuffer velX, Framebuffer velY, float dt) {
+ advect(output, source, velX, velY, dt, 0);
}
- private void addSource(float target[], float source[], float deltaTime) {
- IntStream.range(0, mSize).parallel().forEach(i -> {
- target[i] += source[i] * deltaTime;
- });
+ private void advect(Framebuffer output, Framebuffer source, Framebuffer velX, Framebuffer velY, float dt,
+ int isDensity) {
+ output.bind();
+ advectShader.use();
+
+ velX.getTexture().bind(0);
+ velY.getTexture().bind(1);
+ source.getTexture().bind(2);
+ objectTexture.bind(3);
+
+ advectShader.setUniform("uVelocityX", 0);
+ advectShader.setUniform("uVelocityY", 1);
+ advectShader.setUniform("uSource", 2);
+ advectShader.setUniform("uObstacle", 3);
+ advectShader.setUniform("uTexelSize", 1.0f / width, 1.0f / height);
+ advectShader.setUniform("uDeltaTime", dt);
+ advectShader.setUniform("uDissipation", params.dissipation);
+ advectShader.setUniform("uIsDensity", isDensity);
+
+ quad.draw();
+
+ advectShader.unuse();
+ output.unbind();
}
- private void diffuse(Boundary bound, float target[], float source[], float diff, float deltaTime) {
- int i, j, k;
- float neighbors;
+ private void vorticityConfinement(float dt) {
+ // Calculate curl
+ curl.bind();
+ vorticityShader.use();
- float a = deltaTime / 10000.0f * diff * mRows * mCols;
+ velocityX_read.getTexture().bind(0);
+ velocityY_read.getTexture().bind(1);
- for (k = 0; k < mIterations; k++) {
- for (i = 1; i <= mRows; i++) {
- for (j = 1; j <= mCols; j++) {
- neighbors = target[at(i - 1, j)]
- + target[at(i + 1, j)]
- + target[at(i, j - 1)]
- + target[at(i, j + 1)];
+ vorticityShader.setUniform("uVelocityX", 0);
+ vorticityShader.setUniform("uVelocityY", 1);
+ vorticityShader.setUniform("uTexelSize", 1.0f / width, 1.0f / height);
- target[at(i, j)] = (source[at(i, j)] + a * neighbors) / (1 + 4 * a);
- }
- }
+ quad.draw();
- // setBounds(bound, target); // FIXME
- setBounds(bound, source);
- }
- }
+ vorticityShader.unuse();
+ curl.unbind();
- private void advect(Boundary bound, float d[], float d0[], float u[], float v[], float dt) {
- // int i0, j0, i1, j1;
- // float x, y, s0, t0, s1, t1;
+ // Apply vorticity force to X component
+ velocityX_write.bind();
+ vorticityForceShader.use();
- float dt0X = dt * mCols;
- float dt0Y = dt * mRows;
+ velocityX_read.getTexture().bind(0);
+ curl.getTexture().bind(1);
- IntStream.range(1, mRows).parallel().forEach((i) -> {
- int i0, j0, i1, j1;
- float x, y, s0, t0, s1, t1;
+ vorticityForceShader.setUniform("uVelocity", 0);
+ vorticityForceShader.setUniform("uCurl", 1);
+ vorticityForceShader.setUniform("uTexelSize", 1.0f / width, 1.0f / height);
+ vorticityForceShader.setUniform("uVorticityScale", params.vorticity);
+ vorticityForceShader.setUniform("uDeltaTime", dt);
+ vorticityForceShader.setUniform("uIsXComponent", 1);
- for (int j = 1; j <= mCols; j++) {
- // previous location of particle
- x = i - dt0X * u[at(i, j)];
- y = j - dt0Y * v[at(i, j)];
+ quad.draw();
- // check bounds
- if (x < 0.5f)
- x = 0.5f;
- if (x > mCols + 0.5f)
- x = mCols + 0.5f;
+ vorticityForceShader.unuse();
+ velocityX_write.unbind();
+ Framebuffer temp = velocityX_read;
+ velocityX_read = velocityX_write;
+ velocityX_write = temp;
- if (y < 0.5f)
- y = 0.5f;
- if (y > mRows + 0.5f)
- y = mRows + 0.5f;
+ // Apply vorticity force to Y component
+ velocityY_write.bind();
+ vorticityForceShader.use();
- i0 = (int) x;
- i1 = i0 + 1;
- j0 = (int) y;
- j1 = j0 + 1;
+ velocityY_read.getTexture().bind(0);
+ curl.getTexture().bind(1);
- s1 = x - i0;
- s0 = 1 - s1;
- t1 = y - j0;
- t0 = 1 - t1;
+ vorticityForceShader.setUniform("uVelocity", 0);
+ vorticityForceShader.setUniform("uCurl", 1);
+ vorticityForceShader.setUniform("uTexelSize", 1.0f / width, 1.0f / height);
+ vorticityForceShader.setUniform("uVorticityScale", params.vorticity);
+ vorticityForceShader.setUniform("uDeltaTime", dt);
+ vorticityForceShader.setUniform("uIsXComponent", 0);
- d[at(i, j)] = s0 * (t0 * d0[at(i0, j0)] + t1 * d0[at(i0, j1)])
- + s1 * (t0 * d0[at(i1, j0)] + t1 * d0[at(i1, j1)]);
- }
- });
+ quad.draw();
- // for (int i = 1; i <= mRows; i++) {
- // for (int j = 1; j <= mCols; j++) {
- // // previous location of particle
- // x = i - dt0X * u[at(i, j)];
- // y = j - dt0Y * v[at(i, j)];
+ vorticityForceShader.unuse();
+ velocityY_write.unbind();
+ temp = velocityY_read;
+ velocityY_read = velocityY_write;
+ velocityY_write = temp;
+ }
- // // check bounds
- // if (x < 0.5f)
- // x = 0.5f;
- // if (x > mCols + 0.5f)
- // x = mCols + 0.5f;
+ private void setBounds(Framebuffer source, Framebuffer output, int mode) {
+ output.bind();
+ boundaryShader.use();
- // if (y < 0.5f)
- // y = 0.5f;
- // if (y > mRows + 0.5f)
- // y = mRows + 0.5f;
+ source.getTexture().bind(0);
- // i0 = (int) x;
- // i1 = i0 + 1;
- // j0 = (int) y;
- // j1 = j0 + 1;
+ boundaryShader.setUniform("uSource", 0);
+ boundaryShader.setUniform("uTexelSize", 1.0f / width, 1.0f / height);
+ boundaryShader.setUniform("uBoundaryMode", mode);
- // s1 = x - i0;
- // s0 = 1 - s1;
- // t1 = y - j0;
- // t0 = 1 - t1;
+ quad.draw();
- // d[at(i, j)] = s0 *
- // (t0 * d0[at(i0, j0)] + t1 * d0[at(i0, j1)])
- // + s1 * (t0 * d0[at(i1, j0)] + t1 * d0[at(i1, j1)]);
- // }
- // }
+ boundaryShader.unuse();
+ output.unbind();
+ }
- setBounds(bound, d);
+ /**
+ * Adds force and density at a specific grid position with default interaction
+ * radius.
+ *
+ * @param x grid x-coordinate
+ * @param y grid y-coordinate
+ * @param forceX force in x direction
+ * @param forceY force in y direction
+ * @param densityAmount density to add (can be negative to remove)
+ */
+ public void addForceAndDensity(float x, float y, float forceX, float forceY, float densityAmount) {
+ addForceAndDensity(x, y, forceX, forceY, densityAmount, params.interactionRadius);
}
- private void updateDensity(float diff, float deltaTime) {
- float[] tempDensity = new float[mSize];
+ /**
+ * Adds force and density at a specific grid position with custom radius.
+ *
+ * @param x grid x-coordinate
+ * @param y grid y-coordinate
+ * @param forceX force in x direction
+ * @param forceY force in y direction
+ * @param densityAmount density to add (can be negative to remove)
+ * @param radius interaction radius in pixels
+ */
+ public void addForceAndDensity(float x, float y, float forceX, float forceY, float densityAmount, float radius) {
+ // Add force X
+ sourceVelocityX.bind();
+ splatShader.use();
+
+ sourceVelocityX.getTexture().bind(0);
+
+ splatShader.setUniform("uTarget", 0);
+ splatShader.setUniform("uPoint", x, y);
+ splatShader.setUniform("uRadius", radius);
+ splatShader.setUniform("uValue", forceX);
+ splatShader.setUniform("uGridSize", (float) width, (float) height);
+
+ quad.draw();
+
+ splatShader.unuse();
+ sourceVelocityX.unbind();
+
+ // Add force Y
+ sourceVelocityY.bind();
+ splatShader.use();
+
+ sourceVelocityY.getTexture().bind(0);
+
+ splatShader.setUniform("uTarget", 0);
+ splatShader.setUniform("uPoint", x, y);
+ splatShader.setUniform("uRadius", radius);
+ splatShader.setUniform("uValue", forceY);
+ splatShader.setUniform("uGridSize", (float) width, (float) height);
- addSource(mDensity, mSourceDensity, deltaTime);
+ quad.draw();
- diffuse(Boundary.None, tempDensity, mDensity, diff, deltaTime);
+ splatShader.unuse();
+ sourceVelocityY.unbind();
- var tmp = mDensity;
- mDensity = tempDensity;
- tempDensity = tmp;
+ sourceDensity.bind();
+ splatShader.use();
- advect(Boundary.None, tempDensity, mDensity, mVelocityX, mVelocityY, deltaTime);
+ sourceDensity.getTexture().bind(0);
- // tmp = mDensity;
- mDensity = tempDensity;
- // tempDensity = tmp;
+ splatShader.setUniform("uTarget", 0);
+ splatShader.setUniform("uPoint", x, y);
+ splatShader.setUniform("uRadius", radius);
+ splatShader.setUniform("uValue", densityAmount);
+ splatShader.setUniform("uGridSize", (float) width, (float) height);
+
+ quad.draw();
+
+ splatShader.unuse();
+ sourceDensity.unbind();
}
- private void updateVelocity(float viscosity, float deltaTime) {
- float[] tempVelocityX = new float[mSize];
- float[] tempVelocityY = new float[mSize];
- float[] tmp;
+ private void clearSourceBuffers() {
+ clearShader.use();
+ clearShader.setUniform("uClearColor", 0.0f, 0.0f, 0.0f, 0.0f);
- addSource(mVelocityX, mSourceVelocityX, deltaTime);
- addSource(mVelocityY, mSourceVelocityY, deltaTime);
+ sourceVelocityX.bind();
+ quad.draw();
+ sourceVelocityX.unbind();
- diffuse(Boundary.Horizontal, tempVelocityX, mVelocityX, viscosity, deltaTime);
- diffuse(Boundary.Vertical, tempVelocityY, mVelocityY, viscosity, deltaTime);
+ sourceVelocityY.bind();
+ quad.draw();
+ sourceVelocityY.unbind();
- tmp = mVelocityX;
- mVelocityX = tempVelocityX;
- tempVelocityX = tmp;
+ sourceDensity.bind();
+ quad.draw();
+ sourceDensity.unbind();
- tmp = mVelocityY;
- mVelocityY = tempVelocityY;
- tempVelocityY = tmp;
+ clearShader.unuse();
+ }
- project(mVelocityX, mVelocityY, tempVelocityX, tempVelocityY);
+ private void clearBuffer(Framebuffer fb) {
+ fb.bind();
+ clearShader.use();
+ clearShader.setUniform("uClearColor", 0.0f, 0.0f, 0.0f, 0.0f);
+ quad.draw();
+ clearShader.unuse();
+ fb.unbind();
+ }
- advect(Boundary.Horizontal, tempVelocityX, mVelocityX, mVelocityX, mVelocityY, deltaTime);
- advect(Boundary.Vertical, tempVelocityY, mVelocityY, mVelocityX, mVelocityY, deltaTime);
+ public Texture getDensityTexture() {
+ return density_read.getTexture();
+ }
- tmp = mVelocityX;
- mVelocityX = tempVelocityX;
- tempVelocityX = tmp;
+ /**
+ * Returns the x-component velocity field texture.
+ *
+ * @return velocity X texture
+ */
+ public Texture getVelocityXTexture() {
+ return velocityX_read.getTexture();
+ }
- tmp = mVelocityY;
- mVelocityY = tempVelocityY;
- tempVelocityY = tmp;
+ /**
+ * Returns the y-component velocity field texture.
+ *
+ * @return velocity Y texture
+ */
+ public Texture getVelocityYTexture() {
+ return velocityY_read.getTexture();
+ }
- project(mVelocityX, mVelocityY, tempVelocityX, tempVelocityY);
+ /**
+ * Returns the simulation grid width.
+ *
+ * @return width in cells
+ */
+ public int getWidth() {
+ return width;
}
- private void project(float u[], float v[], float poisson[], float divergence[]) {
- int i, j, k;
- float h = 1f / mCols;
+ /**
+ * Returns the simulation grid height.
+ *
+ * @return height in cells
+ */
+ public int getHeight() {
+ return height;
+ }
- for (i = 1; i <= mRows; i++) {
- for (j = 1; j <= mCols; j++) {
- divergence[at(i, j)] = -0.5f * h
- * (u[at(i + 1, j)]
- - u[at(i - 1, j)]
- + v[at(i, j + 1)]
- - v[at(i, j - 1)]);
+ /**
+ * Requests loading an obstacle bitmap from a PNG file.
+ *
+ * @param path file path to PNG image (must match grid dimensions)
+ * @throws Exception if file validation fails
+ */
+ public void loadObjects(String path) throws Exception {
+ ObstacleLoader.validateObjectPNG(path, width, height);
+ requestObjectLoadPath = path;
+ }
- poisson[at(i, j)] = 0;
- }
- }
+ /**
+ * Requests clearing all obstacles from the simulation.
+ */
+ public void clearObjects() {
+ requestObjectClear = true;
+ }
- setBounds(Boundary.None, divergence);
- setBounds(Boundary.None, poisson);
-
- for (k = 0; k < mIterations; k++) {
- for (i = 1; i <= mRows; i++) {
- for (j = 1; j <= mCols; j++) {
- poisson[at(i, j)] = (divergence[at(i, j)] //
- + poisson[at(i - 1, j)]
- + poisson[at(i + 1, j)]
- + poisson[at(i, j - 1)]
- + poisson[at(i, j + 1)]) / 4;
- }
- }
- setBounds(Boundary.None, poisson);
- }
+ /**
+ * Returns the current obstacle mask texture.
+ *
+ * @return object texture (1.0 = wall, 0.0 = free space)
+ */
+ public Texture getObjectTexture() {
+ return objectTexture;
+ }
- mMinDensity = Float.MAX_VALUE;
- mMaxDensity = 0;
+ /**
+ * Process pending obstacle operations on the OpenGL thread
+ */
+ private void processObjectRequest() {
+ // Handle load
+ if (requestObjectLoadPath != null) {
+ String path = requestObjectLoadPath;
+ requestObjectLoadPath = null;
- for (i = 1; i <= mRows; i++) {
- for (j = 1; j <= mCols; j++) {
- u[at(i, j)] -= 0.5 * (poisson[at(i + 1, j)] - poisson[at(i - 1, j)]) / h;
- v[at(i, j)] -= 0.5 * (poisson[at(i, j + 1)] - poisson[at(i, j - 1)]) / h;
+ try {
+ if (objectTexture != null)
+ objectTexture.delete();
- mMaxDensity = Math.max(mMaxDensity, mDensity[at(i, j)]);
- mMinDensity = Math.min(mMinDensity, mDensity[at(i, j)]);
+ objectTexture = ObstacleLoader.loadObjectFromPNG(path, width, height);
+
+ } catch (Exception e) {
+ System.err.println("Failed to load obstacle: " + e.getMessage());
+ e.printStackTrace();
}
}
- setBounds(Boundary.Vertical, u);
- setBounds(Boundary.Horizontal, v);
- }
-
- // function Curl(x,y:integer):double;
- // begin
- // Curl:=xvelocity[x,y+1]-xvelocity[x,y-1] + yvelocity[x-1,y]-yvelocity[x+1, y];
- // end;
-
- // procedure VorticityConfinement(vorticity:double);
- // var dx,dy,len:double;
- // x,y:integer;
- // begin
- // for y:=2 to _h-3 do
- // begin
- // for x:=2 to _w-3 do
- // begin
- // dx:=abs(curl(x + 0, y - 1)) - abs(curl(x + 0, y + 1));
- // dy:=abs(curl(x + 1, y + 0)) - abs(curl(x - 1, y + 0));
- // len:=sqrt(sqr(dx)+sqr(dy))+1e-5;
- // dx:=vorticity/len*dx;
- // dy:=vorticity/len*dy;
- // xvelocity[x,y]:=xvelocity[x,y]+timestep*curl(x,y)*dx);
- // yvelocity[x,y]:=yvelocity[x,y]+timestep*curl(x,y)*dy);
- // end;
- // end;
- // end;
-
- private float curl(int x, int y) {
- return mVelocityX[at(x, y + 1)]
- - mVelocityX[at(x, y - 1)]
- + mVelocityY[at(x - 1, y)]
- - mVelocityY[at(x + 1, y)];
- }
-
- private void vorticityConfinement(float vorticity, float deltaTime) {
- float dx, dy, len;
- int i, j;
-
- for (i = 2; i < mRows; i++) {
- for (j = 2; j < mCols; j++) {
- dx = Math.abs(curl(i, j - 1)) - Math.abs(curl(i, j + 1));
- dy = Math.abs(curl(i + 1, j)) - Math.abs(curl(i - 1, j));
-
- len = (float) Math.sqrt(dx * dx + dy * dy) + 0.0001f;
-
- dx = vorticity / len * dx;
- dy = vorticity / len * dy;
-
- mVelocityX[at(i, j)] = mVelocityX[at(i, j)] + deltaTime * curl(i, j) * dx;
- mVelocityY[at(i, j)] = mVelocityY[at(i, j)] + deltaTime * curl(i, j) * dy;
- }
+ // Handle clear
+ if (requestObjectClear) {
+ requestObjectClear = false;
+
+ if (objectTexture != null)
+ objectTexture.delete();
+
+ objectTexture = ObstacleLoader.empty(width, height);
}
}
- private void setBounds(Boundary b, float x[]) {
- for (int i = 1; i < mCols + 1; i++) {
- x[at(0, i)] = b == Boundary.Horizontal ? -x[at(1, i)] : x[at(1, i)];
- x[at(mCols + 1, i)] = b == Boundary.Horizontal ? -x[at(mCols, i)] : x[at(mCols, i)];
+ /**
+ * Apply all active nozzles to the simulation
+ */
+ private void updateObjects() {
+ for (SimObject simObject : simObjects) {
+ simObject.apply(this);
}
+ }
+
+ /**
+ * Apply all active nozzles to the simulation
+ */
+ public void deleteAllObjects() {
+ simObjects.clear();
+ }
+
+ /**
+ * Add a nozzle to the simulation
+ *
+ * @param obj the simulation object to add (e.g., Nozzle)
+ */
+ public void addObject(SimObject obj) {
+ obj.initialize(width, height);
+ simObjects.add(obj);
+ }
- for (int i = 1; i < mRows + 1; i++) {
- x[at(i, 0)] = b == Boundary.Vertical ? -x[at(i, 1)] : x[at(i, 1)];
- x[at(i, mRows + 1)] = b == Boundary.Vertical ? -x[at(i, mRows)] : x[at(i, mRows)];
+ /**
+ * Releases all GPU resources including framebuffers, textures, and shaders.
+ */
+ public void cleanup() {
+ // Delete obstacle texture
+ if (objectTexture != null) {
+ objectTexture.delete();
}
- x[at(0, 0)] = 0.5f * (x[at(1, 0)] + x[at(0, 1)]);
- x[at(0, mRows + 1)] = 0.5f * (x[at(1, mRows + 1)] + x[at(0, mRows)]);
- x[at(mCols + 1, 0)] = 0.5f * (x[at(mCols, 0)] + x[at(mCols + 1, 1)]);
- x[at(mCols + 1, mRows + 1)] = 0.5f * (x[at(mCols, mRows + 1)] + x[at(mCols + 1, mRows)]);
+ // Delete framebuffers
+ velocityX_read.delete();
+ velocityX_write.delete();
+ velocityY_read.delete();
+ velocityY_write.delete();
+ density_read.delete();
+ density_write.delete();
+ pressure_read.delete();
+ pressure_write.delete();
+ divergence.delete();
+ curl.delete();
+ sourceVelocityX.delete();
+ sourceVelocityY.delete();
+ sourceDensity.delete();
+
+ // Delete shaders
+ advectShader.delete();
+ diffuseShader.delete();
+ divergenceShader.delete();
+ pressureShader.delete();
+ gradientShader.delete();
+ vorticityShader.delete();
+ vorticityForceShader.delete();
+ boundaryShader.delete();
+ addSourceShader.delete();
+ splatShader.delete();
+ clearShader.delete();
+
+ // Delete quad
+ quad.delete();
}
}
diff --git a/src/main/java/dev/fluidsim/simulation/SimObject.java b/src/main/java/dev/fluidsim/simulation/SimObject.java
new file mode 100644
index 0000000..41adfb0
--- /dev/null
+++ b/src/main/java/dev/fluidsim/simulation/SimObject.java
@@ -0,0 +1,22 @@
+package dev.fluidsim.simulation;
+
+/**
+ * An object that can interact with the fluid simulation.
+ */
+public interface SimObject {
+
+ /**
+ * Initializes the object's world position based on grid dimensions.
+ *
+ * @param gridWidth simulation grid width in pixels
+ * @param gridHeight simulation grid height in pixels
+ */
+ public void initialize(int gridWidth, int gridHeight);
+
+ /**
+ * Applies this object's effects to the simulation state.
+ *
+ * @param simulation the simulation to modify
+ */
+ public void apply(Sim simulation);
+}
diff --git a/src/main/java/dev/fluidsim/simulation/SimParams.java b/src/main/java/dev/fluidsim/simulation/SimParams.java
new file mode 100644
index 0000000..cf2011e
--- /dev/null
+++ b/src/main/java/dev/fluidsim/simulation/SimParams.java
@@ -0,0 +1,222 @@
+package dev.fluidsim.simulation;
+
+import java.awt.Color;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Container for all fluid simulation parameters.
+ * Includes runtime physics settings, grid dimensions, visualization options,
+ * and nozzle configurations.
+ */
+public class SimParams {
+ /** grid width in pixels */
+ public int width = Constants.WIDTH;
+ /** grid height in pixels */
+ public int height = Constants.HEIGHT;
+
+ /** number of solver iterations per time step */
+ public int iterations = Constants.ITERATIONS;
+ /** fluid viscosity coefficient */
+ public float viscosity = Constants.VISCOSITY;
+ /** density diffusion rate */
+ public float diffusion = Constants.DIFFUSION;
+ /** vorticity confinement strength */
+ public float vorticity = Constants.VORTICITY;
+ /** velocity dissipation factor */
+ public float dissipation = Constants.DISSIPATION;
+ /** mouse interaction radius in pixels */
+ public float interactionRadius = Constants.INTERACTION_RADIUS;
+ /** if true, visualizes velocity field instead of density */
+ public boolean velocityView = Constants.VELOCITY_VIEW;
+ /** if true, renders velocity arrows */
+ public boolean showArrows = Constants.SHOW_ARROWS;
+
+ /** spacing between arrows as fraction of grid size */
+ public float arrowDensity = Constants.ARROW_DENSITY;
+ /** maximum arrow length in pixels */
+ public float maxArrowLength = Constants.MAX_ARROW_LENGTH;
+ /** arrowhead length in pixels */
+ public float arrowHeadLength = Constants.ARROWHEAD_LENGTH;
+ /** arrowhead angle in radians */
+ public float arrowHeadAngle = Constants.ARROWHEAD_ANGLE;
+ /** velocity to arrow length scale factor */
+ public float velocityScale = Constants.VELOCITY_SCALE;
+
+ /** arrow rendering color */
+ public Color arrowColor = new Color(255, 255, 255, 255);
+
+ /** list of active nozzles emitting fluid */
+ public List nozzles = new ArrayList<>();
+
+ /**
+ * Creates simulation parameters with default constant values.
+ */
+ public SimParams() {
+ }
+
+ /**
+ * Creates simulation parameters with specified runtime values.
+ *
+ * @param iterations number of solver iterations, must be positive
+ * @param viscosity fluid viscosity coefficient, typically 0.0-1.0
+ * @param diffusion density diffusion rate, typically 0.0-1.0
+ * @param vorticity vorticity confinement strength, typically 0.0-50.0
+ * @param dissipation velocity dissipation factor, typically 0.99-1.0
+ * @param interactionRadius mouse interaction radius in pixels, must be positive
+ */
+ public SimParams(int iterations, float viscosity, float diffusion,
+ float vorticity, float dissipation, float interactionRadius) {
+ this.iterations = iterations;
+ this.viscosity = viscosity;
+ this.diffusion = diffusion;
+ this.vorticity = vorticity;
+ this.dissipation = dissipation;
+ this.interactionRadius = interactionRadius;
+ }
+
+ /**
+ * Returns the number of solver iterations per time step.
+ *
+ * @return iterations count
+ */
+ public int getIterations() {
+ return iterations;
+ }
+
+ /**
+ * Sets the number of solver iterations per time step.
+ *
+ * @param iterations iterations count, must be positive
+ */
+ public void setIterations(int iterations) {
+ this.iterations = iterations;
+ }
+
+ /**
+ * Returns the fluid viscosity coefficient.
+ *
+ * @return viscosity value
+ */
+ public float getViscosity() {
+ return viscosity;
+ }
+
+ /**
+ * Sets the fluid viscosity coefficient.
+ *
+ * @param viscosity viscosity value, typically 0.0-1.0
+ */
+ public void setViscosity(float viscosity) {
+ this.viscosity = viscosity;
+ }
+
+ /**
+ * Returns the density diffusion rate.
+ *
+ * @return diffusion value
+ */
+ public float getDiffusion() {
+ return diffusion;
+ }
+
+ /**
+ * Sets the density diffusion rate.
+ *
+ * @param diffusion diffusion value, typically 0.0-1.0
+ */
+ public void setDiffusion(float diffusion) {
+ this.diffusion = diffusion;
+ }
+
+ /**
+ * Returns the vorticity confinement strength.
+ *
+ * @return vorticity value
+ */
+ public float getVorticity() {
+ return vorticity;
+ }
+
+ /**
+ * Sets the vorticity confinement strength.
+ *
+ * @param vorticity vorticity value, typically 0.0-50.0
+ */
+ public void setVorticity(float vorticity) {
+ this.vorticity = vorticity;
+ }
+
+ /**
+ * Returns the velocity dissipation factor.
+ *
+ * @return dissipation value
+ */
+ public float getDissipation() {
+ return dissipation;
+ }
+
+ /**
+ * Sets the velocity dissipation factor.
+ *
+ * @param dissipation dissipation value, typically 0.99-1.0
+ */
+ public void setDissipation(float dissipation) {
+ this.dissipation = dissipation;
+ }
+
+ /**
+ * Returns the mouse interaction radius.
+ *
+ * @return radius in pixels
+ */
+ public float getInteractionRadius() {
+ return interactionRadius;
+ }
+
+ /**
+ * Sets the mouse interaction radius.
+ *
+ * @param interactionRadius radius in pixels, must be positive
+ */
+ public void setInteractionRadius(float interactionRadius) {
+ this.interactionRadius = interactionRadius;
+ }
+
+ /**
+ * Returns whether velocity field visualization is enabled.
+ *
+ * @return true if showing velocity instead of density
+ */
+ public boolean isVelocityView() {
+ return velocityView;
+ }
+
+ /**
+ * Sets whether to visualize velocity field instead of density.
+ *
+ * @param velocityView true to show velocity field
+ */
+ public void setVelocityView(boolean velocityView) {
+ this.velocityView = velocityView;
+ }
+
+ /**
+ * Returns whether velocity arrows are enabled.
+ *
+ * @return true if rendering arrows
+ */
+ public boolean isShowArrows() {
+ return showArrows;
+ }
+
+ /**
+ * Sets whether to render velocity arrows.
+ *
+ * @param showArrows true to show arrows
+ */
+ public void setShowArrows(boolean showArrows) {
+ this.showArrows = showArrows;
+ }
+
+}
diff --git a/src/main/java/dev/fluidsim/ui/BetterCanvas.java b/src/main/java/dev/fluidsim/ui/BetterCanvas.java
deleted file mode 100644
index 8403a72..0000000
--- a/src/main/java/dev/fluidsim/ui/BetterCanvas.java
+++ /dev/null
@@ -1,116 +0,0 @@
-package dev.fluidsim.ui;
-
-import javax.swing.JFrame;
-
-import dev.fluidsim.simulation.Sim;
-
-import java.awt.*;
-import java.awt.image.BufferStrategy;
-
-public class BetterCanvas {
-
- static JFrame frame;
- static java.awt.Canvas canvas;
-
- private static int FRAME_WIDTH, FRAME_HEIGHT, SIMULATION_WIDTH, SIMULATION_HEIGHT;
-
- public static float now, lastTick, deltaTime;
-
- public static void run(int FRAME_WIDTH, int FRAME_HEIGHT, int SIMULATION_WIDTH, int SIMULATION_HEIGHT) {
- frame = new JFrame("Fluids");
-
- BetterCanvas.FRAME_WIDTH = FRAME_WIDTH;
- BetterCanvas.FRAME_HEIGHT = FRAME_HEIGHT;
- BetterCanvas.SIMULATION_WIDTH = SIMULATION_WIDTH;
- BetterCanvas.SIMULATION_HEIGHT = SIMULATION_HEIGHT;
-
- // frame.setUndecorated(true);
- frame.setResizable(false);
- frame.setSize(FRAME_WIDTH, FRAME_HEIGHT);
- frame.setLocationRelativeTo(null);
-
- frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
-
- canvas = new java.awt.Canvas();
- frame.add(canvas);
- canvas.setSize(FRAME_WIDTH, FRAME_HEIGHT);
-
- frame.setVisible(true);
- canvas.requestFocus();
-
- init();
-
- boolean running = true;
- long gameStart = System.nanoTime();
-
- double FPS = 120;
- double wait = 1000 / FPS;
-
- int i = 0; // DEBUG
-
- while (running) {
- long start = System.nanoTime();
-
- i++;
-
- lastTick = now;
- now = (start - gameStart) * 0.000000001f;
- deltaTime = now - lastTick;
-
- if (i % 100 == 0)
- System.out.println(1 / deltaTime);
-
- update();
-
- BufferStrategy bs = canvas.getBufferStrategy();
- if (bs == null) {
- canvas.createBufferStrategy(2);
- continue;
- }
-
- Graphics2D g = (Graphics2D) bs.getDrawGraphics();
- g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
- g.setBackground(Color.BLACK);
-
- render(g);
-
- bs.show();
- g.dispose();
-
- // long realWait = Math.round(wait - (System.nanoTime() - start) / 1_000_000);
-
- // if (realWait > 0) {
- // try {
- // Thread.sleep(realWait);
- // } catch (InterruptedException e) {
- // e.printStackTrace();
- // }
- // }
- }
- }
-
- private static Sim simulation;
- private static Density density;
- private static Vectors vectors;
-
- private static void init() {
- float scaleX = FRAME_WIDTH / (float) SIMULATION_WIDTH;
- float scaleY = FRAME_HEIGHT / (float) SIMULATION_HEIGHT;
-
- simulation = new Sim(SIMULATION_WIDTH, SIMULATION_HEIGHT);
- density = new Density(simulation, scaleX, scaleY);
- vectors = new Vectors(simulation, FRAME_WIDTH, FRAME_HEIGHT, 5);
-
- var listener = new MouseEventListener(simulation, scaleX, scaleY);
- canvas.addMouseMotionListener(listener);
- }
-
- private static void render(Graphics2D g) {
- density.draw(g, FRAME_WIDTH, FRAME_HEIGHT);
- // vectors.draw(g, FRAME_WIDTH, FRAME_HEIGHT);
- }
-
- private static void update() {
- simulation.update(deltaTime);
- }
-}
\ No newline at end of file
diff --git a/src/main/java/dev/fluidsim/ui/Canvas.java b/src/main/java/dev/fluidsim/ui/Canvas.java
deleted file mode 100644
index fe22d16..0000000
--- a/src/main/java/dev/fluidsim/ui/Canvas.java
+++ /dev/null
@@ -1,220 +0,0 @@
-package dev.fluidsim.ui;
-
-import java.awt.Color;
-import java.awt.Graphics;
-import java.awt.Graphics2D;
-import java.awt.GraphicsConfiguration;
-import java.awt.GraphicsEnvironment;
-import java.awt.RenderingHints;
-import java.awt.Stroke;
-import java.awt.Transparency;
-import java.awt.image.VolatileImage;
-import java.util.ArrayList;
-import java.util.List;
-import javax.swing.JPanel;
-
-import java.util.Collections;
-
-/**
- * A custom canvas component that runs a render loop for animations
- * Implements Runnable to create its own animation thread
- */
-public class Canvas extends JPanel implements Runnable {
- public interface Executable {
- void exec(float deltaTime);
- }
-
- private final int WIDTH;
- private final int HEIGHT;
-
- private Executable updateCallback = null;
-
- // Thread
- private Thread mThread;
- // image creation
- VolatileImage mVolatileImage;
-
- private volatile boolean isRunning = false;
- // List of drawable objects. We use a thread-safe list
- private List drawables = Collections.synchronizedList(new ArrayList<>());
-
- protected long deltaTime; // in sec
-
- public Canvas() {
- this(1000, 1000);
- }
-
- public Canvas(int width, int height) {
- WIDTH = width;
- HEIGHT = height;
- mVolatileImage = _createVolatileImage(width, height);
- }
-
- public void onUpdate(Executable callback) {
- updateCallback = callback;
- }
-
- /**
- * Adds a drawable object to the canvas
- *
- * @param drawable The object to be drawn
- */
- public boolean add(Drawable drawable) {
- return this.drawables.add(drawable);
- }
-
- /**
- * Removes a drawable object from the canvas
- *
- * @param drawable The object to be removed
- */
- public boolean remove(Drawable drawable) {
- return this.drawables.remove(drawable);
- }
-
- /**
- * Starts the animation loop when the component is added to the screen
- */
- @Override
- public void addNotify() {
- super.addNotify();
- start();
- }
-
- /**
- * Stops the animation loop when the component is removed
- */
- @Override
- public void removeNotify() {
- stop();
- super.removeNotify();
- }
-
- /**
- * Starts the animation thread
- */
- public void start() {
- if (mThread == null) {
- isRunning = true;
- mThread = new Thread(this);
- mThread.start();
- }
- }
-
- /**
- * Stops the animation thread gracefully
- */
- public void stop() {
- isRunning = false;
- if (mThread != null) {
- try {
- mThread.join(); // Wait for the thread to finish
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- }
- mThread = null;
- }
-
- /**
- * The main animation loop
- */
- @Override
- public void run() {
- long startTime;
-
- int frame = 0;
-
- while (isRunning) {
- startTime = System.nanoTime();
-
- updateCanvas();
- repaint();
-
- // ms
- deltaTime = (System.nanoTime() - startTime) / 1_000_000;
-
- if (frame % 100 == 0) {
- System.out.println(1000 / deltaTime + " " + deltaTime + "ms");
- }
-
- frame++;
- }
- }
-
- // This method is where all the custom drawing happens
- @Override
- protected void paintComponent(Graphics g) {
- super.paintComponent(g);
-
- GraphicsEnvironment ge = GraphicsEnvironment.getLocalGraphicsEnvironment();
- GraphicsConfiguration gc = ge.getDefaultScreenDevice().getDefaultConfiguration();
-
- Graphics2D g2d = null;
-
- do {
- int valid = mVolatileImage.validate(gc);
-
- if (valid == VolatileImage.IMAGE_INCOMPATIBLE) {
- mVolatileImage = this._createVolatileImage(WIDTH, HEIGHT);
- }
-
- try {
- g2d = mVolatileImage.createGraphics();
- g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
- g2d.setBackground(Color.BLACK);
-
- // render
- synchronized (drawables) {
- Stroke defaultStroke = g2d.getStroke();
- Color defaultColor = g2d.getColor();
-
- for (Drawable drawable : drawables) {
- // System.out.println("drawing " + d.getClass().getCanonicalName());
- drawable.draw(g2d, WIDTH, HEIGHT);
- g2d.setColor(defaultColor);
- g2d.setStroke(defaultStroke);
- }
- }
-
- g.drawImage(mVolatileImage, 0, 0, null);
-
- } finally { // It's always best to dispose of your Graphics objects.
- g2d.dispose();
- }
-
- } while (mVolatileImage.contentsLost());
-
- }
-
- private VolatileImage _createVolatileImage(int width, int height) {
- GraphicsEnvironment ge = GraphicsEnvironment.getLocalGraphicsEnvironment();
- GraphicsConfiguration gc = ge.getDefaultScreenDevice().getDefaultConfiguration();
- VolatileImage image = null;
-
- image = gc.createCompatibleVolatileImage(width, height, Transparency.BITMASK);
-
- int valid = image.validate(gc);
-
- if (valid == VolatileImage.IMAGE_INCOMPATIBLE) {
- image = this._createVolatileImage(width, height);
- return image;
- }
-
- return image;
- }
-
- /**
- * Calls the update method for all drawable objects
- */
- private void updateCanvas() {
- synchronized (drawables) {
- if (updateCallback != null)
- updateCallback.exec(deltaTime / 1000f);
-
- for (Drawable d : drawables) {
- d.update(deltaTime / 1000f);
- }
- }
- }
-}
diff --git a/src/main/java/dev/fluidsim/ui/Density.java b/src/main/java/dev/fluidsim/ui/Density.java
deleted file mode 100644
index ae61316..0000000
--- a/src/main/java/dev/fluidsim/ui/Density.java
+++ /dev/null
@@ -1,58 +0,0 @@
-package dev.fluidsim.ui;
-
-import java.awt.Color;
-import java.awt.Graphics2D;
-import java.awt.image.BufferedImage;
-
-import dev.fluidsim.simulation.Sim;
-
-public class Density implements Drawable {
- private Sim sim;
- private BufferedImage image;
-
- private final float scaleX;
- private final float scaleY;
-
- public Density(Sim simulation, float scaleX, float scaleY) {
- this.sim = simulation;
- this.scaleX = scaleX;
- this.scaleY = scaleY;
-
- image = new BufferedImage(sim.getCellCountX(), sim.getCellCountY(), BufferedImage.TYPE_INT_ARGB);
- }
-
- @Override
- public void update(float deltaTime) {
- }
-
- /**
- * Draws the grid lines on the canvas.
- */
- @Override
- public void draw(Graphics2D g, int screenWidth, int screenHeight) {
- g.scale(scaleX, scaleY);
-
- for (int i = 0; i < sim.getCellCountX(); i++) {
- for (int j = 0; j < sim.getCellCountY(); j++) {
- float d = sim.getDensity(i, j) / sim.getMaxDensity();
- int colorValue = (int) (Math.min(d * 255, 255));
-
- // Color c = Utils.lerp(Color.BLACK, Color.WHITE, d);
- // Color c = Utils.lerp(Color.BLACK, Color.WHITE,
- // (sim.getVelocityX(i, j) + sim.getVelocityY(i, j)) / 2f * d);
-
- Color c = new Color(colorValue, colorValue, colorValue);
-
- // g.setColor(c);
- // g.fillRect((int) (i * scaleX), (int) (j * scaleY), (int) scaleX, (int)
- // scaleY);
-
- image.setRGB(i, j, c.getRGB());
- }
- }
-
- g.drawImage(image, null, null);
- g.scale(1 / scaleX, 1 / scaleY); // reset
- }
-
-}
diff --git a/src/main/java/dev/fluidsim/ui/Drawable.java b/src/main/java/dev/fluidsim/ui/Drawable.java
deleted file mode 100644
index 362af4e..0000000
--- a/src/main/java/dev/fluidsim/ui/Drawable.java
+++ /dev/null
@@ -1,23 +0,0 @@
-package dev.fluidsim.ui;
-
-import java.awt.Graphics2D;
-
-/**
- * An interface for any object that can be drawn on the canvas and updated for
- * animation.
- */
-public interface Drawable {
- /**
- * Updates the object's state (e.g., position, angle).
- */
- void update(float deltaTime);
-
- /**
- * Renders the object on the canvas.
- *
- * @param g The Graphics2D context to draw on.
- * @param screenWidth The current width of the canvas.
- * @param screenHeight The current height of the canvas.
- */
- void draw(Graphics2D g, int screenWidth, int screenHeight);
-}
diff --git a/src/main/java/dev/fluidsim/ui/Grid.java b/src/main/java/dev/fluidsim/ui/Grid.java
deleted file mode 100644
index 7bd6ac9..0000000
--- a/src/main/java/dev/fluidsim/ui/Grid.java
+++ /dev/null
@@ -1,66 +0,0 @@
-package dev.fluidsim.ui;
-
-import java.awt.Color;
-import java.awt.Graphics2D;
-import java.awt.Stroke;
-import java.awt.BasicStroke;
-
-/**
- * A Drawable grid that divides the canvas into N (rows) x K (cols) cells.
- */
-public class Grid implements Drawable {
- public record Look(Color color, float width) {
- Stroke getStroke() {
- return new BasicStroke(width);
- }
- }
-
- protected static final Look DEFAULT_LOOK = new Grid.Look(Color.GRAY, 1.f);
-
- protected int cellSize;
- protected Look look;
-
- public Grid(int cellSize, Look look) throws IllegalArgumentException {
- if (cellSize <= 0) {
- throw new IllegalArgumentException(
- String.format("Grid rows and cols must be >= 0! (got: %d)", cellSize));
- }
-
- this.cellSize = cellSize;
- this.look = look;
- }
-
- public Grid(int cellSize) {
- this(cellSize, DEFAULT_LOOK);
- }
-
- /**
- * The grid is static, so update does nothing.
- */
- @Override
- public void update(float deltaTime) {
- // Nothing to update
- }
-
- /**
- * Draws the grid lines on the canvas.
- */
- @Override
- public void draw(Graphics2D g, int screenWidth, int screenHeight) {
- g.setColor(look.color);
- g.setStroke(look.getStroke());
-
- int cols = Math.floorDiv(screenWidth, cellSize);
- int rows = Math.floorDiv(screenHeight, cellSize);
-
- for (int i = 0; i < cols; i++) {
- int x = (int) (i * cellSize);
- g.drawLine(x, 0, x, screenHeight);
- }
-
- for (int j = 0; j < rows; j++) {
- int y = (int) (j * cellSize);
- g.drawLine(0, y, screenWidth, y);
- }
- }
-}
\ No newline at end of file
diff --git a/src/main/java/dev/fluidsim/ui/MouseEventListener.java b/src/main/java/dev/fluidsim/ui/MouseEventListener.java
deleted file mode 100644
index 3ef6f32..0000000
--- a/src/main/java/dev/fluidsim/ui/MouseEventListener.java
+++ /dev/null
@@ -1,69 +0,0 @@
-package dev.fluidsim.ui;
-
-import java.awt.event.MouseMotionListener;
-
-import dev.fluidsim.simulation.Sim;
-
-public class MouseEventListener implements MouseMotionListener {
- private final Sim mSim;
-
- private final float mScaleX;
- private final float mScaleY;
-
- private int prevMouseX = 0;
- private int prevMouseY = 0;
-
- private static final int MOVEMENT_TRESHOLD = 3;
-
- public MouseEventListener(Sim sim, float scaleX, float scaleY) {
- super();
-
- this.mSim = sim;
-
- mScaleX = scaleX;
- mScaleY = scaleY;
- }
-
- @Override
- public void mouseDragged(java.awt.event.MouseEvent e) {
- int x = (int) (e.getX() / mScaleX);
- int y = (int) (e.getY() / mScaleY);
-
- if (x < 0 || x >= mSim.getCellCountX() || y < 0 || y >= mSim.getCellCountY())
- return;
-
- int dirX = (e.getX() - prevMouseX);
- int dirY = (e.getY() - prevMouseY);
-
- prevMouseX = e.getX();
- prevMouseY = e.getY();
-
- // if (Math.abs(dirX) < MOVEMENT_TRESHOLD && Math.abs(dirY) < MOVEMENT_TRESHOLD)
- // return;
-
- float powerX = (float) (10 * dirX / 60f);
- float powerY = (float) (10 * dirY / 60f);
-
- if (e.isShiftDown()) {
- float density = (3.0f / 60f) * (1 + (float) Math.sqrt(dirX * dirX + dirY * dirY) / 3) * 1000f;
- mSim.addForceAndDensity(x, y, powerX, powerY, density);
-
- } else if (e.isControlDown()) {
- mSim.clearDensity(x, y);
-
- } else if (e.isAltDown()) {
- mSim.clearSourceInRadius(x, y);
-
- } else {
- mSim.addForceAndDensity(x, y, powerX, powerY, 0f);
- }
-
- }
-
- @Override
- public void mouseMoved(java.awt.event.MouseEvent e) {
- prevMouseX = e.getX();
- prevMouseY = e.getY();
-
- }
-}
diff --git a/src/main/java/dev/fluidsim/ui/SimControlPanel.java b/src/main/java/dev/fluidsim/ui/SimControlPanel.java
new file mode 100644
index 0000000..cc12285
--- /dev/null
+++ b/src/main/java/dev/fluidsim/ui/SimControlPanel.java
@@ -0,0 +1,397 @@
+package dev.fluidsim.ui;
+
+import dev.fluidsim.simulation.Constants;
+import dev.fluidsim.simulation.Sim;
+import dev.fluidsim.simulation.SimParams;
+
+import javax.swing.*;
+import javax.swing.border.EmptyBorder;
+import javax.swing.border.TitledBorder;
+import java.awt.*;
+import java.io.File;
+
+/**
+ * Control panel for adjusting simulation parameters in real-time
+ */
+public class SimControlPanel extends JPanel {
+ private final SimParams params;
+ private final Sim simulation;
+
+ private JSlider iterationsSlider;
+ private JSlider viscositySlider;
+ /** slider for adjusting density diffusion rate */
+ private JSlider diffusionSlider;
+ private JSlider vorticitySlider;
+ private JSlider dissipationSlider;
+ private JSlider interactionRadiusSlider;
+
+ private JCheckBox showVelocityCheckBox;
+ private JCheckBox showArrowsCheckBox;
+
+ private JLabel iterationsValueLabel;
+ private JLabel viscosityValueLabel;
+ private JLabel diffusionValueLabel;
+ private JLabel vorticityValueLabel;
+ private JLabel dissipationValueLabel;
+ private JLabel interactionRadiusValueLabel;
+
+ /**
+ * Creates a control panel for real-time parameter adjustment.
+ *
+ * @param params simulation parameters to control
+ * @param simulation simulation instance to interact with
+ */
+ public SimControlPanel(SimParams params, Sim simulation) {
+ this.params = params;
+ this.simulation = simulation;
+
+ // setLayout(new BoxLayout(this, BoxLayout.Y_AXIS));
+ setLayout(new GridBagLayout());
+ GridBagConstraints constraints = new GridBagConstraints();
+ constraints.fill = GridBagConstraints.HORIZONTAL;
+ constraints.weightx = 1;
+ constraints.gridx = 0;
+
+ setBorder(new EmptyBorder(10, 10, 10, 10));
+ setPreferredSize(new Dimension(280, 800));
+
+ // Title
+ JLabel titleLabel = new JLabel("Simulation Controls");
+ titleLabel.setFont(new Font("Arial", Font.BOLD, 16));
+ titleLabel.setAlignmentX(Component.RIGHT_ALIGNMENT);
+ add(titleLabel);
+ add(Box.createVerticalStrut(20));
+
+ // Create control panels
+ add(createVisualizationPanel(), constraints);
+ add(Box.createVerticalStrut(15));
+ add(createIterationsPanel(), constraints);
+ add(Box.createVerticalStrut(10));
+ add(createViscosityPanel(), constraints);
+ add(Box.createVerticalStrut(10));
+ add(createDiffusionPanel(), constraints);
+ add(Box.createVerticalStrut(10));
+ add(createVorticityPanel(), constraints);
+ add(Box.createVerticalStrut(10));
+ add(createDissipationPanel(), constraints);
+ add(Box.createVerticalStrut(10));
+ add(createInteractionRadiusPanel(), constraints);
+ add(Box.createVerticalStrut(20));
+
+ // Reset button
+ JButton resetButton = new JButton("Reset to Defaults");
+ resetButton.setAlignmentX(Component.CENTER_ALIGNMENT);
+ resetButton.addActionListener(e -> resetToDefaults());
+ add(resetButton, constraints);
+ add(Box.createVerticalStrut(20));
+
+ add(createInfoPanel(), constraints);
+ }
+
+ /**
+ * Callback interface for file selection operations.
+ */
+ public interface Executable {
+ /**
+ * Executes an operation with the selected file path.
+ *
+ * @param path absolute path to selected file
+ * @throws Exception if operation fails
+ */
+ void execute(String path) throws Exception;
+ }
+
+ /**
+ * Opens a file picker dialog and executes a callback with selected file.
+ *
+ * @param parent parent component for dialog
+ * @param runnable callback to execute with selected file path
+ */
+ public static void invokeFilePicker(Component parent, Executable runnable) {
+ JFileChooser fileChooser = new JFileChooser();
+ fileChooser.setDialogTitle("Select Obstacle PNG");
+
+ int result = fileChooser.showOpenDialog(parent);
+ if (result == JFileChooser.APPROVE_OPTION) {
+ File file = fileChooser.getSelectedFile();
+
+ try {
+ runnable.execute(file.getAbsolutePath());
+ } catch (Exception ex) {
+ JOptionPane.showMessageDialog(parent,
+ "Error loading file: " + ex.getMessage(),
+ "Error", JOptionPane.ERROR_MESSAGE);
+ }
+ }
+ }
+
+ private JPanel createVisualizationPanel() {
+ JPanel panel = new JPanel();
+ panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));
+ panel.setBorder(new TitledBorder("Visualization"));
+ panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, 110));
+
+ showVelocityCheckBox = new JCheckBox("Show Velocity (Color-coded)", params.isVelocityView());
+ showVelocityCheckBox.setAlignmentX(Component.CENTER_ALIGNMENT);
+ showVelocityCheckBox.addActionListener(e -> {
+ params.setVelocityView(showVelocityCheckBox.isSelected());
+ });
+
+ JLabel hintLabel = new JLabel("(Blue=Low, Red=High)");
+ hintLabel.setFont(new Font("Arial", Font.ITALIC, 10));
+ hintLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
+ hintLabel.setForeground(Color.GRAY);
+
+ showArrowsCheckBox = new JCheckBox("Show Direction Arrows", params.isShowArrows());
+ showArrowsCheckBox.setAlignmentX(Component.CENTER_ALIGNMENT);
+ showArrowsCheckBox.addActionListener(e -> {
+ params.setShowArrows(showArrowsCheckBox.isSelected());
+ });
+
+ panel.add(Box.createVerticalStrut(5));
+ panel.add(showVelocityCheckBox);
+ panel.add(Box.createVerticalStrut(3));
+ panel.add(hintLabel);
+ panel.add(Box.createVerticalStrut(8));
+ panel.add(showArrowsCheckBox);
+ panel.add(Box.createVerticalStrut(5));
+
+ return panel;
+ }
+
+ private JPanel createIterationsPanel() {
+ JPanel panel = new JPanel();
+ panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));
+ panel.setBorder(new TitledBorder("Iterations"));
+ panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, 100));
+
+ iterationsValueLabel = new JLabel(String.valueOf(params.getIterations()));
+ iterationsValueLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
+ iterationsValueLabel.setFont(new Font("Monospaced", Font.BOLD, 14));
+
+ iterationsSlider = new JSlider(1, 100, params.getIterations());
+ iterationsSlider.setMajorTickSpacing(20);
+ iterationsSlider.setMinorTickSpacing(5);
+ iterationsSlider.setPaintTicks(true);
+ iterationsSlider.addChangeListener(e -> {
+ int value = iterationsSlider.getValue();
+ params.setIterations(value);
+ iterationsValueLabel.setText(String.valueOf(value));
+ });
+
+ panel.add(iterationsValueLabel);
+ panel.add(iterationsSlider);
+
+ return panel;
+ }
+
+ private JPanel createViscosityPanel() {
+ JPanel panel = new JPanel();
+ panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));
+ panel.setBorder(new TitledBorder("Viscosity"));
+ panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, 100));
+
+ viscosityValueLabel = new JLabel(String.format("%.3f", params.getViscosity()));
+ viscosityValueLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
+ viscosityValueLabel.setFont(new Font("Monospaced", Font.BOLD, 14));
+
+ // Scale: 0.0 to 2.0, with 0.001 precision (0-10000)
+ viscositySlider = new JSlider(0, 10000, (int) (params.getViscosity() * 10000));
+ viscositySlider.setMajorTickSpacing(5000);
+ viscositySlider.setMinorTickSpacing(1000);
+ viscositySlider.setPaintTicks(true);
+ viscositySlider.addChangeListener(e -> {
+ float value = viscositySlider.getValue() / 10000.0f;
+ params.setViscosity(value);
+ viscosityValueLabel.setText(String.format("%.3f", value));
+ });
+
+ panel.add(viscosityValueLabel);
+ panel.add(viscositySlider);
+
+ return panel;
+ }
+
+ private JPanel createDiffusionPanel() {
+ JPanel panel = new JPanel();
+ panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));
+ panel.setBorder(new TitledBorder("Diffusion"));
+ panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, 100));
+
+ diffusionValueLabel = new JLabel(String.format("%.5f", params.getDiffusion()));
+ diffusionValueLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
+ diffusionValueLabel.setFont(new Font("Monospaced", Font.BOLD, 14));
+
+ // Scale: 0.0 to 0.1, with 0.0001 precision (0-10000)
+ diffusionSlider = new JSlider(0, 10000, (int) (params.getDiffusion() * 10000));
+ diffusionSlider.setMajorTickSpacing(2500);
+ diffusionSlider.setMinorTickSpacing(500);
+ diffusionSlider.setPaintTicks(true);
+ diffusionSlider.addChangeListener(e -> {
+ float value = diffusionSlider.getValue() / 10000.0f;
+ params.setDiffusion(value);
+ diffusionValueLabel.setText(String.format("%.5f", value));
+ });
+
+ panel.add(diffusionValueLabel);
+ panel.add(diffusionSlider);
+
+ return panel;
+ }
+
+ private JPanel createVorticityPanel() {
+ JPanel panel = new JPanel();
+ panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));
+ panel.setBorder(new TitledBorder("Vorticity"));
+ panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, 100));
+
+ vorticityValueLabel = new JLabel(String.format("%.1f", params.getVorticity()));
+ vorticityValueLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
+ vorticityValueLabel.setFont(new Font("Monospaced", Font.BOLD, 14));
+
+ // Scale: 0.0 to 200.0, with 0.1 precision (0-2000)
+ vorticitySlider = new JSlider(0, 2000, (int) (params.getVorticity() * 10));
+ vorticitySlider.setMajorTickSpacing(500);
+ vorticitySlider.setMinorTickSpacing(100);
+ vorticitySlider.setPaintTicks(true);
+ vorticitySlider.addChangeListener(e -> {
+ float value = vorticitySlider.getValue() / 10.0f;
+ params.setVorticity(value);
+ vorticityValueLabel.setText(String.format("%.1f", value));
+ });
+
+ panel.add(vorticityValueLabel);
+ panel.add(vorticitySlider);
+
+ return panel;
+ }
+
+ private JPanel createDissipationPanel() {
+ JPanel panel = new JPanel();
+ panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));
+ panel.setBorder(new TitledBorder("Dissipation"));
+ panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, 100));
+
+ dissipationValueLabel = new JLabel(String.format("%.4f", params.getDissipation()));
+ dissipationValueLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
+ dissipationValueLabel.setFont(new Font("Monospaced", Font.BOLD, 14));
+
+ // Scale: 0.900 to 1.000, with 0.0001 precision (9000-10000)
+ dissipationSlider = new JSlider(9000, 10000, (int) (params.getDissipation() * 10000));
+ dissipationSlider.setMajorTickSpacing(250);
+ dissipationSlider.setMinorTickSpacing(50);
+ dissipationSlider.setPaintTicks(true);
+ dissipationSlider.addChangeListener(e -> {
+ float value = dissipationSlider.getValue() / 10000.0f;
+ params.setDissipation(value);
+ dissipationValueLabel.setText(String.format("%.4f", value));
+ });
+
+ panel.add(dissipationValueLabel);
+ panel.add(dissipationSlider);
+
+ return panel;
+ }
+
+ private JPanel createInteractionRadiusPanel() {
+ JPanel panel = new JPanel();
+ panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));
+ panel.setBorder(new TitledBorder("Interaction Radius"));
+ panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, 100));
+
+ interactionRadiusValueLabel = new JLabel(String.format("%.0f", params.getInteractionRadius()));
+ interactionRadiusValueLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
+ interactionRadiusValueLabel.setFont(new Font("Monospaced", Font.BOLD, 14));
+
+ // Scale: 10 to 500
+ interactionRadiusSlider = new JSlider(10, 500, (int) params.getInteractionRadius());
+ interactionRadiusSlider.setMajorTickSpacing(100);
+ interactionRadiusSlider.setMinorTickSpacing(50);
+ interactionRadiusSlider.setPaintTicks(true);
+ interactionRadiusSlider.addChangeListener(e -> {
+ float value = interactionRadiusSlider.getValue();
+ params.setInteractionRadius(value);
+ interactionRadiusValueLabel.setText(String.format("%.0f", value));
+ });
+
+ panel.add(interactionRadiusValueLabel);
+ panel.add(interactionRadiusSlider);
+
+ return panel;
+ }
+
+ private JPanel createInfoPanel() {
+ JPanel panel = new JPanel();
+
+ panel.setLayout(new GridBagLayout());
+ GridBagConstraints cons = new GridBagConstraints();
+ cons.fill = GridBagConstraints.HORIZONTAL;
+ cons.weightx = 1;
+ cons.gridx = 0;
+
+ panel.setBorder(new TitledBorder("Controls"));
+ panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, 120));
+
+ JLabel info1 = new JLabel("LMB drag: add source velocity");
+ JLabel info2 = new JLabel("Shift + LMB drag: add density and velocity");
+ JLabel info3 = new JLabel("Ctrl + LMB drag: add density and velocity");
+ JLabel info4 = new JLabel("ESC: Exit");
+
+ info1.setFont(new Font("Arial", Font.PLAIN, 12));
+ info2.setFont(new Font("Arial", Font.PLAIN, 12));
+ info3.setFont(new Font("Arial", Font.PLAIN, 12));
+ info4.setFont(new Font("Arial", Font.PLAIN, 12));
+
+ panel.add(Box.createVerticalStrut(5));
+ panel.add(info1, cons);
+ panel.add(info2, cons);
+ panel.add(info3, cons);
+ panel.add(info4, cons);
+ panel.add(Box.createVerticalStrut(5));
+
+ return panel;
+ }
+
+ /**
+ * Updates all UI controls to reflect current parameter values.
+ *
+ * @param params simulation parameters to display
+ */
+ public void updateUI(SimParams params) {
+ // Update sliders
+ iterationsSlider.setValue(params.getIterations());
+ viscositySlider.setValue((int) (params.getViscosity() * 1000));
+ diffusionSlider.setValue((int) (params.getDiffusion() * 10000));
+ vorticitySlider.setValue((int) (params.getVorticity() * 10));
+ dissipationSlider.setValue((int) (params.getDissipation() * 10000));
+ interactionRadiusSlider.setValue((int) params.getInteractionRadius());
+
+ // Update labels
+ iterationsValueLabel.setText(String.valueOf(params.getIterations()));
+ viscosityValueLabel.setText(String.format("%.3f", params.getViscosity()));
+ diffusionValueLabel.setText(String.format("%.5f", params.getDiffusion()));
+ vorticityValueLabel.setText(String.format("%.1f", params.getVorticity()));
+ dissipationValueLabel.setText(String.format("%.4f", params.getDissipation()));
+ interactionRadiusValueLabel.setText(String.format("%.0f", params.getInteractionRadius()));
+
+ }
+
+ private void resetToDefaults() {
+ // Default values
+ params.setIterations(Constants.ITERATIONS);
+ params.setViscosity(Constants.VISCOSITY);
+ params.setDiffusion(Constants.DIFFUSION);
+ params.setVorticity(Constants.VORTICITY);
+ params.setDissipation(Constants.DISSIPATION);
+ params.setInteractionRadius(Constants.INTERACTION_RADIUS);
+ params.setVelocityView(false);
+ params.setShowArrows(false);
+
+ // Update checkbox
+ showVelocityCheckBox.setSelected(false);
+ showArrowsCheckBox.setSelected(false);
+
+ updateUI(params);
+ }
+
+}
diff --git a/src/main/java/dev/fluidsim/ui/SimFrame.java b/src/main/java/dev/fluidsim/ui/SimFrame.java
new file mode 100644
index 0000000..9a9e9d0
--- /dev/null
+++ b/src/main/java/dev/fluidsim/ui/SimFrame.java
@@ -0,0 +1,145 @@
+package dev.fluidsim.ui;
+
+import dev.fluidsim.io.LoadableParams;
+import dev.fluidsim.simulation.Sim;
+
+import javax.swing.*;
+
+import java.awt.*;
+
+/**
+ * Main Swing GUI frame that contains the OpenGL canvas and control panel
+ */
+public class SimFrame extends JFrame {
+ private final SimControlPanel controlPanel;
+ private JLabel frameRateLabel;
+
+ private final Sim simulation;
+ private final LoadableParams params;
+
+ /**
+ * Creates the main GUI frame with control panel and menu bar.
+ *
+ * @param params simulation parameters for control panel
+ * @param simulation simulation instance for interactions
+ * @param canvasWidth OpenGL canvas width (unused in current layout)
+ * @param canvasHeight OpenGL canvas height (unused in current layout)
+ */
+ public SimFrame(LoadableParams params, Sim simulation, int canvasWidth, int canvasHeight) {
+ this.params = params;
+ this.simulation = simulation;
+
+ setTitle("Fluid Simulation - OpenGL GPU");
+ setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
+ setResizable(false);
+ setType(Type.POPUP);
+
+ // Create menu bar
+ createMenuBar();
+
+ // Create control panel
+ controlPanel = new SimControlPanel(params, simulation);
+
+ // Layout
+ Container contentPane = getContentPane();
+ contentPane.setLayout(new BorderLayout());
+
+ // Add control panel to the right
+ contentPane.add(controlPanel, BorderLayout.CENTER);
+
+ pack();
+ setLocationRelativeTo(null); // Center on screen
+ }
+
+ /**
+ * Callback interface for file operations.
+ */
+ public interface Executable {
+ /**
+ * Executes an operation with the selected file path.
+ *
+ * @param path absolute path to file
+ * @throws Exception if operation fails
+ */
+ void execute(String path) throws Exception;
+ }
+
+ private void createMenuBar() {
+ JMenuBar menuBar = new JMenuBar();
+
+ // File menu
+ JMenu fileMenu = new JMenu("File");
+
+ JMenuItem saveItem = new JMenuItem("Save config");
+ saveItem.addActionListener(e -> {
+ SimControlPanel.invokeFilePicker(this, (String path) -> {
+ try {
+ params.save(path);
+ } catch (Exception ex) {
+ ex.printStackTrace();
+ }
+ });
+ });
+
+ JMenuItem loadItem = new JMenuItem("Load config");
+ loadItem.addActionListener(e -> {
+ SimControlPanel.invokeFilePicker(this, (String path) -> {
+ try {
+ params.loadAndSet(path);
+ controlPanel.updateUI(params);
+
+ simulation.deleteAllObjects();
+ params.nozzles.forEach(x -> simulation.addObject(x));
+
+ } catch (Exception ex) {
+ ex.printStackTrace();
+ }
+ });
+ });
+
+ JMenuItem exitItem = new JMenuItem("Exit");
+ exitItem.addActionListener(e -> System.exit(0));
+
+ fileMenu.add(saveItem);
+ fileMenu.add(loadItem);
+ fileMenu.addSeparator();
+ fileMenu.add(exitItem);
+
+ menuBar.add(fileMenu);
+
+ // Objects menu
+ JMenu objectMenu = new JMenu("Objects");
+
+ JMenuItem loadObjectsItem = new JMenuItem("Load bitmap");
+ loadObjectsItem.addActionListener(e -> {
+ SimControlPanel.invokeFilePicker(this, (String path) -> {
+ simulation.loadObjects(path);
+ });
+ });
+
+ JMenuItem clearObjectsItem = new JMenuItem("Clear all");
+ clearObjectsItem.addActionListener(e -> simulation.clearObjects());
+
+ objectMenu.add(loadObjectsItem);
+ objectMenu.add(clearObjectsItem);
+ menuBar.add(objectMenu);
+
+ // Frame rate label
+ frameRateLabel = new JLabel("FPS: 000");
+ frameRateLabel.setFont(new Font("Monospaced", Font.PLAIN, 14));
+
+ menuBar.add(Box.createHorizontalGlue());
+ menuBar.add(frameRateLabel, BorderLayout.EAST);
+
+ setJMenuBar(menuBar);
+ }
+
+ /**
+ * Updates the displayed frame rate in the menu bar.
+ *
+ * @param fps frames per second to display
+ */
+ public void updateFrameRate(int fps) {
+ frameRateLabel.setText(String.format("FPS: %03d", fps));
+ }
+}
diff --git a/src/main/java/dev/fluidsim/ui/Utils.java b/src/main/java/dev/fluidsim/ui/Utils.java
deleted file mode 100644
index 84f8f86..0000000
--- a/src/main/java/dev/fluidsim/ui/Utils.java
+++ /dev/null
@@ -1,36 +0,0 @@
-package dev.fluidsim.ui;
-
-import java.awt.Color;
-
-public class Utils {
- /**
- * Linearly interpolates between two colors.
- *
- * @param c1 The starting color (at fraction = 0).
- * @param c2 The ending color (at fraction = 1).
- * @param fraction A float value between 0.0 and 1.0 representing the
- * interpolation point.
- * @return A new Color object representing the interpolated color.
- */
- public static Color lerp(Color c1, Color c2, float fraction, int alpha) {
- fraction = Math.min(Math.max(fraction, 0.0f), 1.0f);
-
- float r1 = c1.getRed();
- float g1 = c1.getGreen();
- float b1 = c1.getBlue();
-
- float r2 = c2.getRed();
- float g2 = c2.getGreen();
- float b2 = c2.getBlue();
-
- int r = (int) (r1 + (r2 - r1) * fraction);
- int g = (int) (g1 + (g2 - g1) * fraction);
- int b = (int) (b1 + (b2 - b1) * fraction);
-
- return new Color(r, g, b, alpha);
- }
-
- public static Color lerp(Color c1, Color c2, float fraction) {
- return lerp(c1, c2, fraction, 255);
- }
-}
diff --git a/src/main/java/dev/fluidsim/ui/Vector.java b/src/main/java/dev/fluidsim/ui/Vector.java
deleted file mode 100644
index 940c41a..0000000
--- a/src/main/java/dev/fluidsim/ui/Vector.java
+++ /dev/null
@@ -1,199 +0,0 @@
-package dev.fluidsim.ui;
-
-import java.awt.BasicStroke;
-import java.awt.Color;
-import java.awt.Graphics2D;
-import java.awt.geom.Path2D;
-
-import dev.fluidsim.common.Vec;
-
-public class Vector implements Drawable {
- public record Look(float thickness, Color color, int radius, int barbLength, double barbAngle) {
- }
-
- protected static Look DEFAULT_LOOK = new Look(4, Color.GREEN, 5, 6, 45);
-
- private Vec start;
- private Vec end;
-
- private double angle;
- private double length;
-
- private Look look;
- private Color color;
-
- public Vector() {
- start = Vec.NULL();
- end = Vec.NULL();
- }
-
- /**
- * Constructor that takes an angle and length
- *
- * @param x1 The origin X coordinate
- * @param y1 The origin Y coordinate
- * @param x2 The target X coordinate
- * @param y2 The target Y coordinate
- * @param look The display params of the vector
- */
- public Vector(int x1, int y1, int x2, int y2, Look look) {
- start = new Vec(x1, y1);
- end = new Vec(x2, y2);
-
- update(end);
- this.look = look;
- this.color = look.color;
- }
-
- public Vector(int x1, int y1, int x2, int y2) {
- this(x1, y1, x2, y2, DEFAULT_LOOK);
- }
-
- /**
- * Constructor that takes an angle and length
- *
- * @param x1 The origin X coordinate
- * @param y1 The origin Y coordinate
- * @param angle The angle in radians
- * @param length The length of the vector
- * @param look The display params of the vector
- */
- public Vector(int x1, int y1, double angle, double length, Look look) {
- start = new Vec(x1, y1);
- this.angle = angle;
- this.length = length;
-
- update(angle, length);
- this.look = look;
- this.color = look.color;
- }
-
- /**
- * Constructor that takes an angle and length
- *
- * @param x1 The origin X coordinate
- * @param y1 The origin Y coordinate
- * @param angle The angle in radians
- * @param length The length of the vector
- */
- public Vector(int x1, int y1, double angle, double length) {
- this(x1, y1, angle, length, DEFAULT_LOOK);
- }
-
- public double getAngle() {
- return angle;
- }
-
- public double getLength() {
- return length;
- }
-
- public Vec getStart() {
- return start;
- }
-
- public Vec getEnd() {
- return end;
- }
-
- public void setStart(Vec start) {
- this.start = start;
- update(end);
- }
-
- public void setEnd(Vec end) {
- this.end = end;
- update(end);
- }
-
- public void setEnd(int x, int y) {
- this.end = new Vec(x, y);
- update(end);
- }
-
- public void setEndRelative(int x, int y) {
- this.end = new Vec(this.start.x + x, this.start.y + y);
- update(end);
- }
-
- public void setAngle(double angle) {
- this.angle = angle;
- update(angle, length);
- }
-
- public void setLength(double length) {
- this.length = length;
- update(angle, length);
- }
-
- public void setColor(Color c) {
- this.color = c;
- }
-
- // ---
-
- @Override
- public void update(float deltaTime) {
- }
-
- @Override
- public void draw(Graphics2D g, int screenWidth, int screenHeight) {
- g.setStroke(new BasicStroke(look.thickness));
- g.setColor(color);
-
- // g.fillOval(start.x - look.radius, start.y - look.radius, look.radius * 2,
- // look.radius * 2);
-
- // only draw the dot when the vector is null
- if (start.equals(end))
- return;
-
- g.drawLine(start.x, start.y, end.x, end.y);
-
- // double barbAngle = Math.toRadians(look.barbAngle);
-
- // int x1 = (int) (end.x - look.barbLength * Math.cos(angle - barbAngle));
- // int y1 = (int) (end.y - look.barbLength * Math.sin(angle - barbAngle));
-
- // int x2 = (int) (end.x - look.barbLength * Math.cos(angle + barbAngle));
- // int y2 = (int) (end.y - look.barbLength * Math.sin(angle + barbAngle));
-
- // g.drawLine(end.x, end.y, x1, y1);
- // g.drawLine(end.x, end.y, x2, y2);
-
- // ---
-
- // --- 3. Define the arrowhead shape (canonical) ---
- // We define the arrowhead as if it's pointing left (angle = PI)
- // at the origin (0,0). The shaft is along the positive X-axis.
- // The barbs will be at angles (PI - arrowAngleRad) and (PI + arrowAngleRad).
-
- // We'll use simple trigonometry to find the endpoints of the barbs.
- // x = length * cos(angle)
- // y = length * sin(angle)
-
- // Point 1 (top barb)
- int x_base = (int) (-look.barbLength * Math.cos(angle));
- int y_base = (int) (look.barbLength * Math.sin(angle)); // Note: positive y
-
- g.drawLine(end.x, end.y, end.x + x_base, end.y + y_base);
- g.drawLine(end.x, end.y, end.x + x_base, end.y - y_base);
- }
-
- /**
- * Update the vector based on the endpoint
- */
- private void update(Vec end) {
- angle = Math.atan2(end.y - start.y, end.x - start.x);
- length = (int) Math.sqrt(Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2));
- }
-
- /**
- * Update the vector based on angle and length
- */
- private void update(double angle, double length) {
- int x2 = (int) (start.x + length * Math.cos(angle));
- int y2 = (int) (start.y + length * Math.sin(angle));
- end = new Vec(x2, y2);
- }
-}
diff --git a/src/main/java/dev/fluidsim/ui/Vectors.java b/src/main/java/dev/fluidsim/ui/Vectors.java
deleted file mode 100644
index 19c1b6f..0000000
--- a/src/main/java/dev/fluidsim/ui/Vectors.java
+++ /dev/null
@@ -1,65 +0,0 @@
-package dev.fluidsim.ui;
-
-import java.awt.Color;
-import java.awt.Graphics2D;
-
-import dev.fluidsim.simulation.Sim;
-
-public class Vectors implements Drawable {
- private final int cellSize;
- /**
- * Every n-th cell will have a vector rendered
- */
- private final int density;
-
- private Vector[] vecs;
- private Sim sim;
-
- private Vector.Look thinLook = new Vector.Look(2, Color.RED, 2, 2, 55);
-
- public Vectors(Sim sim, int screenWidth, int screenHeight, int density) {
- this.sim = sim;
- this.density = density;
-
- if (screenWidth < screenHeight) {
- this.cellSize = (int) (screenWidth / sim.getCellCountX());
- } else {
- this.cellSize = (int) (screenHeight / sim.getCellCountY());
- }
-
- vecs = new Vector[sim.getSize()];
-
- for (int i = 0; i < sim.getCellCountX(); i += density) {
- for (int j = 0; j < sim.getCellCountY(); j += density) {
- int x = i * cellSize + cellSize / 2;
- int y = j * cellSize + cellSize / 2;
- vecs[sim.at(i, j)] = new Vector(x, y, x, y, thinLook);
- }
- }
- }
-
- @Override
- public void update(float deltaTime) {
- }
-
- /**
- * Draws the grid lines on the canvas.
- */
- @Override
- public void draw(Graphics2D g, int screenWidth, int screenHeight) {
- int i, j, x, y, idx;
-
- for (i = 0; i < sim.getCellCountX(); i += density) {
- for (j = 0; j < sim.getCellCountY(); j += density) {
- idx = sim.at(i, j);
-
- x = Math.min((int) (sim.getVelocityX(i, j) * 100f), this.cellSize);
- y = Math.min((int) (sim.getVelocityY(i, j) * 100f), this.cellSize);
-
- vecs[idx].setEndRelative(x, y);
- // vecs[idx].setColor(Utils.lerp(Color.GREEN, Color.RED, (x + y) / 10f));
- vecs[idx].draw(g, screenWidth, screenHeight);
- }
- }
- }
-}
diff --git a/src/main/resources/schema/simparams.xsd b/src/main/resources/schema/simparams.xsd
new file mode 100644
index 0000000..ff51197
--- /dev/null
+++ b/src/main/resources/schema/simparams.xsd
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/shaders/add_source.frag b/src/main/resources/shaders/add_source.frag
new file mode 100644
index 0000000..7140418
--- /dev/null
+++ b/src/main/resources/shaders/add_source.frag
@@ -0,0 +1,20 @@
+#version 330 core
+
+in vec2 vTexCoord;
+out vec4 FragColor;
+
+uniform sampler2D uTarget;
+uniform sampler2D uSource;
+uniform float uDeltaTime;
+
+void main() {
+ vec2 coord = vTexCoord;
+
+ float target = texture(uTarget, coord).r;
+ float source = texture(uSource, coord).r;
+
+ // Add source scaled by time step
+ float result = target + source * uDeltaTime;
+
+ FragColor = vec4(result, 0.0, 0.0, 1.0);
+}
diff --git a/src/main/resources/shaders/advect.frag b/src/main/resources/shaders/advect.frag
new file mode 100644
index 0000000..1911a8a
--- /dev/null
+++ b/src/main/resources/shaders/advect.frag
@@ -0,0 +1,118 @@
+#version 330 core
+
+in vec2 vTexCoord;
+out vec4 FragColor;
+
+uniform sampler2D uVelocityX;
+uniform sampler2D uVelocityY;
+uniform sampler2D uSource;
+uniform sampler2D uObstacle;
+uniform vec2 uTexelSize;
+uniform float uDeltaTime;
+uniform float uDissipation;
+uniform int uIsDensity; // 1 = density, 0 = velocity
+
+void main() {
+ vec2 coord = vTexCoord;
+
+ // Check if current cell is inside obstacle
+ float obstacle = texture(uObstacle, coord).r;
+ if (obstacle > 0.5) {
+ // Inside wall - set velocity to zero, density to zero
+ FragColor = vec4(0.0);
+ return;
+ }
+
+ // Get velocity at current position
+ float vx = texture(uVelocityX, coord).r;
+ float vy = texture(uVelocityY, coord).r;
+
+ // Trace particle backwards in time
+ vec2 pos = coord - uDeltaTime * vec2(vx, vy) * uTexelSize;
+
+ // Clamp to valid texture coordinates
+ pos = clamp(pos, uTexelSize, vec2(1.0) - uTexelSize);
+
+ // Check if backtraced position hits an obstacle
+ float obstacleAtSource = texture(uObstacle, pos).r;
+
+ if (obstacleAtSource > 0.5) {
+ // The backtraced position is inside a wall
+ if (uIsDensity == 1) {
+ // For density, find alternative sample location
+ // Try reflecting the position across the obstacle boundary
+ vec2 dir = normalize(pos - coord);
+ if (length(dir) < 0.001) {
+ dir = vec2(1.0, 0.0); // fallback direction
+ }
+
+ // Search for nearest free cell
+ vec2 reflectedPos = coord;
+ float bestDist = 10000.0;
+
+ // Check in multiple directions around current cell
+ for (int i = 0; i < 32; i++) {
+ float angle = float(i) * 3.14159 * 1.0 / 32.0; // 22.5 degree increments
+ vec2 offset = vec2(cos(angle), sin(angle)) * uTexelSize * 2.0;
+ vec2 testPos = coord + offset;
+ testPos = clamp(testPos, uTexelSize, vec2(1.0) - uTexelSize);
+
+ float testObstacle = texture(uObstacle, testPos).r;
+ if (testObstacle < 0.5) {
+ float dist = length(testPos - pos);
+ if (dist < bestDist) {
+ bestDist = dist;
+ reflectedPos = testPos;
+ }
+ }
+ }
+
+ // Sample from the best alternative position
+ vec4 result = texture(uSource, reflectedPos);
+ result = result * uDissipation * 0.95; // Small loss on reflection
+ FragColor = max(result, vec4(0.0));
+ } else {
+ // For velocity, implement slip boundary condition
+ // Find the nearest non-obstacle cell by checking neighbors
+ vec2 offsets[4] = vec2[](
+ vec2(uTexelSize.x, 0.0),
+ vec2(-uTexelSize.x, 0.0),
+ vec2(0.0, uTexelSize.y),
+ vec2(0.0, -uTexelSize.y)
+ );
+
+ vec2 slipPos = pos;
+ float minObstacle = 1.0;
+
+ // Find the direction away from the obstacle
+ for (int i = 0; i < 4; i++) {
+ vec2 testPos = pos + offsets[i];
+ testPos = clamp(testPos, uTexelSize, vec2(1.0) - uTexelSize);
+ float testObstacle = texture(uObstacle, testPos).r;
+
+ if (testObstacle < minObstacle) {
+ minObstacle = testObstacle;
+ slipPos = testPos;
+ }
+ }
+
+ // Sample velocity from the slip position
+ vec4 result = texture(uSource, slipPos);
+ result = result * 2.5; // Slight boost - super slippery walls
+ FragColor = result;
+
+ }
+ return;
+ }
+
+ // Bilinear sampling with dissipation
+ vec4 result = texture(uSource, pos);
+ result = result * uDissipation;
+
+ // Clamp to prevent negative density
+ if (uIsDensity == 1) {
+ result = max(result, vec4(0.0));
+ }
+
+ FragColor = result;
+}
diff --git a/src/main/resources/shaders/arrow.frag b/src/main/resources/shaders/arrow.frag
new file mode 100644
index 0000000..6bf2d15
--- /dev/null
+++ b/src/main/resources/shaders/arrow.frag
@@ -0,0 +1,9 @@
+#version 330 core
+
+out vec4 FragColor;
+
+uniform vec3 uColor;
+
+void main() {
+ FragColor = vec4(uColor, 1.0);
+}
diff --git a/src/main/resources/shaders/arrow.vert b/src/main/resources/shaders/arrow.vert
new file mode 100644
index 0000000..676e1d0
--- /dev/null
+++ b/src/main/resources/shaders/arrow.vert
@@ -0,0 +1,73 @@
+#version 330 core
+
+layout(location = 0) in vec2 aPosition; // Arrow template vertex position
+layout(location = 1) in vec2 aInstancePos; // Instance grid position (per instance)
+
+uniform mat4 uProjection;
+uniform sampler2D uVelocityX;
+uniform sampler2D uVelocityY;
+uniform vec2 uTexelSize;
+uniform vec2 uGridSize;
+uniform vec2 uScreenSize;
+
+uniform float uMaxArrowLength;
+uniform float uVelocityScale;
+
+void main() {
+ // aInstancePos is in grid space, convert to texture coordinates
+ vec2 texCoord = aInstancePos * uTexelSize;
+
+ // Sample 5x5 neighborhood and average for smoothness
+ float avgVx = 0.0;
+ float avgVy = 0.0;
+ int samples = 0;
+ int radius = 3;
+
+ for (int dy = -radius; dy <= radius; dy++) {
+ for (int dx = -radius; dx <= radius; dx++) {
+ vec2 offset = vec2(float(dx), float(dy)) * uTexelSize;
+ vec2 sampleCoord = texCoord + offset;
+
+ // Clamp to valid texture coordinates
+ if (sampleCoord.x >= 0.0 && sampleCoord.x <= 1.0 &&
+ sampleCoord.y >= 0.0 && sampleCoord.y <= 1.0) {
+ avgVx += texture(uVelocityX, sampleCoord).r;
+ avgVy += texture(uVelocityY, sampleCoord).r;
+ samples++;
+ }
+ }
+ }
+
+ // Average the velocities
+ float vx = avgVx / float(samples);
+ float vy = avgVy / float(samples);
+
+ // Calculate velocity magnitude and direction
+ float magnitude = sqrt(vx * vx + vy * vy);
+
+ // Skip rendering if velocity is too small (will be clipped)
+ if (magnitude < 0.1) {
+ gl_Position = vec4(-10.0, -10.0, 0.0, 1.0); // Move off-screen
+ return;
+ }
+
+ // Normalize direction and scale length
+ float length = min(magnitude * uVelocityScale, uMaxArrowLength);
+ vec2 dir = vec2(vx, vy) / magnitude;
+
+ // Rotation matrix
+ mat2 rotation = mat2(
+ dir.x, -dir.y,
+ dir.y, dir.x
+ );
+
+ // Transform arrow template vertex
+ vec2 rotatedPos = rotation * aPosition * length;
+
+ // Convert grid position to screen space
+ vec2 screenPos = (aInstancePos / uGridSize) * uScreenSize;
+ screenPos.y = uScreenSize.y - screenPos.y;
+ vec2 finalPos = screenPos + rotatedPos;
+
+ gl_Position = uProjection * vec4(finalPos, 0.0, 1.0);
+}
diff --git a/src/main/resources/shaders/boundary.frag b/src/main/resources/shaders/boundary.frag
new file mode 100644
index 0000000..56fd535
--- /dev/null
+++ b/src/main/resources/shaders/boundary.frag
@@ -0,0 +1,35 @@
+#version 330 core
+
+in vec2 vTexCoord;
+out vec4 FragColor;
+
+uniform sampler2D uSource;
+uniform vec2 uTexelSize;
+uniform int uBoundaryMode; // 0=none, 1=horizontal, 2=vertical
+
+void main() {
+ vec2 coord = vTexCoord;
+ float value = texture(uSource, coord).r;
+
+ // Check if we're at boundary
+ bool isLeftEdge = coord.x < uTexelSize.x * 1.5;
+ bool isRightEdge = coord.x > 1.0 - uTexelSize.x * 1.5;
+ bool isBottomEdge = coord.y < uTexelSize.y * 1.5;
+ bool isTopEdge = coord.y > 1.0 - uTexelSize.y * 1.5;
+
+ if (isLeftEdge) {
+ float innerValue = texture(uSource, coord + vec2(uTexelSize.x, 0.0)).r;
+ value = (uBoundaryMode == 1) ? -innerValue : innerValue;
+ } else if (isRightEdge) {
+ float innerValue = texture(uSource, coord - vec2(uTexelSize.x, 0.0)).r;
+ value = (uBoundaryMode == 1) ? -innerValue : innerValue;
+ } else if (isBottomEdge) {
+ float innerValue = texture(uSource, coord + vec2(0.0, uTexelSize.y)).r;
+ value = (uBoundaryMode == 2) ? -innerValue : innerValue;
+ } else if (isTopEdge) {
+ float innerValue = texture(uSource, coord - vec2(0.0, uTexelSize.y)).r;
+ value = (uBoundaryMode == 2) ? -innerValue : innerValue;
+ }
+
+ FragColor = vec4(value, 0.0, 0.0, 1.0);
+}
diff --git a/src/main/resources/shaders/clear.frag b/src/main/resources/shaders/clear.frag
new file mode 100644
index 0000000..304b868
--- /dev/null
+++ b/src/main/resources/shaders/clear.frag
@@ -0,0 +1,10 @@
+#version 330 core
+
+in vec2 vTexCoord;
+out vec4 FragColor;
+
+uniform vec4 uClearColor;
+
+void main() {
+ FragColor = uClearColor;
+}
diff --git a/src/main/resources/shaders/common.glsl b/src/main/resources/shaders/common.glsl
new file mode 100644
index 0000000..e69de29
diff --git a/src/main/resources/shaders/diffuse.frag b/src/main/resources/shaders/diffuse.frag
new file mode 100644
index 0000000..d46546f
--- /dev/null
+++ b/src/main/resources/shaders/diffuse.frag
@@ -0,0 +1,32 @@
+#version 330 core
+
+in vec2 vTexCoord;
+out vec4 FragColor;
+
+uniform sampler2D uSource;
+uniform sampler2D uOriginal;
+uniform vec2 uTexelSize;
+uniform float uAlpha;
+uniform float uBeta;
+uniform int uIsDensity;
+
+void main() {
+ vec2 coord = vTexCoord;
+
+ // Sample neighbors (Jacobi iteration)
+ float left = texture(uSource, coord - vec2(uTexelSize.x, 0.0)).r;
+ float right = texture(uSource, coord + vec2(uTexelSize.x, 0.0)).r;
+ float bottom = texture(uSource, coord - vec2(0.0, uTexelSize.y)).r;
+ float top = texture(uSource, coord + vec2(0.0, uTexelSize.y)).r;
+
+ float original = texture(uOriginal, coord).r;
+
+ // Jacobi iteration: x_new = (x_original + alpha * neighbors) / beta
+ float result = (original + uAlpha * (left + right + bottom + top)) / uBeta;
+
+ if (uIsDensity == 1) {
+ result = max(result, 0.0);
+ }
+
+ FragColor = vec4(result, 0.0, 0.0, 1.0);
+}
diff --git a/src/main/resources/shaders/display.frag b/src/main/resources/shaders/display.frag
new file mode 100644
index 0000000..4581451
--- /dev/null
+++ b/src/main/resources/shaders/display.frag
@@ -0,0 +1,65 @@
+#version 330 core
+
+in vec2 vTexCoord;
+out vec4 FragColor;
+
+uniform sampler2D uDensity;
+uniform sampler2D uVelocityX;
+uniform sampler2D uVelocityY;
+uniform sampler2D uObstacle;
+uniform float uMinDensity;
+uniform float uMaxDensity;
+uniform int uShowVelocity; // 0 = density, 1 = velocity
+
+// Color gradient for velocity visualization
+vec3 velocityColor(float t) {
+ t = clamp(t, 0.0, 1.0);
+
+ if (t < 0.25) {
+ return vec3(0.0, t / 0.25, 1.0);
+ } else if (t < 0.5) {
+ return vec3(0.0, 1.0, 1.0 - (t - 0.25) / 0.25);
+ } else if (t < 0.75) {
+ return vec3((t - 0.5) / 0.25, 1.0, 0.0);
+ } else {
+ return vec3(1.0, 1.0 - (t - 0.75) / 0.25, 0.0);
+ }
+}
+
+void main() {
+ vec3 color;
+
+ // Check if this pixel is an obstacle
+ float obstacle = texture(uObstacle, vTexCoord).r;
+
+ if (obstacle > 0.5) {
+ // Draw obstacle as dark gray
+ color = vec3(0.2, 0.2, 0.2);
+ } else if (uShowVelocity == 1) {
+ // Velocity visualization
+ float vx = texture(uVelocityX, vTexCoord).r;
+ float vy = texture(uVelocityY, vTexCoord).r;
+
+ // Calculate velocity magnitude
+ float magnitude = sqrt(vx * vx + vy * vy);
+
+ // Normalize to [0, 1] range (adjust max velocity as needed)
+ float maxVelocity = 300.0;
+ float normalized = magnitude / maxVelocity;
+ normalized = clamp(normalized, 0.0, 1.0);
+
+ // Apply color gradient
+ color = velocityColor(normalized);
+ } else {
+ // Density visualization (original grayscale)
+ float density = texture(uDensity, vTexCoord).r;
+
+ // Normalize density to [0, 1] range
+ float normalized = (density - uMinDensity) / (uMaxDensity - uMinDensity + 0.0001);
+ normalized = clamp(normalized, 0.0, 1.0);
+
+ color = vec3(normalized);
+ }
+
+ FragColor = vec4(color, 1.0);
+}
diff --git a/src/main/resources/shaders/divergence.frag b/src/main/resources/shaders/divergence.frag
new file mode 100644
index 0000000..9ab2d8c
--- /dev/null
+++ b/src/main/resources/shaders/divergence.frag
@@ -0,0 +1,42 @@
+#version 330 core
+
+in vec2 vTexCoord;
+out vec4 FragColor;
+
+uniform sampler2D uVelocityX;
+uniform sampler2D uVelocityY;
+uniform sampler2D uObstacle;
+uniform vec2 uTexelSize;
+
+void main() {
+ vec2 coord = vTexCoord;
+
+ // Check obstacle at neighboring cells
+ float obstacleC = texture(uObstacle, coord).r;
+ float obstacleL = texture(uObstacle, coord - vec2(uTexelSize.x, 0.0)).r;
+ float obstacleR = texture(uObstacle, coord + vec2(uTexelSize.x, 0.0)).r;
+ float obstacleB = texture(uObstacle, coord - vec2(0.0, uTexelSize.y)).r;
+ float obstacleT = texture(uObstacle, coord + vec2(0.0, uTexelSize.y)).r;
+
+ // Sample velocity field
+ float vxRight = texture(uVelocityX, coord + vec2(uTexelSize.x, 0.0)).r;
+ float vxLeft = texture(uVelocityX, coord - vec2(uTexelSize.x, 0.0)).r;
+ float vyTop = texture(uVelocityY, coord + vec2(0.0, uTexelSize.y)).r;
+ float vyBottom = texture(uVelocityY, coord - vec2(0.0, uTexelSize.y)).r;
+
+ // Treat obstacle boundaries as solid walls (set velocity to zero)
+ if (obstacleR > 0.5) vxRight = 0.0;
+ if (obstacleL > 0.5) vxLeft = 0.0;
+ if (obstacleT > 0.5) vyTop = 0.0;
+ if (obstacleB > 0.5) vyBottom = 0.0;
+
+ // Calculate divergence: ∇·v = ∂vx/∂x + ∂vy/∂y
+ float divergence = -0.5 * ((vxRight - vxLeft) + (vyTop - vyBottom));
+
+ // Zero divergence inside obstacles
+ if (obstacleC > 0.5) {
+ divergence = 0.0;
+ }
+
+ FragColor = vec4(divergence, 0.0, 0.0, 1.0);
+}
diff --git a/src/main/resources/shaders/gradient.frag b/src/main/resources/shaders/gradient.frag
new file mode 100644
index 0000000..b05e4ce
--- /dev/null
+++ b/src/main/resources/shaders/gradient.frag
@@ -0,0 +1,60 @@
+#version 330 core
+
+in vec2 vTexCoord;
+out vec4 FragColor;
+
+uniform sampler2D uVelocity;
+uniform sampler2D uPressure;
+uniform sampler2D uObstacle;
+uniform vec2 uTexelSize;
+uniform int uIsXComponent;
+
+void main() {
+ vec2 coord = vTexCoord;
+
+ float obstacleC = texture(uObstacle, coord).r;
+
+ // Inside obstacle, velocity is zero
+ if (obstacleC > 0.5) {
+ FragColor = vec4(0.0);
+ return;
+ }
+
+ // Check for obstacles in neighboring cells
+ float obstacleRight = texture(uObstacle, coord + vec2(uTexelSize.x, 0.0)).r;
+ float obstacleLeft = texture(uObstacle, coord - vec2(uTexelSize.x, 0.0)).r;
+ float obstacleTop = texture(uObstacle, coord + vec2(0.0, uTexelSize.y)).r;
+ float obstacleBottom = texture(uObstacle, coord - vec2(0.0, uTexelSize.y)).r;
+
+ // Sample pressure gradient
+ float pressureRight = texture(uPressure, coord + vec2(uTexelSize.x, 0.0)).r;
+ float pressureLeft = texture(uPressure, coord - vec2(uTexelSize.x, 0.0)).r;
+ float pressureTop = texture(uPressure, coord + vec2(0.0, uTexelSize.y)).r;
+ float pressureBottom = texture(uPressure, coord - vec2(0.0, uTexelSize.y)).r;
+
+ // If neighbor is obstacle, use center pressure (free-slip condition)
+ if (obstacleRight > 0.5) pressureRight = texture(uPressure, coord).r;
+ if (obstacleLeft > 0.5) pressureLeft = texture(uPressure, coord).r;
+ if (obstacleTop > 0.5) pressureTop = texture(uPressure, coord).r;
+ if (obstacleBottom > 0.5) pressureBottom = texture(uPressure, coord).r;
+
+ float velocity = texture(uVelocity, coord).r;
+
+ // Subtract pressure gradient from velocity
+ float gradient;
+ if (uIsXComponent == 1) {
+ gradient = 0.5 * (pressureRight - pressureLeft);
+ if (obstacleRight > 0.5 || obstacleLeft > 0.5) {
+ gradient = 0.0;
+ }
+ } else {
+ gradient = 0.5 * (pressureTop - pressureBottom);
+ if (obstacleTop > 0.5 || obstacleBottom > 0.5) {
+ gradient = 0.0;
+ }
+ }
+
+ float result = velocity - gradient;
+
+ FragColor = vec4(result, 0.0, 0.0, 1.0);
+}
diff --git a/src/main/resources/shaders/pressure.frag b/src/main/resources/shaders/pressure.frag
new file mode 100644
index 0000000..de44e7d
--- /dev/null
+++ b/src/main/resources/shaders/pressure.frag
@@ -0,0 +1,47 @@
+#version 330 core
+
+in vec2 vTexCoord;
+out vec4 FragColor;
+
+uniform sampler2D uPressure;
+uniform sampler2D uDivergence;
+uniform sampler2D uObstacle;
+uniform vec2 uTexelSize;
+
+void main() {
+ vec2 coord = vTexCoord;
+
+ float obstacleC = texture(uObstacle, coord).r;
+
+ // Inside obstacle, pressure is zero
+ if (obstacleC > 0.5) {
+ FragColor = vec4(0.0);
+ return;
+ }
+
+ // Check neighbors for obstacles
+ float obstacleL = texture(uObstacle, coord - vec2(uTexelSize.x, 0.0)).r;
+ float obstacleR = texture(uObstacle, coord + vec2(uTexelSize.x, 0.0)).r;
+ float obstacleB = texture(uObstacle, coord - vec2(0.0, uTexelSize.y)).r;
+ float obstacleT = texture(uObstacle, coord + vec2(0.0, uTexelSize.y)).r;
+
+ // Sample neighboring pressure values
+ float left = texture(uPressure, coord - vec2(uTexelSize.x, 0.0)).r;
+ float right = texture(uPressure, coord + vec2(uTexelSize.x, 0.0)).r;
+ float bottom = texture(uPressure, coord - vec2(0.0, uTexelSize.y)).r;
+ float top = texture(uPressure, coord + vec2(0.0, uTexelSize.y)).r;
+ float center = texture(uPressure, coord).r;
+
+ // Use center pressure for obstacle neighbors (Neumann boundary condition)
+ if (obstacleL > 0.5) left = center;
+ if (obstacleR > 0.5) right = center;
+ if (obstacleB > 0.5) bottom = center;
+ if (obstacleT > 0.5) top = center;
+
+ float divergence = texture(uDivergence, coord).r;
+
+ // Jacobi iteration for Poisson equation
+ float result = (divergence + left + right + bottom + top) * 0.25;
+
+ FragColor = vec4(result, 0.0, 0.0, 1.0);
+}
diff --git a/src/main/resources/shaders/quad.vert b/src/main/resources/shaders/quad.vert
new file mode 100644
index 0000000..b8c9f38
--- /dev/null
+++ b/src/main/resources/shaders/quad.vert
@@ -0,0 +1,11 @@
+#version 330 core
+
+layout (location = 0) in vec2 aPosition;
+layout (location = 1) in vec2 aTexCoord;
+
+out vec2 vTexCoord;
+
+void main() {
+ vTexCoord = aTexCoord;
+ gl_Position = vec4(aPosition, 0.0, 1.0);
+}
diff --git a/src/main/resources/shaders/splat.frag b/src/main/resources/shaders/splat.frag
new file mode 100644
index 0000000..84e03c0
--- /dev/null
+++ b/src/main/resources/shaders/splat.frag
@@ -0,0 +1,26 @@
+#version 330 core
+
+in vec2 vTexCoord;
+out vec4 FragColor;
+
+uniform sampler2D uTarget;
+uniform vec2 uPoint;
+uniform float uRadius;
+uniform float uValue;
+uniform vec2 uGridSize;
+
+void main() {
+ vec2 coord = vTexCoord;
+ float current = texture(uTarget, coord).r;
+
+ // Calculate distance from splat point
+ vec2 pixelCoord = coord * uGridSize;
+ vec2 pointCoord = uPoint;
+ float dist = length(pixelCoord - pointCoord);
+
+ // Gaussian falloff
+ float splat = exp(-dist * dist / uRadius);
+ float result = current + uValue * splat;
+
+ FragColor = vec4(result, 0.0, 0.0, 1.0);
+}
diff --git a/src/main/resources/shaders/vorticity.frag b/src/main/resources/shaders/vorticity.frag
new file mode 100644
index 0000000..df5d384
--- /dev/null
+++ b/src/main/resources/shaders/vorticity.frag
@@ -0,0 +1,23 @@
+#version 330 core
+
+in vec2 vTexCoord;
+out vec4 FragColor;
+
+uniform sampler2D uVelocityX;
+uniform sampler2D uVelocityY;
+uniform vec2 uTexelSize;
+
+void main() {
+ vec2 coord = vTexCoord;
+
+ // Sample velocity field for curl calculation
+ float vxTop = texture(uVelocityX, coord + vec2(0.0, uTexelSize.y)).r;
+ float vxBottom = texture(uVelocityX, coord - vec2(0.0, uTexelSize.y)).r;
+ float vyRight = texture(uVelocityY, coord + vec2(uTexelSize.x, 0.0)).r;
+ float vyLeft = texture(uVelocityY, coord - vec2(uTexelSize.x, 0.0)).r;
+
+ // Calculate curl (vorticity): ω = ∂vy/∂x - ∂vx/∂y
+ float curl = (vyRight - vyLeft) - (vxTop - vxBottom);
+
+ FragColor = vec4(curl, 0.0, 0.0, 1.0);
+}
diff --git a/src/main/resources/shaders/vorticity_force.frag b/src/main/resources/shaders/vorticity_force.frag
new file mode 100644
index 0000000..9064bf7
--- /dev/null
+++ b/src/main/resources/shaders/vorticity_force.frag
@@ -0,0 +1,41 @@
+#version 330 core
+
+in vec2 vTexCoord;
+out vec4 FragColor;
+
+uniform sampler2D uVelocity;
+uniform sampler2D uCurl;
+uniform vec2 uTexelSize;
+uniform float uVorticityScale;
+uniform float uDeltaTime;
+uniform int uIsXComponent;
+
+void main() {
+ vec2 coord = vTexCoord;
+
+ // Sample curl values
+ float curlLeft = abs(texture(uCurl, coord - vec2(uTexelSize.x, 0.0)).r);
+ float curlRight = abs(texture(uCurl, coord + vec2(uTexelSize.x, 0.0)).r);
+ float curlBottom = abs(texture(uCurl, coord - vec2(0.0, uTexelSize.y)).r);
+ float curlTop = abs(texture(uCurl, coord + vec2(0.0, uTexelSize.y)).r);
+ float curlCenter = texture(uCurl, coord).r;
+
+ // Calculate vorticity gradient
+ vec2 vorticityGradient = vec2(curlRight - curlLeft, curlTop - curlBottom);
+ float len = length(vorticityGradient) + 0.00001;
+ vorticityGradient = vorticityGradient / len;
+
+ float velocity = texture(uVelocity, coord).r;
+
+ // Apply vorticity confinement force
+ float force;
+ if (uIsXComponent == 1) {
+ force = uVorticityScale * uDeltaTime * curlCenter * vorticityGradient.y;
+ } else {
+ force = uVorticityScale * uDeltaTime * curlCenter * -vorticityGradient.x;
+ }
+
+ float result = velocity + force;
+
+ FragColor = vec4(result, 0.0, 0.0, 1.0);
+}
diff --git a/src/test/java/dev/fluidsim/LoadableParamsTest.java b/src/test/java/dev/fluidsim/LoadableParamsTest.java
new file mode 100644
index 0000000..8246087
--- /dev/null
+++ b/src/test/java/dev/fluidsim/LoadableParamsTest.java
@@ -0,0 +1,266 @@
+package dev.fluidsim;
+
+import dev.fluidsim.io.LoadableParams;
+import dev.fluidsim.simulation.Nozzle;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.xml.sax.SAXException;
+
+import javax.xml.parsers.ParserConfigurationException;
+import java.awt.Color;
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Test cases for LoadableParams XML serialization and deserialization
+ */
+public class LoadableParamsTest {
+
+ @TempDir
+ Path tempDir;
+
+ /**
+ * Test 1: Successfully load a valid XML file with all parameters
+ */
+ @Test
+ public void testLoadValidXMLFile() throws Exception {
+ // Create a valid XML file
+ Path xmlFile = tempDir.resolve("valid_params.xml");
+ String validXml = """
+
+
+
+ 25
+ 0.005
+ 0.03
+ 15.0
+ 0.95
+ 150.0
+ true
+ true
+
+
+ 800
+ 600
+
+
+
+
+
+
+
+ """;
+ Files.writeString(xmlFile, validXml);
+
+ // Load and verify
+ LoadableParams params = LoadableParams.load(xmlFile.toString());
+
+ // Verify simulation parameters
+ assertEquals(25, params.iterations);
+ assertEquals(0.005f, params.viscosity, 0.0001f);
+ assertEquals(0.03f, params.diffusion, 0.0001f);
+ assertEquals(15.0f, params.vorticity, 0.0001f);
+ assertEquals(0.95f, params.dissipation, 0.0001f);
+ assertEquals(150.0f, params.interactionRadius, 0.0001f);
+
+ // Verify grid parameters
+ assertEquals(800, params.width);
+ assertEquals(600, params.height);
+
+ // Verify arrow parameters
+ assertEquals(25.0f, params.arrowDensity, 0.0001f);
+ assertEquals(35.0f, params.maxArrowLength, 0.0001f);
+ assertEquals(6.0f, params.arrowHeadLength, 0.0001f);
+ assertEquals(0.6f, params.arrowHeadAngle, 0.0001f);
+ assertEquals(1.5f, params.velocityScale, 0.0001f);
+ assertEquals(new Color(200, 220, 255, 230), params.arrowColor);
+
+ // Verify nozzles
+ assertEquals(2, params.nozzles.size());
+ Nozzle nozzle1 = params.nozzles.get(0);
+ assertEquals(Nozzle.Side.LEFT, nozzle1.getSide());
+ assertEquals(0.5f, nozzle1.getPosition(), 0.0001f);
+ assertEquals(20.0f, nozzle1.getRadius(), 0.0001f);
+ }
+
+ /**
+ * Test 2: IOException when file doesn't exist
+ */
+ @Test
+ public void testLoadNonExistentFile() {
+ String nonExistentPath = tempDir.resolve("nonexistent.xml").toString();
+
+ assertThrows(IOException.class, () -> {
+ LoadableParams.load(nonExistentPath);
+ });
+ }
+
+ /**
+ * Test 3: SAXException when XML is malformed
+ */
+ @Test
+ public void testLoadMalformedXML() throws IOException {
+ Path xmlFile = tempDir.resolve("malformed.xml");
+ String malformedXml = """
+
+
+
+ 25
+ 0.005
+
+
+ """;
+ Files.writeString(xmlFile, malformedXml);
+
+ assertThrows(SAXException.class, () -> {
+ LoadableParams.load(xmlFile.toString());
+ });
+ }
+
+ /**
+ * Test 4: NumberFormatException when numeric values are invalid
+ */
+ @Test
+ public void testLoadInvalidNumericValues() throws IOException {
+ Path xmlFile = tempDir.resolve("invalid_numbers.xml");
+ String invalidXml = """
+
+
+
+ not_a_number
+ 0.005
+ 0.03
+ 15.0
+ 0.95
+ 150.0
+ true
+ true
+
+
+ 800
+ 600
+
+
+
+ """;
+ Files.writeString(xmlFile, invalidXml);
+
+ assertThrows(NumberFormatException.class, () -> {
+ LoadableParams.load(xmlFile.toString());
+ });
+ }
+
+ /**
+ * Test 5: Successfully save and reload parameters
+ */
+ @Test
+ public void testSaveAndReloadParameters() throws Exception {
+ // Create params with test data
+ LoadableParams original = new LoadableParams();
+ original.iterations = 30;
+ original.viscosity = 0.008f;
+ original.diffusion = 0.04f;
+ original.vorticity = 18.0f;
+ original.dissipation = 0.92f;
+ original.interactionRadius = 175.0f;
+ original.width = 1024;
+ original.height = 768;
+ original.arrowDensity = 22.0f;
+ original.maxArrowLength = 32.0f;
+ original.arrowHeadLength = 5.5f;
+ original.arrowHeadAngle = 0.55f;
+ original.velocityScale = 1.2f;
+ original.arrowColor = new Color(180, 200, 255, 200);
+
+ // Add nozzles
+ original.nozzles.add(new Nozzle(
+ Nozzle.Side.RIGHT,
+ 0.6f,
+ 25.0f,
+ 120.0f,
+ 180.0f,
+ 180000.0f,
+ 220.0f));
+
+ // Save to file
+ Path xmlFile = tempDir.resolve("saved_params.xml");
+ original.save(xmlFile.toString());
+
+ // Verify file was created
+ assertTrue(Files.exists(xmlFile));
+
+ // Load back
+ LoadableParams loaded = LoadableParams.load(xmlFile.toString());
+
+ // Verify all values match
+ assertEquals(original.iterations, loaded.iterations);
+ assertEquals(original.viscosity, loaded.viscosity, 0.0001f);
+ assertEquals(original.diffusion, loaded.diffusion, 0.0001f);
+ assertEquals(original.vorticity, loaded.vorticity, 0.0001f);
+ assertEquals(original.dissipation, loaded.dissipation, 0.0001f);
+ assertEquals(original.interactionRadius, loaded.interactionRadius, 0.0001f);
+ assertEquals(original.width, loaded.width);
+ assertEquals(original.height, loaded.height);
+ assertEquals(original.arrowDensity, loaded.arrowDensity, 0.0001f);
+ assertEquals(original.arrowColor, loaded.arrowColor);
+ assertEquals(original.nozzles.size(), loaded.nozzles.size());
+ }
+
+ /**
+ * Test 6: loadAndSet method updates existing instance
+ */
+ @Test
+ public void testLoadAndSetUpdatesInstance() throws Exception {
+ // Create XML file with specific values
+ Path xmlFile = tempDir.resolve("update_params.xml");
+ String xml = """
+
+
+
+ 42
+ 0.007
+ 0.035
+ 17.5
+ 0.93
+ 160.0
+ false
+ false
+
+
+ 1280
+ 720
+
+
+
+
+
+
+ """;
+ Files.writeString(xmlFile, xml);
+
+ // Create instance with different initial values
+ LoadableParams params = new LoadableParams();
+ params.iterations = 10;
+ params.viscosity = 0.001f;
+ params.arrowDensity = 5.0f;
+
+ // Load and set from file
+ params.loadAndSet(xmlFile.toString());
+
+ // Verify values were updated
+ assertEquals(42, params.iterations);
+ assertEquals(0.007f, params.viscosity, 0.0001f);
+ assertEquals(0.035f, params.diffusion, 0.0001f);
+ assertEquals(17.5f, params.vorticity, 0.0001f);
+ assertEquals(28.0f, params.arrowDensity, 0.0001f);
+ assertEquals(38.0f, params.maxArrowLength, 0.0001f);
+ assertEquals(new Color(255, 200, 180, 240), params.arrowColor);
+ assertEquals(1, params.nozzles.size());
+ assertEquals(Nozzle.Side.TOP, params.nozzles.get(0).getSide());
+ }
+}
diff --git a/src/test/java/dev/fluidsim/NozzleTest.java b/src/test/java/dev/fluidsim/NozzleTest.java
new file mode 100644
index 0000000..323160b
--- /dev/null
+++ b/src/test/java/dev/fluidsim/NozzleTest.java
@@ -0,0 +1,124 @@
+package dev.fluidsim;
+
+import dev.fluidsim.simulation.Nozzle;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Test cases for Nozzle class
+ */
+public class NozzleTest {
+
+ /**
+ * Test 1: Constructor properly calculates force components from direction angle
+ */
+ @Test
+ public void testConstructorCalculatesForceComponents() {
+ // Test 0 degrees (pointing right)
+ Nozzle nozzle0 = new Nozzle(Nozzle.Side.LEFT, 0.5f, 20.0f, 100.0f, 0.0f, 1000.0f, 250.0f);
+ assertEquals(1000.0f, nozzle0.getForceX(), 0.1f);
+ assertEquals(0.0f, nozzle0.getForceY(), 0.1f);
+
+ // Test 90 degrees (pointing up)
+ Nozzle nozzle90 = new Nozzle(Nozzle.Side.BOTTOM, 0.5f, 20.0f, 100.0f, 90.0f, 1000.0f, 250.0f);
+ assertEquals(0.0f, nozzle90.getForceX(), 0.1f);
+ assertEquals(1000.0f, nozzle90.getForceY(), 0.1f);
+
+ // Test 180 degrees (pointing left)
+ Nozzle nozzle180 = new Nozzle(Nozzle.Side.RIGHT, 0.5f, 20.0f, 100.0f, 180.0f, 1000.0f, 250.0f);
+ assertEquals(-1000.0f, nozzle180.getForceX(), 0.1f);
+ assertEquals(0.0f, nozzle180.getForceY(), 0.1f);
+
+ // Test 270 degrees (pointing down)
+ Nozzle nozzle270 = new Nozzle(Nozzle.Side.TOP, 0.5f, 20.0f, 100.0f, 270.0f, 1000.0f, 250.0f);
+ assertEquals(0.0f, nozzle270.getForceX(), 0.1f);
+ assertEquals(-1000.0f, nozzle270.getForceY(), 0.1f);
+ }
+
+ /**
+ * Test 2: Constructor clamps position to valid range [0.0, 1.0]
+ */
+ @Test
+ public void testConstructorClampsPosition() {
+ // Test position above 1.0 is clamped
+ Nozzle nozzleHigh = new Nozzle(Nozzle.Side.LEFT, 1.5f, 20.0f, 100.0f, 0.0f, 1000.0f, 250.0f);
+ assertEquals(1.0f, nozzleHigh.getPosition(), 0.0001f);
+
+ // Test position below 0.0 is clamped
+ Nozzle nozzleLow = new Nozzle(Nozzle.Side.LEFT, -0.5f, 20.0f, 100.0f, 0.0f, 1000.0f, 250.0f);
+ assertEquals(0.0f, nozzleLow.getPosition(), 0.0001f);
+
+ // Test valid position is preserved
+ Nozzle nozzleValid = new Nozzle(Nozzle.Side.LEFT, 0.75f, 20.0f, 100.0f, 0.0f, 1000.0f, 250.0f);
+ assertEquals(0.75f, nozzleValid.getPosition(), 0.0001f);
+ }
+
+ /**
+ * Test 3: Initialize method calculates correct world position for LEFT side
+ */
+ @Test
+ public void testInitializeLeftSide() {
+ Nozzle nozzle = new Nozzle(Nozzle.Side.LEFT, 0.5f, 20.0f, 100.0f, 0.0f, 1000.0f, 250.0f);
+ nozzle.initialize(1000, 800);
+
+ assertEquals(0.0f, nozzle.getWorldX(), 0.0001f);
+ assertEquals(400.0f, nozzle.getWorldY(), 0.0001f); // 0.5 * 800
+ }
+
+ /**
+ * Test 4: Initialize method calculates correct world position for RIGHT side
+ */
+ @Test
+ public void testInitializeRightSide() {
+ Nozzle nozzle = new Nozzle(Nozzle.Side.RIGHT, 0.25f, 20.0f, 100.0f, 180.0f, 1000.0f, 250.0f);
+ nozzle.initialize(1000, 800);
+
+ assertEquals(1000.0f, nozzle.getWorldX(), 0.0001f);
+ assertEquals(200.0f, nozzle.getWorldY(), 0.0001f); // 0.25 * 800
+ }
+
+ /**
+ * Test 5: Initialize method calculates correct world position for TOP side
+ */
+ @Test
+ public void testInitializeTopSide() {
+ Nozzle nozzle = new Nozzle(Nozzle.Side.TOP, 0.75f, 20.0f, 100.0f, 270.0f, 1000.0f, 250.0f);
+ nozzle.initialize(1000, 800);
+
+ assertEquals(750.0f, nozzle.getWorldX(), 0.0001f); // 0.75 * 1000
+ assertEquals(800.0f, nozzle.getWorldY(), 0.0001f);
+ }
+
+ /**
+ * Test 6: Initialize method calculates correct world position for BOTTOM side
+ */
+ @Test
+ public void testInitializeBottomSide() {
+ Nozzle nozzle = new Nozzle(Nozzle.Side.BOTTOM, 0.3f, 20.0f, 100.0f, 90.0f, 1000.0f, 250.0f);
+ nozzle.initialize(1000, 800);
+
+ assertEquals(300.0f, nozzle.getWorldX(), 0.0001f); // 0.3 * 1000
+ assertEquals(0.0f, nozzle.getWorldY(), 0.0001f);
+ }
+
+ /**
+ * Test 7: All getters return correct values
+ */
+ @Test
+ public void testGetters() {
+ Nozzle nozzle = new Nozzle(Nozzle.Side.LEFT, 0.6f, 25.0f, 150.0f, 45.0f, 2000.0f, 300.0f);
+
+ assertEquals(Nozzle.Side.LEFT, nozzle.getSide());
+ assertEquals(0.6f, nozzle.getPosition(), 0.0001f);
+ assertEquals(25.0f, nozzle.getRadius(), 0.0001f);
+ assertEquals(150.0f, nozzle.getLength(), 0.0001f);
+ assertEquals(2000.0f, nozzle.getForceMagnitude(), 0.0001f);
+ assertEquals(300.0f, nozzle.getDensity(), 0.0001f);
+
+ // Force components should match 45 degree angle
+ double expectedForce = 2000.0f * Math.cos(Math.toRadians(45.0));
+ assertEquals(expectedForce, nozzle.getForceX(), 1.0f);
+ assertEquals(expectedForce, nozzle.getForceY(), 1.0f);
+ }
+}
diff --git a/src/test/java/dev/fluidsim/ParamsHandlerTest.java b/src/test/java/dev/fluidsim/ParamsHandlerTest.java
new file mode 100644
index 0000000..d1c601a
--- /dev/null
+++ b/src/test/java/dev/fluidsim/ParamsHandlerTest.java
@@ -0,0 +1,289 @@
+package dev.fluidsim;
+
+import dev.fluidsim.io.LoadableParams;
+import dev.fluidsim.io.ParamsHandler;
+import dev.fluidsim.simulation.Nozzle;
+import org.junit.jupiter.api.Test;
+import org.xml.sax.InputSource;
+import org.xml.sax.SAXException;
+
+import javax.xml.parsers.SAXParser;
+import javax.xml.parsers.SAXParserFactory;
+import java.awt.Color;
+import java.io.StringReader;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Test cases for ParamsHandler SAX parser
+ */
+public class ParamsHandlerTest {
+
+ /**
+ * Test 1: Default constructor initializes with new LoadableParams
+ */
+ @Test
+ public void testDefaultConstructor() {
+ ParamsHandler handler = new ParamsHandler();
+
+ assertNotNull(handler.getParams());
+ assertInstanceOf(LoadableParams.class, handler.getParams());
+ }
+
+ /**
+ * Test 2: Constructor with LoadableParams parameter uses provided instance
+ */
+ @Test
+ public void testConstructorWithParams() {
+ LoadableParams customParams = new LoadableParams();
+ customParams.iterations = 99;
+ customParams.viscosity = 0.123f;
+
+ ParamsHandler handler = new ParamsHandler(customParams);
+
+ assertNotNull(handler.getParams());
+ assertSame(customParams, handler.getParams());
+ assertEquals(99, handler.getParams().iterations);
+ assertEquals(0.123f, handler.getParams().viscosity, 0.0001f);
+ }
+
+ /**
+ * Test 3: Parse simulation section elements correctly
+ */
+ @Test
+ public void testParseSimulationSection() throws Exception {
+ String xml = """
+
+
+
+ 42
+ 0.007
+ 0.035
+ 17.5
+ 0.93
+ 160.0
+
+
+ 800
+ 600
+
+
+
+ """;
+
+ ParamsHandler handler = new ParamsHandler();
+ SAXParserFactory factory = SAXParserFactory.newInstance();
+ SAXParser parser = factory.newSAXParser();
+ parser.parse(new InputSource(new StringReader(xml)), handler);
+
+ LoadableParams params = handler.getParams();
+ assertEquals(42, params.iterations);
+ assertEquals(0.007f, params.viscosity, 0.0001f);
+ assertEquals(0.035f, params.diffusion, 0.0001f);
+ assertEquals(17.5f, params.vorticity, 0.0001f);
+ assertEquals(0.93f, params.dissipation, 0.0001f);
+ assertEquals(160.0f, params.interactionRadius, 0.0001f);
+ }
+
+ /**
+ * Test 4: Parse grid section elements correctly
+ */
+ @Test
+ public void testParseGridSection() throws Exception {
+ String xml = """
+
+
+
+ 20
+ 0.004
+ 0.02
+ 20.0
+ 0.98
+ 200.0
+
+
+ 1280
+ 720
+
+
+
+ """;
+
+ ParamsHandler handler = new ParamsHandler();
+ SAXParserFactory factory = SAXParserFactory.newInstance();
+ SAXParser parser = factory.newSAXParser();
+ parser.parse(new InputSource(new StringReader(xml)), handler);
+
+ LoadableParams params = handler.getParams();
+ assertEquals(1280, params.width);
+ assertEquals(720, params.height);
+ }
+
+ /**
+ * Test 5: Parse arrow attributes correctly including color
+ */
+ @Test
+ public void testParseArrowAttributes() throws Exception {
+ String xml = """
+
+
+
+ 20
+ 0.004
+ 0.02
+ 20.0
+ 0.98
+ 200.0
+
+
+ 1000
+ 1000
+
+
+
+ """;
+
+ ParamsHandler handler = new ParamsHandler();
+ SAXParserFactory factory = SAXParserFactory.newInstance();
+ SAXParser parser = factory.newSAXParser();
+ parser.parse(new InputSource(new StringReader(xml)), handler);
+
+ LoadableParams params = handler.getParams();
+ assertEquals(25.5f, params.arrowDensity, 0.0001f);
+ assertEquals(35.0f, params.maxArrowLength, 0.0001f);
+ assertEquals(6.5f, params.arrowHeadLength, 0.0001f);
+ assertEquals(0.75f, params.arrowHeadAngle, 0.0001f);
+ assertEquals(1.8f, params.velocityScale, 0.0001f);
+ assertEquals(new Color(180, 200, 220, 240), params.arrowColor);
+ }
+
+ /**
+ * Test 6: Parse multiple nozzles correctly
+ */
+ @Test
+ public void testParseMultipleNozzles() throws Exception {
+ String xml = """
+
+
+
+ 20
+ 0.004
+ 0.02
+ 20.0
+ 0.98
+ 200.0
+
+
+ 1000
+ 1000
+
+
+
+
+
+
+
+
+ """;
+
+ ParamsHandler handler = new ParamsHandler();
+ SAXParserFactory factory = SAXParserFactory.newInstance();
+ SAXParser parser = factory.newSAXParser();
+ parser.parse(new InputSource(new StringReader(xml)), handler);
+
+ LoadableParams params = handler.getParams();
+ assertNotNull(params.nozzles);
+ assertEquals(3, params.nozzles.size());
+
+ // Check first nozzle
+ Nozzle nozzle1 = params.nozzles.get(0);
+ assertEquals(Nozzle.Side.LEFT, nozzle1.getSide());
+ assertEquals(0.5f, nozzle1.getPosition(), 0.0001f);
+ assertEquals(20.0f, nozzle1.getRadius(), 0.0001f);
+ assertEquals(100.0f, nozzle1.getLength(), 0.0001f);
+ assertEquals(200000.0f, nozzle1.getForceMagnitude(), 0.0001f);
+ assertEquals(250.0f, nozzle1.getDensity(), 0.0001f);
+
+ // Check second nozzle
+ Nozzle nozzle2 = params.nozzles.get(1);
+ assertEquals(Nozzle.Side.BOTTOM, nozzle2.getSide());
+ assertEquals(0.3f, nozzle2.getPosition(), 0.0001f);
+
+ // Check third nozzle
+ Nozzle nozzle3 = params.nozzles.get(2);
+ assertEquals(Nozzle.Side.RIGHT, nozzle3.getSide());
+ assertEquals(0.75f, nozzle3.getPosition(), 0.0001f);
+ }
+
+ /**
+ * Test 7: Parse empty nozzles section (no nozzles)
+ */
+ @Test
+ public void testParseEmptyNozzlesSection() throws Exception {
+ String xml = """
+
+
+
+ 20
+ 0.004
+ 0.02
+ 20.0
+ 0.98
+ 200.0
+
+
+ 1000
+ 1000
+
+
+
+
+
+ """;
+
+ ParamsHandler handler = new ParamsHandler();
+ SAXParserFactory factory = SAXParserFactory.newInstance();
+ SAXParser parser = factory.newSAXParser();
+ parser.parse(new InputSource(new StringReader(xml)), handler);
+
+ LoadableParams params = handler.getParams();
+ assertNotNull(params.nozzles);
+ assertEquals(0, params.nozzles.size());
+ }
+
+ /**
+ * Test 8: Parser throws exception for invalid nozzle side value
+ */
+ @Test
+ public void testParseInvalidNozzleSide() {
+ String xml = """
+
+
+
+ 20
+ 0.004
+ 0.02
+ 20.0
+ 0.98
+ 200.0
+
+
+ 1000
+ 1000
+
+
+
+
+
+
+ """;
+
+ ParamsHandler handler = new ParamsHandler();
+ SAXParserFactory factory = SAXParserFactory.newInstance();
+
+ assertThrows(Exception.class, () -> {
+ SAXParser parser = factory.newSAXParser();
+ parser.parse(new InputSource(new StringReader(xml)), handler);
+ });
+ }
+}
diff --git a/src/test/java/dev/fluidsim/SimParamsTest.java b/src/test/java/dev/fluidsim/SimParamsTest.java
new file mode 100644
index 0000000..c5a7968
--- /dev/null
+++ b/src/test/java/dev/fluidsim/SimParamsTest.java
@@ -0,0 +1,183 @@
+package dev.fluidsim;
+
+import dev.fluidsim.simulation.Constants;
+import dev.fluidsim.simulation.SimParams;
+import org.junit.jupiter.api.Test;
+
+import java.awt.Color;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Test cases for SimParams class
+ */
+public class SimParamsTest {
+
+ /**
+ * Test 1: Default constructor initializes with Constants values
+ */
+ @Test
+ public void testDefaultConstructorUsesConstants() {
+ SimParams params = new SimParams();
+
+ assertEquals(Constants.WIDTH, params.width);
+ assertEquals(Constants.HEIGHT, params.height);
+ assertEquals(Constants.ITERATIONS, params.iterations);
+ assertEquals(Constants.VISCOSITY, params.viscosity, 0.0001f);
+ assertEquals(Constants.DIFFUSION, params.diffusion, 0.0001f);
+ assertEquals(Constants.VORTICITY, params.vorticity, 0.0001f);
+ assertEquals(Constants.DISSIPATION, params.dissipation, 0.0001f);
+ assertEquals(Constants.INTERACTION_RADIUS, params.interactionRadius, 0.0001f);
+ assertEquals(Constants.VELOCITY_VIEW, params.velocityView);
+ assertEquals(Constants.SHOW_ARROWS, params.showArrows);
+ assertEquals(Constants.ARROW_DENSITY, params.arrowDensity, 0.0001f);
+ assertEquals(Constants.MAX_ARROW_LENGTH, params.maxArrowLength, 0.0001f);
+ assertEquals(Constants.ARROWHEAD_LENGTH, params.arrowHeadLength, 0.0001f);
+ assertEquals(Constants.ARROWHEAD_ANGLE, params.arrowHeadAngle, 0.0001f);
+ assertEquals(Constants.VELOCITY_SCALE, params.velocityScale, 0.0001f);
+ }
+
+ /**
+ * Test 2: Parameterized constructor sets values correctly
+ */
+ @Test
+ public void testParameterizedConstructor() {
+ SimParams params = new SimParams(25, 0.008f, 0.04f, 18.0f, 0.92f, 175.0f);
+
+ assertEquals(25, params.iterations);
+ assertEquals(0.008f, params.viscosity, 0.0001f);
+ assertEquals(0.04f, params.diffusion, 0.0001f);
+ assertEquals(18.0f, params.vorticity, 0.0001f);
+ assertEquals(0.92f, params.dissipation, 0.0001f);
+ assertEquals(175.0f, params.interactionRadius, 0.0001f);
+ }
+
+ /**
+ * Test 3: Iterations setter and getter
+ */
+ @Test
+ public void testIterationsSetterGetter() {
+ SimParams params = new SimParams();
+ params.setIterations(50);
+ assertEquals(50, params.getIterations());
+
+ params.setIterations(1);
+ assertEquals(1, params.getIterations());
+ }
+
+ /**
+ * Test 4: Viscosity setter and getter
+ */
+ @Test
+ public void testViscositySetterGetter() {
+ SimParams params = new SimParams();
+ params.setViscosity(0.01f);
+ assertEquals(0.01f, params.getViscosity(), 0.0001f);
+
+ params.setViscosity(0.0f);
+ assertEquals(0.0f, params.getViscosity(), 0.0001f);
+ }
+
+ /**
+ * Test 5: Diffusion setter and getter
+ */
+ @Test
+ public void testDiffusionSetterGetter() {
+ SimParams params = new SimParams();
+ params.setDiffusion(0.05f);
+ assertEquals(0.05f, params.getDiffusion(), 0.0001f);
+
+ params.setDiffusion(0.001f);
+ assertEquals(0.001f, params.getDiffusion(), 0.0001f);
+ }
+
+ /**
+ * Test 6: Vorticity setter and getter
+ */
+ @Test
+ public void testVorticitySetterGetter() {
+ SimParams params = new SimParams();
+ params.setVorticity(30.0f);
+ assertEquals(30.0f, params.getVorticity(), 0.0001f);
+
+ params.setVorticity(5.0f);
+ assertEquals(5.0f, params.getVorticity(), 0.0001f);
+ }
+
+ /**
+ * Test 7: Dissipation setter and getter
+ */
+ @Test
+ public void testDissipationSetterGetter() {
+ SimParams params = new SimParams();
+ params.setDissipation(0.99f);
+ assertEquals(0.99f, params.getDissipation(), 0.0001f);
+
+ params.setDissipation(0.5f);
+ assertEquals(0.5f, params.getDissipation(), 0.0001f);
+ }
+
+ /**
+ * Test 8: InteractionRadius setter and getter
+ */
+ @Test
+ public void testInteractionRadiusSetterGetter() {
+ SimParams params = new SimParams();
+ params.setInteractionRadius(250.0f);
+ assertEquals(250.0f, params.getInteractionRadius(), 0.0001f);
+
+ params.setInteractionRadius(100.0f);
+ assertEquals(100.0f, params.getInteractionRadius(), 0.0001f);
+ }
+
+ /**
+ * Test 9: VelocityView boolean setter and getter
+ */
+ @Test
+ public void testVelocityViewSetterGetter() {
+ SimParams params = new SimParams();
+
+ params.setVelocityView(true);
+ assertTrue(params.isVelocityView());
+
+ params.setVelocityView(false);
+ assertFalse(params.isVelocityView());
+ }
+
+ /**
+ * Test 10: ShowArrows boolean setter and getter
+ */
+ @Test
+ public void testShowArrowsSetterGetter() {
+ SimParams params = new SimParams();
+
+ params.setShowArrows(true);
+ assertTrue(params.isShowArrows());
+
+ params.setShowArrows(false);
+ assertFalse(params.isShowArrows());
+ }
+
+ /**
+ * Test 11: Nozzles list is initialized as empty
+ */
+ @Test
+ public void testNozzlesListInitialized() {
+ SimParams params = new SimParams();
+
+ assertNotNull(params.nozzles);
+ assertTrue(params.nozzles.isEmpty());
+ assertEquals(0, params.nozzles.size());
+ }
+
+ /**
+ * Test 12: Arrow color is initialized with default color
+ */
+ @Test
+ public void testArrowColorInitialized() {
+ SimParams params = new SimParams();
+
+ assertNotNull(params.arrowColor);
+ assertEquals(new Color(255, 255, 255, 255), params.arrowColor);
+ }
+}
diff --git a/test/boat.png b/test/boat.png
new file mode 100644
index 0000000..a0cec5c
Binary files /dev/null and b/test/boat.png differ
diff --git a/test/square.png b/test/square.png
new file mode 100644
index 0000000..746b8f4
Binary files /dev/null and b/test/square.png differ
diff --git a/test/test.png b/test/test.png
new file mode 100644
index 0000000..f663cb6
Binary files /dev/null and b/test/test.png differ