From 0b81aa931a9328d6b3dd9ca23039d36bd9a7646e Mon Sep 17 00:00:00 2001 From: Miley Chandonnet Date: Sun, 24 May 2026 19:14:09 -0500 Subject: [PATCH] PHO-16: Add LumosRenderer interface and VoxelFrame data class Introduces the parallel renderer abstraction for :phosphor-lumos: LumosRenderTarget enum, @Serializable VoxelFrame (with VoxelCell, VoxelAmbient, optional VoxelGlyphState), and LumosRenderer interface. Sibling to PhosphorRenderer/SimulationFrame; both pipelines coexist. Adds explicit kotlinx-serialization-core/json deps since phosphor-core declares them as implementation. Co-Authored-By: Claude Opus 4.7 (1M context) --- phosphor-lumos/build.gradle.kts | 2 + .../phosphor/lumos/LumosRenderTarget.kt | 15 ++ .../socket/phosphor/lumos/LumosRenderer.kt | 20 ++ .../link/socket/phosphor/lumos/VoxelFrame.kt | 104 +++++++++ .../phosphor/lumos/LumosRenderTargetTest.kt | 23 ++ .../socket/phosphor/lumos/VoxelFrameTest.kt | 211 ++++++++++++++++++ 6 files changed, 375 insertions(+) create mode 100644 phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosRenderTarget.kt create mode 100644 phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosRenderer.kt create mode 100644 phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/VoxelFrame.kt create mode 100644 phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/LumosRenderTargetTest.kt create mode 100644 phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/VoxelFrameTest.kt diff --git a/phosphor-lumos/build.gradle.kts b/phosphor-lumos/build.gradle.kts index f5e1ee8..464c7e0 100644 --- a/phosphor-lumos/build.gradle.kts +++ b/phosphor-lumos/build.gradle.kts @@ -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") } } } diff --git a/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosRenderTarget.kt b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosRenderTarget.kt new file mode 100644 index 0000000..96674fb --- /dev/null +++ b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosRenderTarget.kt @@ -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, +} diff --git a/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosRenderer.kt b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosRenderer.kt new file mode 100644 index 0000000..800dba5 --- /dev/null +++ b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosRenderer.kt @@ -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 { + val target: LumosRenderTarget + val preferredFps: Int + + fun render(frame: VoxelFrame): T +} diff --git a/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/VoxelFrame.kt b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/VoxelFrame.kt new file mode 100644 index 0000000..f47a7f1 --- /dev/null +++ b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/VoxelFrame.kt @@ -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, + 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, +) diff --git a/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/LumosRenderTargetTest.kt b/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/LumosRenderTargetTest.kt new file mode 100644 index 0000000..1a39ee2 --- /dev/null +++ b/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/LumosRenderTargetTest.kt @@ -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")) + } +} diff --git a/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/VoxelFrameTest.kt b/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/VoxelFrameTest.kt new file mode 100644 index 0000000..f30bc1b --- /dev/null +++ b/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/VoxelFrameTest.kt @@ -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)) + } +}