diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/EdgeForce.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/EdgeForce.kt new file mode 100644 index 0000000..cc9f70e --- /dev/null +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/EdgeForce.kt @@ -0,0 +1,86 @@ +package link.socket.phosphor.field + +import link.socket.phosphor.math.Vector2 + +/** + * Transfer function used by an [EdgeForce]. + */ +fun interface EdgeTransferFunction { + fun transfer( + from: Volume, + to: Volume, + deltaTime: Float, + ): Float +} + +/** + * Fluid coupling between two adjacent volumes. + * + * The transfer function returns "requested transfer amount per step". + * The actual transfer is clamped to available fluid in the source volume. + */ +data class EdgeForce( + val fromVolumeId: String, + val toVolumeId: String, + val transferFunction: EdgeTransferFunction = DEFAULT_TRANSFER_FUNCTION, + val momentumScale: Float = DEFAULT_MOMENTUM_SCALE, +) { + init { + require(fromVolumeId.isNotBlank()) { "fromVolumeId must not be blank" } + require(toVolumeId.isNotBlank()) { "toVolumeId must not be blank" } + require(fromVolumeId != toVolumeId) { "fromVolumeId and toVolumeId must be different" } + require(momentumScale >= 0f) { "momentumScale must be >= 0, got $momentumScale" } + } + + internal fun apply( + world: PhosphorWorld, + deltaTime: Float, + ) { + val from = world.getVolume(fromVolumeId) ?: return + val to = world.getVolume(toVolumeId) ?: return + + if (!from.isAdjacentTo(to)) return + + val requestedAmount = transferFunction.transfer(from, to, deltaTime).coerceAtLeast(0f) + if (requestedAmount <= 0f) return + + val moved = from.removeFluid(requestedAmount) + if (moved <= 0f) return + + to.addFluid(moved) + transferMomentum(from = from, to = to, transferredAmount = moved) + } + + private fun transferMomentum( + from: Volume, + to: Volume, + transferredAmount: Float, + ) { + val direction = + Vector2( + x = to.bounds.centerX - from.bounds.centerX, + y = to.bounds.centerY - from.bounds.centerY, + ).normalized() + + if (direction.length() == 0f) return + + val impulse = direction * (transferredAmount * momentumScale) + from.applyImpulse(impulse * -1f) + to.applyImpulse(impulse) + } + + companion object { + const val DEFAULT_MOMENTUM_SCALE: Float = 0.1f + + val DEFAULT_TRANSFER_FUNCTION = + EdgeTransferFunction { from, to, deltaTime -> + val pressureDelta = (from.pressure - to.pressure).coerceAtLeast(0f) + if (pressureDelta == 0f) { + 0f + } else { + val coupling = minOf(from.fluidType.diffusionRate, to.fluidType.diffusionRate) + pressureDelta * coupling * deltaTime + } + } + } +} diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/FluidType.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/FluidType.kt new file mode 100644 index 0000000..3766fc5 --- /dev/null +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/FluidType.kt @@ -0,0 +1,71 @@ +package link.socket.phosphor.field + +/** + * Defines physical behavior for a fluid family. + * + * Each fluid type controls how quickly momentum dissipates (viscosity), + * how much pressure it produces for a given amount (density), and how + * quickly it spreads through edges (diffusionRate). + */ +sealed class FluidType( + val viscosity: Float, + val density: Float, + val diffusionRate: Float, + val particleType: ParticleType, +) { + init { + require(viscosity >= 0f) { "viscosity must be >= 0, got $viscosity" } + require(density > 0f) { "density must be > 0, got $density" } + require(diffusionRate >= 0f) { "diffusionRate must be >= 0, got $diffusionRate" } + } + + /** + * Medium viscosity, balanced spread, stable pressure. + */ + object Water : FluidType( + viscosity = 0.08f, + density = 1.0f, + diffusionRate = 0.35f, + particleType = ParticleType.MOTE, + ) + + /** + * Low viscosity, fast spread, low density. + */ + object Fire : FluidType( + viscosity = 0.02f, + density = 0.35f, + diffusionRate = 0.7f, + particleType = ParticleType.SPARK, + ) + + /** + * Very low viscosity, broad diffusion, light pressure. + */ + object Air : FluidType( + viscosity = 0.01f, + density = 0.12f, + diffusionRate = 0.9f, + particleType = ParticleType.TRAIL, + ) + + /** + * User-defined fluid profile. + */ + class Custom( + val name: String, + viscosity: Float, + density: Float, + diffusionRate: Float, + particleType: ParticleType = ParticleType.MOTE, + ) : FluidType( + viscosity = viscosity, + density = density, + diffusionRate = diffusionRate, + particleType = particleType, + ) { + init { + require(name.isNotBlank()) { "name must not be blank" } + } + } +} diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/PhosphorWorld.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/PhosphorWorld.kt new file mode 100644 index 0000000..9dd7d65 --- /dev/null +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/PhosphorWorld.kt @@ -0,0 +1,282 @@ +package link.socket.phosphor.field + +import link.socket.phosphor.math.Vector2 + +/** + * Simulation container with spatial partitioning and fixed timestep updates. + */ +class PhosphorWorld( + val width: Int, + val height: Int, + val partitionSize: Int = DEFAULT_PARTITION_SIZE, + val fixedTimeStep: Float = DEFAULT_FIXED_TIME_STEP, + private val maxSubSteps: Int = DEFAULT_MAX_SUB_STEPS, +) { + init { + require(width > 0) { "width must be > 0, got $width" } + require(height > 0) { "height must be > 0, got $height" } + require(partitionSize > 0) { "partitionSize must be > 0, got $partitionSize" } + require(fixedTimeStep > 0f) { "fixedTimeStep must be > 0, got $fixedTimeStep" } + require(maxSubSteps > 0) { "maxSubSteps must be > 0, got $maxSubSteps" } + } + + private val volumesById = linkedMapOf() + private val edgeForces = mutableListOf() + private val solvers = mutableListOf(CoupledFluidSolver()) + private val partitions = mutableMapOf>() + + private var accumulator: Float = 0f + var simulationTime: Float = 0f + private set + + val volumeCount: Int get() = volumesById.size + val edgeForceCount: Int get() = edgeForces.size + val solverCount: Int get() = solvers.size + + fun addVolume(volume: Volume) { + validateBounds(volume.bounds) + require(!volumesById.containsKey(volume.id)) { "Volume '${volume.id}' already exists" } + + volumesById[volume.id] = volume + indexVolume(volume) + } + + fun removeVolume(volumeId: String): Volume? { + val removed = volumesById.remove(volumeId) ?: return null + deindexVolume(removed) + edgeForces.removeAll { it.fromVolumeId == volumeId || it.toVolumeId == volumeId } + return removed + } + + fun getVolume(volumeId: String): Volume? = volumesById[volumeId] + + fun allVolumes(): List = volumesById.values.toList() + + fun volumesAt( + x: Int, + y: Int, + ): List { + if (x < 0 || x >= width || y < 0 || y >= height) return emptyList() + + val key = partitionKeyFor(x = x, y = y) + val ids = partitions[key] ?: return emptyList() + + return ids + .mapNotNull { id -> volumesById[id] } + .filter { volume -> volume.contains(x, y) } + } + + fun addEdgeForce(edgeForce: EdgeForce) { + require(volumesById.containsKey(edgeForce.fromVolumeId)) { + "Unknown source volume '${edgeForce.fromVolumeId}'" + } + require(volumesById.containsKey(edgeForce.toVolumeId)) { + "Unknown target volume '${edgeForce.toVolumeId}'" + } + edgeForces.add(edgeForce) + } + + fun allEdgeForces(): List = edgeForces.toList() + + fun clearEdgeForces() { + edgeForces.clear() + } + + /** + * Adds bidirectional edges between all touching volumes. + * + * @return number of edges created + */ + fun connectAdjacentVolumes( + transferFunction: EdgeTransferFunction = EdgeForce.DEFAULT_TRANSFER_FUNCTION, + momentumScale: Float = EdgeForce.DEFAULT_MOMENTUM_SCALE, + ): Int { + var created = 0 + val volumes = volumesById.values.toList() + + for (i in volumes.indices) { + for (j in i + 1 until volumes.size) { + val first = volumes[i] + val second = volumes[j] + + if (!first.isAdjacentTo(second)) continue + + val forward = + EdgeForce( + fromVolumeId = first.id, + toVolumeId = second.id, + transferFunction = transferFunction, + momentumScale = momentumScale, + ) + val reverse = + EdgeForce( + fromVolumeId = second.id, + toVolumeId = first.id, + transferFunction = transferFunction, + momentumScale = momentumScale, + ) + + if (!edgeForces.contains(forward)) { + edgeForces.add(forward) + created++ + } + if (!edgeForces.contains(reverse)) { + edgeForces.add(reverse) + created++ + } + } + } + + return created + } + + fun addSolver(solver: Solver) { + solvers.add(solver) + } + + fun clearSolvers() { + solvers.clear() + } + + fun setSolvers(newSolvers: List) { + solvers.clear() + solvers.addAll(newSolvers) + } + + fun allSolvers(): List = solvers.toList() + + /** + * Advance the simulation by variable wall-clock delta. + * + * @return number of fixed steps executed + */ + fun update(deltaTime: Float): Int { + require(deltaTime >= 0f) { "deltaTime must be >= 0, got $deltaTime" } + + accumulator += deltaTime + var steps = 0 + + while (accumulator >= fixedTimeStep && steps < maxSubSteps) { + step(fixedTimeStep) + accumulator -= fixedTimeStep + simulationTime += fixedTimeStep + steps++ + } + + return steps + } + + /** + * Advance by exactly one deterministic step. + */ + fun step(deltaTime: Float = fixedTimeStep) { + require(deltaTime > 0f) { "deltaTime must be > 0, got $deltaTime" } + if (volumesById.isEmpty()) return + + val activeSolvers = if (solvers.isEmpty()) listOf(DEFAULT_SOLVER) else solvers + val interleavedDelta = deltaTime / activeSolvers.size + + activeSolvers.forEach { solver -> + solver.solve(this, interleavedDelta) + applyEdgeForces(interleavedDelta) + } + + volumesById.values.forEach { volume -> + volume.syncParticleOutput(simulationTime) + } + } + + /** + * Snapshot current fluid state as a particle system for rendering. + */ + fun toParticleSystem(maxParticles: Int = totalParticleBudget()): ParticleSystem { + val budget = maxParticles.coerceAtLeast(0) + val renderSystem = + ParticleSystem( + maxParticles = budget, + drag = 0f, + gravity = Vector2.ZERO, + lifeDecayRate = 0f, + ) + + if (budget == 0) return renderSystem + + volumesById.values.forEach { volume -> + renderSystem.addParticles(volume.renderParticles()) + } + + return renderSystem + } + + internal fun mutableVolumes(): Collection = volumesById.values + + private fun applyEdgeForces(deltaTime: Float) { + edgeForces.forEach { edge -> + edge.apply(this, deltaTime) + } + } + + private fun validateBounds(bounds: VolumeBounds) { + require(bounds.x >= 0 && bounds.y >= 0) { + "Volume bounds must start inside world; got (${bounds.x}, ${bounds.y})" + } + require(bounds.maxXExclusive <= width) { + "Volume maxX (${bounds.maxXExclusive}) exceeds world width ($width)" + } + require(bounds.maxYExclusive <= height) { + "Volume maxY (${bounds.maxYExclusive}) exceeds world height ($height)" + } + } + + private fun indexVolume(volume: Volume) { + partitionKeysFor(volume.bounds).forEach { key -> + partitions.getOrPut(key) { linkedSetOf() }.add(volume.id) + } + } + + private fun deindexVolume(volume: Volume) { + partitionKeysFor(volume.bounds).forEach { key -> + val bucket = partitions[key] ?: return@forEach + bucket.remove(volume.id) + if (bucket.isEmpty()) { + partitions.remove(key) + } + } + } + + private fun partitionKeysFor(bounds: VolumeBounds): Sequence { + val minCellX = bounds.x / partitionSize + val maxCellX = (bounds.maxXExclusive - 1) / partitionSize + val minCellY = bounds.y / partitionSize + val maxCellY = (bounds.maxYExclusive - 1) / partitionSize + + return sequence { + for (cellY in minCellY..maxCellY) { + for (cellX in minCellX..maxCellX) { + yield(PartitionKey(cellX, cellY)) + } + } + } + } + + private fun partitionKeyFor( + x: Int, + y: Int, + ): PartitionKey = PartitionKey(x / partitionSize, y / partitionSize) + + private fun totalParticleBudget(): Int = + volumesById.values.sumOf { volume -> volume.particleBudget }.coerceAtLeast(1) + + private data class PartitionKey( + val x: Int, + val y: Int, + ) + + companion object { + const val DEFAULT_PARTITION_SIZE: Int = 8 + const val DEFAULT_FIXED_TIME_STEP: Float = 1f / 60f + const val DEFAULT_MAX_SUB_STEPS: Int = 8 + + private val DEFAULT_SOLVER = CoupledFluidSolver() + } +} diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/Solver.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/Solver.kt new file mode 100644 index 0000000..17c54d6 --- /dev/null +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/Solver.kt @@ -0,0 +1,25 @@ +package link.socket.phosphor.field + +/** + * Coupled solver contract for interleaved fixed-timestep updates. + */ +fun interface Solver { + fun solve( + world: PhosphorWorld, + deltaTime: Float, + ) +} + +/** + * Default solver: integrates each volume's local state. + */ +class CoupledFluidSolver : Solver { + override fun solve( + world: PhosphorWorld, + deltaTime: Float, + ) { + world.mutableVolumes().forEach { volume -> + volume.integrate(deltaTime) + } + } +} diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/Volume.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/Volume.kt new file mode 100644 index 0000000..fd04a76 --- /dev/null +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/Volume.kt @@ -0,0 +1,187 @@ +package link.socket.phosphor.field + +import kotlin.math.ceil +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.sin +import kotlin.math.sqrt +import link.socket.phosphor.math.Vector2 + +/** + * Axis-aligned region in world space where fluid simulation is computed. + */ +data class VolumeBounds( + val x: Int, + val y: Int, + val width: Int, + val height: Int, +) { + init { + require(width > 0) { "width must be > 0, got $width" } + require(height > 0) { "height must be > 0, got $height" } + } + + val maxXExclusive: Int = x + width + val maxYExclusive: Int = y + height + val centerX: Float = x + width * 0.5f + val centerY: Float = y + height * 0.5f + + fun contains( + px: Int, + py: Int, + ): Boolean { + return px >= x && px < maxXExclusive && py >= y && py < maxYExclusive + } + + /** + * Returns true when edges touch with overlap on the perpendicular axis. + * Diagonal corner contact does not count as adjacent. + */ + fun isAdjacentTo(other: VolumeBounds): Boolean { + val horizontalTouch = maxXExclusive == other.x || other.maxXExclusive == x + val verticalOverlap = y < other.maxYExclusive && other.y < maxYExclusive + + val verticalTouch = maxYExclusive == other.y || other.maxYExclusive == y + val horizontalOverlap = x < other.maxXExclusive && other.x < maxXExclusive + + return (horizontalTouch && verticalOverlap) || (verticalTouch && horizontalOverlap) + } +} + +/** + * Mutable simulation state for one spatial region. + */ +class Volume( + val id: String, + val bounds: VolumeBounds, + val fluidType: FluidType, + initialAmount: Float = 0f, + initialVelocity: Vector2 = Vector2.ZERO, + val particleBudget: Int = DEFAULT_PARTICLE_BUDGET, +) { + init { + require(id.isNotBlank()) { "id must not be blank" } + require(particleBudget >= 0) { "particleBudget must be >= 0, got $particleBudget" } + } + + private val particleSystem = + ParticleSystem( + maxParticles = particleBudget, + drag = fluidType.viscosity, + gravity = Vector2.ZERO, + lifeDecayRate = 0f, + ) + + var amount: Float = initialAmount.coerceAtLeast(0f) + private set + + var velocity: Vector2 = initialVelocity + private set + + var pressure: Float = amount * fluidType.density + private set + + fun contains( + x: Int, + y: Int, + ): Boolean = bounds.contains(x, y) + + fun isAdjacentTo(other: Volume): Boolean = bounds.isAdjacentTo(other.bounds) + + fun setFluidAmount(newAmount: Float) { + amount = newAmount.coerceAtLeast(0f) + recomputePressure() + } + + fun addFluid(delta: Float) { + if (delta <= 0f) return + amount += delta + recomputePressure() + } + + fun removeFluid(delta: Float): Float { + if (delta <= 0f || amount <= 0f) return 0f + val removed = delta.coerceAtMost(amount) + amount -= removed + recomputePressure() + return removed + } + + fun applyImpulse(impulse: Vector2) { + velocity = velocity + impulse + } + + fun renderParticles(): List = particleSystem.getParticles() + + internal fun integrate(deltaTime: Float) { + val damping = (1f - fluidType.viscosity * deltaTime).coerceIn(0f, 1f) + velocity = velocity * damping + recomputePressure() + } + + internal fun syncParticleOutput(simulationTime: Float) { + particleSystem.clear() + + if (amount <= 0f || particleBudget == 0) return + + val targetParticles = (amount * particleBudget).toInt().coerceIn(0, particleBudget) + if (targetParticles == 0) return + + val particles = + List(targetParticles) { index -> + createParticle(index = index, simulationTime = simulationTime) + } + particleSystem.addParticles(particles) + } + + private fun createParticle( + index: Int, + simulationTime: Float, + ): Particle { + val position = latticePosition(index = index, simulationTime = simulationTime) + val glyph = Particle.Companion.Glyphs.forType(fluidType.particleType, life = 1f, useUnicode = true) + + return Particle( + position = position, + velocity = velocity * fluidType.diffusionRate, + life = 1f, + type = fluidType.particleType, + glyph = glyph, + ) + } + + private fun latticePosition( + index: Int, + simulationTime: Float, + ): Vector2 { + val columns = max(1, sqrt(particleBudget.toFloat()).toInt()) + val rows = max(1, ceil(particleBudget.toFloat() / columns).toInt()) + + val col = index % columns + val row = (index / columns) % rows + + val baseX = bounds.x + (col + 0.5f) / columns * bounds.width + val baseY = bounds.y + (row + 0.5f) / rows * bounds.height + + // Mild deterministic drift to keep the field visually alive. + val phase = simulationTime * fluidType.diffusionRate + index * 0.41f + val driftX = sin(phase) * 0.25f + val driftY = cos(phase) * 0.25f + + val maxX = (bounds.maxXExclusive - 1).toFloat() + val maxY = (bounds.maxYExclusive - 1).toFloat() + + return Vector2( + x = (baseX + driftX).coerceIn(bounds.x.toFloat(), maxX), + y = (baseY + driftY).coerceIn(bounds.y.toFloat(), maxY), + ) + } + + private fun recomputePressure() { + pressure = amount * fluidType.density + } + + companion object { + const val DEFAULT_PARTICLE_BUDGET: Int = 48 + } +} diff --git a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/field/FluidSimulationTest.kt b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/field/FluidSimulationTest.kt new file mode 100644 index 0000000..8413b4a --- /dev/null +++ b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/field/FluidSimulationTest.kt @@ -0,0 +1,239 @@ +package link.socket.phosphor.field + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +private const val FLOAT_TOLERANCE = 1e-6f + +class FluidTypeTest { + @Test + fun `custom fluid validates parameters`() { + assertFailsWith { + FluidType.Custom( + name = "custom", + viscosity = -0.1f, + density = 1f, + diffusionRate = 0.2f, + ) + } + + assertFailsWith { + FluidType.Custom( + name = "custom", + viscosity = 0.1f, + density = 0f, + diffusionRate = 0.2f, + ) + } + + assertFailsWith { + FluidType.Custom( + name = "custom", + viscosity = 0.1f, + density = 1f, + diffusionRate = -1f, + ) + } + + assertFailsWith { + FluidType.Custom( + name = " ", + viscosity = 0.1f, + density = 1f, + diffusionRate = 0.2f, + ) + } + } + + @Test + fun `preset fluids expose distinct behavior profiles`() { + assertTrue(FluidType.Fire.diffusionRate > FluidType.Water.diffusionRate) + assertTrue(FluidType.Water.density > FluidType.Air.density) + assertTrue(FluidType.Air.viscosity < FluidType.Water.viscosity) + } +} + +class EdgeForceTest { + @Test + fun `edge force transfers fluid between adjacent volumes`() { + val world = PhosphorWorld(width = 20, height = 10, fixedTimeStep = 0.1f) + val left = + Volume( + id = "left", + bounds = VolumeBounds(x = 0, y = 0, width = 10, height = 10), + fluidType = FluidType.Water, + initialAmount = 1f, + ) + val right = + Volume( + id = "right", + bounds = VolumeBounds(x = 10, y = 0, width = 10, height = 10), + fluidType = FluidType.Water, + initialAmount = 0f, + ) + + world.addVolume(left) + world.addVolume(right) + world.addEdgeForce(EdgeForce(fromVolumeId = "left", toVolumeId = "right")) + + world.step(0.1f) + + assertTrue(left.amount < 1f, "Source volume should lose fluid") + assertTrue(right.amount > 0f, "Target volume should gain fluid") + assertTrue(right.velocity.x > 0f, "Target volume should receive positive x impulse") + } + + @Test + fun `edge force does not transfer across non-adjacent volumes`() { + val world = PhosphorWorld(width = 30, height = 10, fixedTimeStep = 0.1f) + val left = + Volume( + id = "left", + bounds = VolumeBounds(x = 0, y = 0, width = 10, height = 10), + fluidType = FluidType.Water, + initialAmount = 1f, + ) + val right = + Volume( + id = "right", + bounds = VolumeBounds(x = 11, y = 0, width = 10, height = 10), + fluidType = FluidType.Water, + initialAmount = 0f, + ) + + world.addVolume(left) + world.addVolume(right) + world.addEdgeForce(EdgeForce(fromVolumeId = "left", toVolumeId = "right")) + + world.step(0.1f) + + assertEquals(1f, left.amount, FLOAT_TOLERANCE) + assertEquals(0f, right.amount, FLOAT_TOLERANCE) + } +} + +class PhosphorWorldTest { + @Test + fun `world indexes volumes in spatial partitions`() { + val world = PhosphorWorld(width = 64, height = 32, partitionSize = 8) + val volume = + Volume( + id = "v1", + bounds = VolumeBounds(x = 12, y = 4, width = 10, height = 6), + fluidType = FluidType.Air, + initialAmount = 0.2f, + ) + + world.addVolume(volume) + + val found = world.volumesAt(x = 15, y = 7) + val missing = world.volumesAt(x = 40, y = 20) + + assertEquals(1, found.size) + assertEquals("v1", found.first().id) + assertTrue(missing.isEmpty()) + } + + @Test + fun `update uses fixed timestep and interleaved solver passes`() { + val world = PhosphorWorld(width = 20, height = 10, fixedTimeStep = 0.1f) + world.addVolume( + Volume( + id = "core", + bounds = VolumeBounds(x = 0, y = 0, width = 20, height = 10), + fluidType = FluidType.Water, + initialAmount = 0.4f, + ), + ) + + val solverA = RecordingSolver() + val solverB = RecordingSolver() + world.setSolvers(listOf(solverA, solverB)) + + val steps = world.update(0.25f) + + assertEquals(2, steps) + assertEquals(2, solverA.calls) + assertEquals(2, solverB.calls) + assertEquals(0.05f, solverA.deltaTimes.first(), FLOAT_TOLERANCE) + assertEquals(0.05f, solverB.deltaTimes.first(), FLOAT_TOLERANCE) + assertEquals(0.2f, world.simulationTime, FLOAT_TOLERANCE) + } + + @Test + fun `connectAdjacentVolumes creates bidirectional edges once`() { + val world = PhosphorWorld(width = 20, height = 10) + world.addVolume( + Volume( + id = "left", + bounds = VolumeBounds(x = 0, y = 0, width = 10, height = 10), + fluidType = FluidType.Water, + ), + ) + world.addVolume( + Volume( + id = "right", + bounds = VolumeBounds(x = 10, y = 0, width = 10, height = 10), + fluidType = FluidType.Water, + ), + ) + + val first = world.connectAdjacentVolumes() + val second = world.connectAdjacentVolumes() + + assertEquals(2, first) + assertEquals(0, second) + assertEquals(2, world.edgeForceCount) + } + + @Test + fun `particle output is derived from fluid state`() { + val world = PhosphorWorld(width = 20, height = 10, fixedTimeStep = 0.1f) + world.addVolume( + Volume( + id = "left", + bounds = VolumeBounds(x = 0, y = 0, width = 10, height = 10), + fluidType = FluidType.Water, + initialAmount = 1f, + particleBudget = 12, + ), + ) + + world.step(0.1f) + val particles = world.toParticleSystem(maxParticles = 12).getParticles() + + assertEquals(12, particles.size) + assertTrue(particles.all { particle -> particle.type == ParticleType.MOTE }) + } + + @Test + fun `adding out-of-bounds volume fails`() { + val world = PhosphorWorld(width = 10, height = 10) + val invalid = + Volume( + id = "invalid", + bounds = VolumeBounds(x = 8, y = 8, width = 5, height = 5), + fluidType = FluidType.Water, + ) + + assertFailsWith { + world.addVolume(invalid) + } + } +} + +private class RecordingSolver : Solver { + var calls: Int = 0 + private set + val deltaTimes = mutableListOf() + + override fun solve( + world: PhosphorWorld, + deltaTime: Float, + ) { + calls++ + deltaTimes.add(deltaTime) + } +}