"""
Stable 2D fluid simulation (Jos Stam) using Pygame + PyOpenGL + NumPy.

- Single-file demo.
- Left mouse: add density (smoke).
- Right mouse: add velocity (drag to create flow).
- Keys: C = clear, D = toggle display velocity, Esc or close window to quit.

Dependencies:
    pip install pygame PyOpenGL numpy

Run:
    python stable_fluid_pygame_opengl.py

Notes: This is a CPU solver on a grid (not GPU accelerated). Grid size defaults to 128x128 which is interactive on modern machines.
"""

import sys
import math
import numpy as np
import pygame
from pygame.locals import *
from OpenGL.GL import *

# ----------------------- Fluid solver (Stable Fluids, Stam) -----------------------

class StableFluid:
    def __init__(self, N, diffusion, viscosity, dt):
        self.N = N  # grid size (N x N)
        self.size = N + 2  # include boundary
        self.dt = dt
        self.diff = diffusion
        self.visc = viscosity

        # fields (with boundary padding)
        self.s = np.zeros((self.size, self.size), dtype=np.float32)        # temp
        self.density = np.zeros((self.size, self.size), dtype=np.float32)  # smoke

        self.Vx = np.zeros((self.size, self.size), dtype=np.float32)
        self.Vy = np.zeros((self.size, self.size), dtype=np.float32)
        self.Vx0 = np.zeros((self.size, self.size), dtype=np.float32)
        self.Vy0 = np.zeros((self.size, self.size), dtype=np.float32)

    def add_density(self, x, y, amount):
        self.density[y, x] += amount

    def add_velocity(self, x, y, amount_x, amount_y):
        self.Vx[y, x] += amount_x
        self.Vy[y, x] += amount_y

    def step(self):
        N = self.N
        diff = self.diff
        visc = self.visc
        dt = self.dt

        self.diffuse(1, self.Vx0, self.Vx, visc, dt)
        self.diffuse(2, self.Vy0, self.Vy, visc, dt)

        self.project(self.Vx0, self.Vy0, self.Vx, self.Vy)

        self.advect(1, self.Vx, self.Vx0, self.Vx0, self.Vy0, dt)
        self.advect(2, self.Vy, self.Vy0, self.Vx0, self.Vy0, dt)

        self.project(self.Vx, self.Vy, self.Vx0, self.Vy0)

        self.diffuse(0, self.s, self.density, diff, dt)
        self.advect(0, self.density, self.s, self.Vx, self.Vy, dt)

    # helper routines
    def set_bnd(self, b, x):
        N = self.N
        # edges
        x[0, 1:N+1] = -x[1, 1:N+1] if b == 2 else x[1, 1:N+1]
        x[N+1, 1:N+1] = -x[N, 1:N+1] if b == 2 else x[N, 1:N+1]

        x[1:N+1, 0] = -x[1:N+1, 1] if b == 1 else x[1:N+1, 1]
        x[1:N+1, N+1] = -x[1:N+1, N] if b == 1 else x[1:N+1, N]

        # corners
        x[0, 0] = 0.5 * (x[1, 0] + x[0, 1])
        x[0, N+1] = 0.5 * (x[1, N+1] + x[0, N])
        x[N+1, 0] = 0.5 * (x[N, 0] + x[N+1, 1])
        x[N+1, N+1] = 0.5 * (x[N, N+1] + x[N+1, N])

    def lin_solve(self, b, x, x0, a, c):
        N = self.N
        for _ in range(20):  # Gauss-Seidel iterations
            x[1:N+1, 1:N+1] = (
                x0[1:N+1, 1:N+1] + a * (
                    x[0:N, 1:N+1] + x[2:N+2, 1:N+1] + x[1:N+1, 0:N] + x[1:N+1, 2:N+2]
                )
            ) / c
            self.set_bnd(b, x)

    def diffuse(self, b, x, x0, diff, dt):
        N = self.N
        a = dt * diff * N * N
        self.lin_solve(b, x, x0, a, 1 + 4 * a)

    def advect(self, b, d, d0, velocX, velocY, dt):
        N = self.N
        dt0 = dt * N
        for i in range(1, N+1):
            for j in range(1, N+1):
                x = i - dt0 * velocX[j, i]
                y = j - dt0 * velocY[j, i]

                if x < 0.5:
                    x = 0.5
                if x > N + 0.5:
                    x = N + 0.5
                i0 = int(math.floor(x))
                i1 = i0 + 1

                if y < 0.5:
                    y = 0.5
                if y > N + 0.5:
                    y = N + 0.5
                j0 = int(math.floor(y))
                j1 = j0 + 1

                s1 = x - i0
                s0 = 1 - s1
                t1 = y - j0
                t0 = 1 - t1

                d[j, i] = (
                    s0 * (t0 * d0[j0, i0] + t1 * d0[j1, i0]) +
                    s1 * (t0 * d0[j0, i1] + t1 * d0[j1, i1])
                )
        self.set_bnd(b, d)

    def project(self, velocX, velocY, p, div):
        N = self.N
        div[1:N+1, 1:N+1] = -0.5 * (
            velocX[1:N+1, 2:N+2] - velocX[1:N+1, 0:N] + velocY[2:N+2, 1:N+1] - velocY[0:N, 1:N+1]
        ) / N
        p[1:N+1, 1:N+1] = 0
        self.set_bnd(0, div)
        self.set_bnd(0, p)

        self.lin_solve(0, p, div, 1, 4)

        velocX[1:N+1, 1:N+1] -= 0.5 * (p[1:N+1, 2:N+2] - p[1:N+1, 0:N]) * N
        velocY[1:N+1, 1:N+1] -= 0.5 * (p[2:N+2, 1:N+1] - p[0:N, 1:N+1]) * N

        self.set_bnd(1, velocX)
        self.set_bnd(2, velocY)

