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
2 changes: 2 additions & 0 deletions phosphor-lumos/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,13 @@ kotlin {
val commonMain by getting {
dependencies {
implementation(project(":phosphor-core"))
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3")
}
}
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package link.socket.phosphor.lumos

/**
* Identifies a target surface for [LumosRenderer] output. Sibling to
* [link.socket.phosphor.renderer.RenderTarget]; the two enums are
* intentionally separate because the Lumos voxel rendering pipeline and
* the Phosphor cell-based pipeline produce different frame shapes.
*/
enum class LumosRenderTarget {
/** Native 3D voxel rendering via a host-provided 3D library (Three.js, Compose Canvas + custom voxel impl, etc.). */
VOXEL_NATIVE,

/** ANSI-projected voxel orb for terminal output. Reserved for Wave 2; declared now so consumers can match on the enum exhaustively. */
VOXEL_TERMINAL,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package link.socket.phosphor.lumos

/**
* Renders a [VoxelFrame] into a target-specific output type [T].
*
* Sibling to [link.socket.phosphor.renderer.PhosphorRenderer]; both abstractions
* coexist because the Lumos voxel pipeline and the Phosphor cell-based pipeline
* produce different geometric primitives. Consumers binding to Lumos do not need
* to interact with [link.socket.phosphor.renderer.PhosphorRenderer], and vice versa.
*
* Implementations are framework-free in the sense that they should not depend on
* a specific UI toolkit. Concrete output types (Compose draw commands, JSON for
* web bridge, ANSI text in Wave 2) live in consuming modules or platforms.
*/
interface LumosRenderer<out T> {
val target: LumosRenderTarget
val preferredFps: Int

fun render(frame: VoxelFrame): T
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package link.socket.phosphor.lumos

import kotlinx.serialization.Serializable

/**
* Serializable snapshot of one voxel-orb render tick.
*
* Sibling to [link.socket.phosphor.renderer.SimulationFrame]; the two frame
* types are intentionally separate because the Lumos voxel pipeline emits a
* list of 3D cubes with per-voxel position, scale, and color, whereas the
* Phosphor cell-based pipeline emits a 2D grid of character cells.
*
* Consumers map [VoxelFrame] to their own 3D draw primitives — Compose
* Multiplatform 3D, Three.js, or an ANSI-projected terminal renderer. This
* type carries no UI-framework dependencies.
*
* The frame is produced by the builder introduced in PHO-17 from an
* `AtmosphereState`, an `AtmosphereTransition`, and a `VoxelSphere`; this
* ticket (PHO-16) only defines the data shape.
*
* @property tick Monotonic frame counter, matching [link.socket.phosphor.renderer.SimulationFrame.tick].
* @property timestampEpochMillis Wall-clock timestamp at which the frame was produced.
* @property resolution Resolution at which this frame's voxel lattice was constructed.
* @property cells Per-voxel render data, ordered identically to `VoxelSphere.voxels`.
* @property ambient Per-frame derived parameters useful to renderers: glow intensity,
* overall rotation, atmospheric color (for halo/glow effects).
* @property glyph Active glyph state, if any. Null when no glyph is being rendered.
* Populated by PHO-18; included in the DTO now so the shape is stable.
*/
@Serializable
data class VoxelFrame(
val tick: Long,
val timestampEpochMillis: Long,
val resolution: Int,
val cells: List<VoxelCell>,
val ambient: VoxelAmbient,
val glyph: VoxelGlyphState? = null,
)

/**
* Per-voxel render data carried by [VoxelFrame].
*
* @property x Voxel x position in lattice space, post-noise post-bump post-pulse.
* @property y Voxel y position in lattice space, post-noise post-bump post-pulse.
* @property z Voxel z position in lattice space, post-noise post-bump post-pulse.
* @property scale Scale multiplier applied to the voxel cube. 1.0 = full size, 0.0 = invisible.
* Combines voxel-gap, breath pulse, bipolar boundary thinning, and glyph carving.
* @property red Final rendered red channel in sRGB, 0..1.
* @property green Final rendered green channel in sRGB, 0..1.
* @property blue Final rendered blue channel in sRGB, 0..1.
* @property alpha Optional alpha; null means "use 1.0". Reserved for future fade effects.
*/
@Serializable
data class VoxelCell(
val x: Float,
val y: Float,
val z: Float,
val scale: Float,
val red: Float,
val green: Float,
val blue: Float,
val alpha: Float? = null,
)

/**
* Per-frame ambient parameters carried by [VoxelFrame].
*
* @property glowRed Atmospheric glow red channel in sRGB, 0..1, computed as an OKLab-mid of primary and secondary atmosphere hues.
* @property glowGreen Atmospheric glow green channel in sRGB, 0..1.
* @property glowBlue Atmospheric glow blue channel in sRGB, 0..1.
* @property glowIntensity Glow intensity multiplier, drawn from `AtmosphereState.glow`.
* @property orbRotationX Continuous orb rotation around the X axis in radians (Euler XYZ), applied uniformly to all voxels.
* @property orbRotationY Continuous orb rotation around the Y axis in radians (Euler XYZ).
* @property orbRotationZ Continuous orb rotation around the Z axis in radians (Euler XYZ).
*/
@Serializable
data class VoxelAmbient(
val glowRed: Float,
val glowGreen: Float,
val glowBlue: Float,
val glowIntensity: Float,
val orbRotationX: Float,
val orbRotationY: Float,
val orbRotationZ: Float,
)

/**
* Active glyph carved into the voxel orb, carried by [VoxelFrame.glyph].
*
* @property glyphName Identifier of the active glyph (see the PHO-18 `LumosGlyph` enum).
* @property progress Glyph rendering progress, 0..1. Renderers may use this to fade glyph
* voxels in/out across the glyph's display window.
* @property red Glyph-member voxel red channel in sRGB, 0..1, distinct from base voxel color.
* @property green Glyph-member voxel green channel in sRGB, 0..1.
* @property blue Glyph-member voxel blue channel in sRGB, 0..1.
*/
@Serializable
data class VoxelGlyphState(
val glyphName: String,
val progress: Float,
val red: Float,
val green: Float,
val blue: Float,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package link.socket.phosphor.lumos

import kotlin.test.Test
import kotlin.test.assertEquals

class LumosRenderTargetTest {
@Test
fun `enum contains exactly the documented values`() {
assertEquals(
listOf(
LumosRenderTarget.VOXEL_NATIVE,
LumosRenderTarget.VOXEL_TERMINAL,
),
LumosRenderTarget.entries.toList(),
)
}

@Test
fun `valueOf resolves each documented value`() {
assertEquals(LumosRenderTarget.VOXEL_NATIVE, LumosRenderTarget.valueOf("VOXEL_NATIVE"))
assertEquals(LumosRenderTarget.VOXEL_TERMINAL, LumosRenderTarget.valueOf("VOXEL_TERMINAL"))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
package link.socket.phosphor.lumos

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNull
import kotlinx.serialization.json.Json

class VoxelFrameTest {
@Test
fun `VoxelFrame round-trips via kotlinx-serialization`() {
val frame =
VoxelFrame(
tick = 42L,
timestampEpochMillis = 99_999L,
resolution = 8,
cells =
listOf(
VoxelCell(
x = 0.1f,
y = 0.2f,
z = -0.3f,
scale = 0.85f,
red = 0.5f,
green = 0.6f,
blue = 0.7f,
),
VoxelCell(
x = 1.5f,
y = -0.25f,
z = 0.0f,
scale = 1.0f,
red = 0.1f,
green = 0.2f,
blue = 0.3f,
alpha = 0.75f,
),
),
ambient =
VoxelAmbient(
glowRed = 0.4f,
glowGreen = 0.5f,
glowBlue = 0.6f,
glowIntensity = 0.9f,
orbRotationX = 0.1f,
orbRotationY = 0.2f,
orbRotationZ = 0.3f,
),
glyph =
VoxelGlyphState(
glyphName = "SPARK",
progress = 0.65f,
red = 0.95f,
green = 0.85f,
blue = 0.75f,
),
)

val json = Json { encodeDefaults = true }
val encoded = json.encodeToString(VoxelFrame.serializer(), frame)
val decoded = json.decodeFromString(VoxelFrame.serializer(), encoded)

assertEquals(frame, decoded)
}

@Test
fun `VoxelFrame round-trips with null glyph and null alpha`() {
val frame =
VoxelFrame(
tick = 0L,
timestampEpochMillis = 0L,
resolution = 4,
cells =
listOf(
VoxelCell(
x = 0f,
y = 0f,
z = 0f,
scale = 1f,
red = 0f,
green = 0f,
blue = 0f,
),
),
ambient =
VoxelAmbient(
glowRed = 0f,
glowGreen = 0f,
glowBlue = 0f,
glowIntensity = 0f,
orbRotationX = 0f,
orbRotationY = 0f,
orbRotationZ = 0f,
),
)

val json = Json { encodeDefaults = true }
val encoded = json.encodeToString(VoxelFrame.serializer(), frame)
val decoded = json.decodeFromString(VoxelFrame.serializer(), encoded)

assertEquals(frame, decoded)
assertNull(decoded.glyph)
assertNull(decoded.cells.single().alpha)
}

@Test
fun `empty cells list is a valid degenerate frame`() {
val frame =
VoxelFrame(
tick = 1L,
timestampEpochMillis = 2L,
resolution = 0,
cells = emptyList(),
ambient =
VoxelAmbient(
glowRed = 0f,
glowGreen = 0f,
glowBlue = 0f,
glowIntensity = 0f,
orbRotationX = 0f,
orbRotationY = 0f,
orbRotationZ = 0f,
),
)

assertEquals(0, frame.cells.size)

val json = Json { encodeDefaults = true }
val encoded = json.encodeToString(VoxelFrame.serializer(), frame)
val decoded = json.decodeFromString(VoxelFrame.serializer(), encoded)

assertEquals(frame, decoded)
}

@Test
fun `VoxelFrame equality respects every field`() {
val baseAmbient =
VoxelAmbient(
glowRed = 0.4f,
glowGreen = 0.5f,
glowBlue = 0.6f,
glowIntensity = 0.9f,
orbRotationX = 0.1f,
orbRotationY = 0.2f,
orbRotationZ = 0.3f,
)
val baseCell =
VoxelCell(
x = 0.1f,
y = 0.2f,
z = 0.3f,
scale = 0.9f,
red = 0.4f,
green = 0.5f,
blue = 0.6f,
alpha = 0.7f,
)
val baseGlyph =
VoxelGlyphState(
glyphName = "SPARK",
progress = 0.5f,
red = 0.1f,
green = 0.2f,
blue = 0.3f,
)
val base =
VoxelFrame(
tick = 1L,
timestampEpochMillis = 2L,
resolution = 8,
cells = listOf(baseCell),
ambient = baseAmbient,
glyph = baseGlyph,
)

assertEquals(base, base.copy())
assertNotEquals(base, base.copy(tick = 2L))
assertNotEquals(base, base.copy(timestampEpochMillis = 3L))
assertNotEquals(base, base.copy(resolution = 9))
assertNotEquals(base, base.copy(cells = emptyList()))
assertNotEquals(base, base.copy(cells = listOf(baseCell.copy(x = 9f))))
assertNotEquals(base, base.copy(ambient = baseAmbient.copy(glowIntensity = 0.1f)))
assertNotEquals(base, base.copy(glyph = null))
assertNotEquals(base, base.copy(glyph = baseGlyph.copy(progress = 0.99f)))
}

@Test
fun `VoxelCell equality respects every field including optional alpha`() {
val cell =
VoxelCell(
x = 0.1f,
y = 0.2f,
z = 0.3f,
scale = 0.9f,
red = 0.4f,
green = 0.5f,
blue = 0.6f,
alpha = 0.7f,
)

assertEquals(cell, cell.copy())
assertNotEquals(cell, cell.copy(x = 1f))
assertNotEquals(cell, cell.copy(y = 1f))
assertNotEquals(cell, cell.copy(z = 1f))
assertNotEquals(cell, cell.copy(scale = 1f))
assertNotEquals(cell, cell.copy(red = 1f))
assertNotEquals(cell, cell.copy(green = 1f))
assertNotEquals(cell, cell.copy(blue = 1f))
assertNotEquals(cell, cell.copy(alpha = null))
}
}
Loading