Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import kotlin.math.acos
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
import link.socket.phosphor.coordinate.CoordinateSpace
import link.socket.phosphor.coordinate.CoordinateTransform
import link.socket.phosphor.math.Vector2
import link.socket.phosphor.math.Vector3
import link.socket.phosphor.signal.AgentActivityState
Expand Down Expand Up @@ -48,6 +50,23 @@ class AgentLayer(
private val agents = mutableMapOf<String, AgentVisualState>()
private val spawnProgress = mutableMapOf<String, Float>()

/**
* The coordinate space that agent positions are expressed in.
*
* 3D-aware layouts ([AgentLayoutOrientation.CIRCULAR], [AgentLayoutOrientation.SPHERE],
* [AgentLayoutOrientation.CLUSTERED]) produce positions in [CoordinateSpace.WORLD_CENTERED].
* 2D layouts and [AgentLayoutOrientation.CUSTOM] use [CoordinateSpace.WORLD_POSITIVE].
*/
val coordinateSpace: CoordinateSpace
get() =
when (orientation) {
AgentLayoutOrientation.CIRCULAR,
AgentLayoutOrientation.SPHERE,
AgentLayoutOrientation.CLUSTERED,
-> CoordinateSpace.WORLD_CENTERED
else -> CoordinateSpace.WORLD_POSITIVE
}

/** All current agents */
val allAgents: List<AgentVisualState> get() = agents.values.toList()

Expand Down Expand Up @@ -376,6 +395,57 @@ class AgentLayer(
agents.clear()
spawnProgress.clear()
}

/**
* Get an agent's 2D position converted to the requested coordinate space.
*
* @param agentId The agent to query
* @param targetSpace The desired coordinate space
* @param worldWidth The world extent along X (needed for conversion)
* @param worldDepth The world extent along Z (needed for conversion)
* @return The position in [targetSpace], or null if agent not found
*/
fun getAgentPositionIn(
agentId: String,
targetSpace: CoordinateSpace,
worldWidth: Float,
worldDepth: Float,
): Vector2? {
val agent = agents[agentId] ?: return null
return CoordinateTransform.convert(
agent.position,
worldWidth,
worldDepth,
from = coordinateSpace,
to = targetSpace,
)
}

/**
* Get an agent's 3D position converted to the requested coordinate space.
* Y (height) is preserved during conversion.
*
* @param agentId The agent to query
* @param targetSpace The desired coordinate space
* @param worldWidth The world extent along X (needed for conversion)
* @param worldDepth The world extent along Z (needed for conversion)
* @return The position in [targetSpace], or null if agent not found
*/
fun getAgentPosition3DIn(
agentId: String,
targetSpace: CoordinateSpace,
worldWidth: Float,
worldDepth: Float,
): Vector3? {
val agent = agents[agentId] ?: return null
return CoordinateTransform.convert(
agent.position3D,
worldWidth,
worldDepth,
from = coordinateSpace,
to = targetSpace,
)
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package link.socket.phosphor.coordinate

import link.socket.phosphor.math.Vector2
import link.socket.phosphor.math.Vector3

/**
* Defines the two coordinate spaces used by Phosphor's rendering pipeline.
*
* - [WORLD_CENTERED]: Origin at center of the world. Coordinates range from
* `-size/2` to `+size/2`. Used by [CognitiveWaveform.worldPosition] and
* the 3D camera/projection pipeline.
*
* - [WORLD_POSITIVE]: Origin at the corner (minimum). Coordinates range from
* `0` to `size`. Used by the waveform grid sampling loop and substrate
* density lookups.
*/
enum class CoordinateSpace {
/** Origin at center, e.g. -w/2 .. +w/2 */
WORLD_CENTERED,

/** Origin at corner, e.g. 0 .. w */
WORLD_POSITIVE,
}

/**
* Converts positions between [CoordinateSpace.WORLD_CENTERED] and
* [CoordinateSpace.WORLD_POSITIVE].
*
* All conversions are symmetric and round-trip safe:
* ```
* toCentered(toPositive(x, size), size) == x
* toPositive(toCentered(x, size), size) == x
* ```
*/
object CoordinateTransform {
/**
* Convert a scalar from [from] space to [CoordinateSpace.WORLD_POSITIVE].
*
* @param pos The coordinate value to convert
* @param worldSize The extent of the axis (width or depth)
* @param from The source coordinate space
* @return The coordinate in WORLD_POSITIVE space
*/
fun toPositive(
pos: Float,
worldSize: Float,
from: CoordinateSpace,
): Float =
when (from) {
CoordinateSpace.WORLD_POSITIVE -> pos
CoordinateSpace.WORLD_CENTERED -> pos + worldSize / 2f
}

/**
* Convert a scalar from [from] space to [CoordinateSpace.WORLD_CENTERED].
*
* @param pos The coordinate value to convert
* @param worldSize The extent of the axis (width or depth)
* @param from The source coordinate space
* @return The coordinate in WORLD_CENTERED space
*/
fun toCentered(
pos: Float,
worldSize: Float,
from: CoordinateSpace,
): Float =
when (from) {
CoordinateSpace.WORLD_CENTERED -> pos
CoordinateSpace.WORLD_POSITIVE -> pos - worldSize / 2f
}

/**
* Convert a [Vector2] position between coordinate spaces.
*
* @param pos The 2D position to convert (x maps to world-X, y maps to world-Z)
* @param worldWidth Extent of the X axis
* @param worldDepth Extent of the Z axis
* @param from Source coordinate space
* @param to Target coordinate space
* @return Converted position
*/
fun convert(
pos: Vector2,
worldWidth: Float,
worldDepth: Float,
from: CoordinateSpace,
to: CoordinateSpace,
): Vector2 {
if (from == to) return pos
return when (to) {
CoordinateSpace.WORLD_POSITIVE ->
Vector2(
toPositive(pos.x, worldWidth, from),
toPositive(pos.y, worldDepth, from),
)
CoordinateSpace.WORLD_CENTERED ->
Vector2(
toCentered(pos.x, worldWidth, from),
toCentered(pos.y, worldDepth, from),
)
}
}

/**
* Convert a [Vector3] position between coordinate spaces.
* Only X and Z are converted; Y (height) is preserved.
*
* @param pos The 3D position to convert
* @param worldWidth Extent of the X axis
* @param worldDepth Extent of the Z axis
* @param from Source coordinate space
* @param to Target coordinate space
* @return Converted position with Y unchanged
*/
fun convert(
pos: Vector3,
worldWidth: Float,
worldDepth: Float,
from: CoordinateSpace,
to: CoordinateSpace,
): Vector3 {
if (from == to) return pos
return when (to) {
CoordinateSpace.WORLD_POSITIVE ->
Vector3(
toPositive(pos.x, worldWidth, from),
pos.y,
toPositive(pos.z, worldDepth, from),
)
CoordinateSpace.WORLD_CENTERED ->
Vector3(
toCentered(pos.x, worldWidth, from),
pos.y,
toCentered(pos.z, worldDepth, from),
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package link.socket.phosphor.render
import kotlin.math.exp
import kotlin.math.sqrt
import link.socket.phosphor.choreography.AgentLayer
import link.socket.phosphor.coordinate.CoordinateSpace
import link.socket.phosphor.coordinate.CoordinateTransform
import link.socket.phosphor.field.FlowLayer
import link.socket.phosphor.field.SubstrateState
import link.socket.phosphor.math.Vector3
Expand Down Expand Up @@ -30,6 +32,7 @@ class CognitiveWaveform(
val gridDepth: Int = 30,
val worldWidth: Float = 20f,
val worldDepth: Float = 15f,
val agentCoordinateSpace: CoordinateSpace = CoordinateSpace.WORLD_POSITIVE,
) {
/** Height values at each grid point. Updated each frame. */
val heights: FloatArray = FloatArray(gridWidth * gridDepth)
Expand Down Expand Up @@ -171,10 +174,11 @@ class CognitiveWaveform(

val radius = defaultInfluenceRadius

// Convert agent's 2D position to world-space fraction
// Agent positions are in screen/grid coordinates; normalize them
val agentWorldX = agent.position.x
val agentWorldZ = agent.position.y
// Convert agent position to positive space (matching the grid sampling loop below)
val agentWorldX =
CoordinateTransform.toPositive(agent.position.x, worldWidth, agentCoordinateSpace)
val agentWorldZ =
CoordinateTransform.toPositive(agent.position.y, worldDepth, agentCoordinateSpace)

for (gz in 0 until gridDepth) {
for (gx in 0 until gridWidth) {
Expand Down Expand Up @@ -204,10 +208,10 @@ class CognitiveWaveform(
val source = agents.getAgent(connection.sourceAgentId) ?: continue
val target = agents.getAgent(connection.targetAgentId) ?: continue

val sx = source.position.x
val sz = source.position.y
val tx = target.position.x
val tz = target.position.y
val sx = CoordinateTransform.toPositive(source.position.x, worldWidth, agentCoordinateSpace)
val sz = CoordinateTransform.toPositive(source.position.y, worldDepth, agentCoordinateSpace)
val tx = CoordinateTransform.toPositive(target.position.x, worldWidth, agentCoordinateSpace)
val tz = CoordinateTransform.toPositive(target.position.y, worldDepth, agentCoordinateSpace)

// Direction along the ridge
val ridgeDx = tx - sx
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package link.socket.phosphor.choreography

import kotlin.math.abs
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import link.socket.phosphor.coordinate.CoordinateSpace
import link.socket.phosphor.math.Vector2
import link.socket.phosphor.math.Vector3
import link.socket.phosphor.signal.AgentActivityState
Expand Down Expand Up @@ -418,6 +420,106 @@ class AgentLayerTest {
assertEquals(10f, updated.position.x, 0.01f)
assertEquals(20f, updated.position.y, 0.01f)
}

@Test
fun `coordinateSpace is WORLD_CENTERED for 3D layouts`() {
assertEquals(
CoordinateSpace.WORLD_CENTERED,
AgentLayer(100, 100, AgentLayoutOrientation.CIRCULAR).coordinateSpace,
)
assertEquals(
CoordinateSpace.WORLD_CENTERED,
AgentLayer(100, 100, AgentLayoutOrientation.SPHERE).coordinateSpace,
)
assertEquals(
CoordinateSpace.WORLD_CENTERED,
AgentLayer(100, 100, AgentLayoutOrientation.CLUSTERED).coordinateSpace,
)
}

@Test
fun `coordinateSpace is WORLD_POSITIVE for 2D layouts`() {
assertEquals(
CoordinateSpace.WORLD_POSITIVE,
AgentLayer(100, 30, AgentLayoutOrientation.HORIZONTAL).coordinateSpace,
)
assertEquals(
CoordinateSpace.WORLD_POSITIVE,
AgentLayer(100, 30, AgentLayoutOrientation.VERTICAL).coordinateSpace,
)
assertEquals(
CoordinateSpace.WORLD_POSITIVE,
AgentLayer(100, 30, AgentLayoutOrientation.CUSTOM).coordinateSpace,
)
}

@Test
fun `getAgentPositionIn converts from centered to positive`() {
val layer = AgentLayer(100, 100, AgentLayoutOrientation.CIRCULAR)
layer.addAgent(AgentVisualState("1", "A", "r", Vector2.ZERO))

// Circular layout produces centered coordinates; get position in positive space
val centered = layer.getAgent("1")!!.position
val positive =
layer.getAgentPositionIn(
"1",
CoordinateSpace.WORLD_POSITIVE,
worldWidth = 20f,
worldDepth = 15f,
)

assertNotNull(positive)
// Conversion: positive = centered + worldSize/2
assertEquals(centered.x + 10f, positive.x, 0.01f)
assertEquals(centered.y + 7.5f, positive.y, 0.01f)
}

@Test
fun `getAgentPositionIn same-space returns unchanged`() {
val layer = AgentLayer(100, 30, AgentLayoutOrientation.CUSTOM)
layer.addAgent(AgentVisualState("1", "A", "r", Vector2(10f, 7.5f)))

val result =
layer.getAgentPositionIn(
"1",
CoordinateSpace.WORLD_POSITIVE,
worldWidth = 20f,
worldDepth = 15f,
)

assertNotNull(result)
assertEquals(10f, result.x, 0.01f)
assertEquals(7.5f, result.y, 0.01f)
}

@Test
fun `getAgentPosition3DIn preserves Y during conversion`() {
val layer = AgentLayer(100, 100, AgentLayoutOrientation.CIRCULAR)
layer.addAgent(AgentVisualState("1", "A", "r", Vector2.ZERO))

// Set a custom 3D position with non-zero Y
layer.setAgentPosition3D("1", Vector3(-5f, 3f, 2f))

val positive =
layer.getAgentPosition3DIn(
"1",
CoordinateSpace.WORLD_POSITIVE,
worldWidth = 20f,
worldDepth = 15f,
)

assertNotNull(positive)
assertEquals(3f, positive.y, 0.01f) // Y preserved
assertEquals(-5f + 10f, positive.x, 0.01f) // X converted
assertEquals(2f + 7.5f, positive.z, 0.01f) // Z converted
}

@Test
fun `getAgentPositionIn returns null for unknown agent`() {
val layer = AgentLayer(100, 30)
assertNull(layer.getAgentPositionIn("nonexistent", CoordinateSpace.WORLD_POSITIVE, 20f, 15f))
assertNull(layer.getAgentPosition3DIn("nonexistent", CoordinateSpace.WORLD_POSITIVE, 20f, 15f))
}
}

class AgentVisualState3DTest {
Expand Down
Loading