# ----------------------- OpenGL texture renderer -----------------------

class GLRenderer:
    def __init__(self, width, height, gridN):
        self.width = width
        self.height = height
        self.gridN = gridN
        self.tex_id = glGenTextures(1)
        self.init_texture()

    def init_texture(self):
        glBindTexture(GL_TEXTURE_2D, self.tex_id)
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST)
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST)
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)

        # initial empty texture (RGBA)
        data = np.zeros((self.gridN, self.gridN, 4), dtype=np.uint8)
        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, self.gridN, self.gridN, 0, GL_RGBA, GL_UNSIGNED_BYTE, data)

    def update_texture_from_density(self, density):
        # density is (N+2)x(N+2), we upload only 1..N
        N = self.gridN
        den = density[1:N+1, 1:N+1]
        # map density to 0-255
        img = np.empty((N, N, 4), dtype=np.uint8)
        # grayscale smoke map; store in rgb and alpha
        clipped = np.clip(den * 255.0, 0, 255).astype(np.uint8)
        img[:, :, 0] = clipped
        img[:, :, 1] = clipped
        img[:, :, 2] = clipped
        img[:, :, 3] = clipped

        glBindTexture(GL_TEXTURE_2D, self.tex_id)
        glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, N, N, GL_RGBA, GL_UNSIGNED_BYTE, img)

    def draw_fullscreen(self):
        glEnable(GL_TEXTURE_2D)
        glBindTexture(GL_TEXTURE_2D, self.tex_id)
        glBegin(GL_QUADS)
        glTexCoord2f(0.0, 0.0); glVertex2f(-1.0, -1.0)
        glTexCoord2f(1.0, 0.0); glVertex2f(1.0, -1.0)
        glTexCoord2f(1.0, 1.0); glVertex2f(1.0, 1.0)
        glTexCoord2f(0.0, 1.0); glVertex2f(-1.0, 1.0)
        glEnd()
        glDisable(GL_TEXTURE_2D)

    def draw_velocity(self, fluid):
        # draw simple vector field lines
        N = fluid.N
        glPushAttrib(GL_CURRENT_BIT | GL_POINT_BIT | GL_LINE_BIT)
        glPointSize(2.0)
        glBegin(GL_LINES)
        step = max(1, N // 32)
        for i in range(1, N+1, step):
            for j in range(1, N+1, step):
                x = (i - 0.5) / N * 2 - 1
                y = (j - 0.5) / N * 2 - 1
                vx = fluid.Vx[j, i]
                vy = fluid.Vy[j, i]
                glVertex2f(x, y)
                glVertex2f(x + vx * 0.02, y + vy * 0.02)
        glEnd()
        glPopAttrib()

# ----------------------- Main app -----------------------

def main():
    # settings
    WIN_W, WIN_H = 800, 800
    GRID_N = 128
    DIFFUSION = 0.0001
    VISCOSITY = 0.0001
    DT = 0.1

    pygame.init()
    pygame.display.set_mode((WIN_W, WIN_H), DOUBLEBUF | OPENGL)
    pygame.display.set_caption('Stable Fluid (Stam) - Pygame + PyOpenGL')

    # setup orthographic projection
    glViewport(0, 0, WIN_W, WIN_H)
    glMatrixMode(GL_PROJECTION)
    glLoadIdentity()
    glOrtho(-1, 1, -1, 1, -1, 1)
    glMatrixMode(GL_MODELVIEW)
    glLoadIdentity()

    fluid = StableFluid(GRID_N, DIFFUSION, VISCOSITY, DT)
    renderer = GLRenderer(WIN_W, WIN_H, GRID_N)

    clock = pygame.time.Clock()
    running = True
    show_velocity = False

    is_down_left = False
    is_down_right = False
    last_mouse = None

    while running:
        for event in pygame.event.get():
            if event.type == QUIT:
                running = False
            elif event.type == KEYDOWN:
                if event.key == K_ESCAPE:
                    running = False
                elif event.key == K_c:
                    fluid.density.fill(0)
                    fluid.Vx.fill(0)
                    fluid.Vy.fill(0)
                elif event.key == K_d:
                    show_velocity = not show_velocity
            elif event.type == MOUSEBUTTONDOWN:
                if event.button == 1:
                    is_down_left = True
                elif event.button == 3:
                    is_down_right = True
                last_mouse = pygame.mouse.get_pos()
            elif event.type == MOUSEBUTTONUP:
                if event.button == 1:
                    is_down_left = False
                elif event.button == 3:
                    is_down_right = False
                last_mouse = None

        mx, my = pygame.mouse.get_pos()
        # convert to grid coords (i,x) with padding
        def screen_to_grid(px, py):
            gx = int((px / WIN_W) * GRID_N) + 1
            gy = int(((WIN_H - py) / WIN_H) * GRID_N) + 1
            gx = max(1, min(GRID_N, gx))
            gy = max(1, min(GRID_N, gy))
            return gx, gy

        if last_mouse is not None:
            lx, ly = last_mouse
            if is_down_left or is_down_right:
                g1 = screen_to_grid(lx, ly)
                g2 = screen_to_grid(mx, my)
                gx1, gy1 = g1
                gx2, gy2 = g2
                # draw along line between points
                steps = max(abs(gx2 - gx1), abs(gy2 - gy1), 1)
                for s in range(steps + 1):
                    t = s / steps
                    gx = int(round(gx1 + (gx2 - gx1) * t))
                    gy = int(round(gy1 + (gy2 - gy1) * t))
                    if is_down_left:
                        fluid.add_density(gx, gy, 100.0 * DT)
                    if is_down_right:
                        vx = (gx2 - gx1) * 5.0
                        vy = (gy2 - gy1) * 5.0
                        fluid.add_velocity(gx, gy, vx, vy)
                last_mouse = (mx, my)

        # step simulation
        fluid.step()

        # render
        glClear(GL_COLOR_BUFFER_BIT)
        renderer.update_texture_from_density(fluid.density)
        renderer.draw_fullscreen()

        if show_velocity:
            glColor3f(1.0, 0.0, 0.0)
            renderer.draw_velocity(fluid)
            glColor3f(1.0, 1.0, 1.0)

        pygame.display.flip()
#
        clock.tick(10)

    pygame.quit()

if __name__ == '__main__':
    main()
