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
@@ -0,0 +1,40 @@
package link.socket.phosphor.lumos

/**
* State for an active glyph.
*
* Tracks remaining time and emits the progress envelope used by frame builders
* to fade glyph-member voxels in and out.
*/
internal data class GlyphLifecycle(
val glyph: LumosGlyph,
val totalDurationSeconds: Float,
val ageSeconds: Float,
) {
val progress: Float get() = (ageSeconds / totalDurationSeconds).coerceIn(0f, 1f)

val isComplete: Boolean get() = ageSeconds >= totalDurationSeconds

val visibility: Float
get() {
val p = progress
val fadeIn = 0.20f
val fadeOut = 0.80f
return when {
p < fadeIn -> smoothstep(0f, fadeIn, p)
p > fadeOut -> 1f - smoothstep(fadeOut, 1f, p)
else -> 1f
}
}

fun advance(dt: Float): GlyphLifecycle = copy(ageSeconds = ageSeconds + dt)

private fun smoothstep(
edge0: Float,
edge1: Float,
x: Float,
): Float {
val t = ((x - edge0) / (edge1 - edge0)).coerceIn(0f, 1f)
return t * t * (3f - 2f * t)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package link.socket.phosphor.lumos

import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt

/**
* A 2D shape predicate that classifies points in [-1, 1]^2 screen-space as
* inside-the-glyph or outside.
*
* The screen-space coordinate is the voxel's unit direction, rotated into
* camera space, projected to its X/Y components.
*/
fun interface GlyphShape {
fun contains(
screenX: Float,
screenY: Float,
): Boolean

companion object {
fun forGlyph(glyph: LumosGlyph): GlyphShape =
when (glyph) {
LumosGlyph.CHECK -> CheckShape
LumosGlyph.EXCLAIM -> ExclaimShape
LumosGlyph.QUESTION -> QuestionShape
LumosGlyph.HEART -> HeartShape
LumosGlyph.STAR -> StarShape
LumosGlyph.LIGHTNING -> LightningShape
}
}
}

internal object CheckShape : GlyphShape {
override fun contains(
screenX: Float,
screenY: Float,
): Boolean =
distanceToSegment(screenX, screenY, -0.50f, 0.00f, -0.10f, -0.40f) < 0.105f ||
distanceToSegment(screenX, screenY, -0.10f, -0.40f, 0.52f, 0.42f) < 0.105f
}

internal object ExclaimShape : GlyphShape {
override fun contains(
screenX: Float,
screenY: Float,
): Boolean {
val stem = abs(screenX) < 0.08f && screenY in -0.35f..0.35f
val dot = distance(screenX, screenY, 0f, -0.55f) < 0.12f
return stem || dot
}
}

internal object QuestionShape : GlyphShape {
override fun contains(
screenX: Float,
screenY: Float,
): Boolean {
val dx = screenX - 0.02f
val dy = screenY - 0.20f
val radius = sqrt(dx * dx + dy * dy)
val angle = atan2(dy, dx)
val arc = abs(radius - 0.32f) < 0.085f && angle in -0.15f..2.95f
val hook = distanceToSegment(screenX, screenY, 0.25f, 0.06f, 0.02f, -0.24f) < 0.085f
val dot = distance(screenX, screenY, 0f, -0.55f) < 0.12f
return arc || hook || dot
}
}

internal object HeartShape : GlyphShape {
override fun contains(
screenX: Float,
screenY: Float,
): Boolean {
val x = screenX * 1.18f
val y = screenY * 1.18f + 0.10f
val field = x * x + y * y - 0.60f
return field * field * field - x * x * y * y * y < 0f
}
}

internal object StarShape : GlyphShape {
private val vertices: List<Pair<Float, Float>> =
buildList {
repeat(STAR_VERTEX_COUNT) { index ->
val radius = if (index % 2 == 0) OUTER_RADIUS else INNER_RADIUS
val angle = (PI / 2.0 + index * PI / 5.0).toFloat()
add(cos(angle) * radius to sin(angle) * radius)
}
}

override fun contains(
screenX: Float,
screenY: Float,
): Boolean = pointInPolygon(screenX, screenY, vertices)

private const val STAR_VERTEX_COUNT = 10
private const val OUTER_RADIUS = 0.58f
private const val INNER_RADIUS = 0.25f
}

internal object LightningShape : GlyphShape {
override fun contains(
screenX: Float,
screenY: Float,
): Boolean =
distanceToSegment(screenX, screenY, 0.24f, 0.52f, -0.08f, 0.03f) < 0.10f ||
distanceToSegment(screenX, screenY, -0.08f, 0.03f, 0.14f, 0.03f) < 0.10f ||
distanceToSegment(screenX, screenY, 0.14f, 0.03f, -0.24f, -0.52f) < 0.10f
}

private fun distanceToSegment(
x: Float,
y: Float,
ax: Float,
ay: Float,
bx: Float,
by: Float,
): Float {
val vx = bx - ax
val vy = by - ay
val lengthSquared = vx * vx + vy * vy
if (lengthSquared == 0f) return distance(x, y, ax, ay)

val t = (((x - ax) * vx + (y - ay) * vy) / lengthSquared).coerceIn(0f, 1f)
return distance(x, y, ax + vx * t, ay + vy * t)
}

private fun distance(
x: Float,
y: Float,
cx: Float,
cy: Float,
): Float {
val dx = x - cx
val dy = y - cy
return sqrt(dx * dx + dy * dy)
}

private fun pointInPolygon(
x: Float,
y: Float,
vertices: List<Pair<Float, Float>>,
): Boolean {
var inside = false
var previousIndex = vertices.lastIndex

for (index in vertices.indices) {
val (xi, yi) = vertices[index]
val (xj, yj) = vertices[previousIndex]
val intersects = (yi > y) != (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi
if (intersects) {
inside = !inside
}
previousIndex = index
}

return inside
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package link.socket.phosphor.lumos

import kotlinx.serialization.Serializable

/**
* Canonical glyph identifiers for the Lumos voxel-orb visualization.
*
* Glyphs are punctuated single-shot animations, distinct from
* [link.socket.phosphor.signal.AtmosphereState], which describes continuous
* scene-global character. Renderers receive an active glyph via [VoxelFrame.glyph].
*/
@Serializable
enum class LumosGlyph(
/**
* Semantic accent color in HSL, applied to glyph-member voxels.
*
* Renderers convert this to sRGB at frame-build time.
*/
val hue: Float,
val saturation: Float,
val lightness: Float,
) {
/** Completion / task done. */
CHECK(hue = 145f, saturation = 0.65f, lightness = 0.55f),

/** Attention required / warning. */
EXCLAIM(hue = 32f, saturation = 0.95f, lightness = 0.55f),

/** CHI escalation / uncertainty surface. */
QUESTION(hue = 280f, saturation = 0.70f, lightness = 0.60f),

/** Affirmation / preference. */
HEART(hue = 340f, saturation = 0.80f, lightness = 0.60f),

/** Achievement / milestone. */
STAR(hue = 50f, saturation = 0.95f, lightness = 0.65f),

/** Active execution / spark. */
LIGHTNING(hue = 244f, saturation = 0.85f, lightness = 0.60f),
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import kotlin.math.sin
import link.socket.phosphor.color.NeutralColor
import link.socket.phosphor.field.Voxel
import link.socket.phosphor.field.VoxelSphere
import link.socket.phosphor.field.facingCamera
import link.socket.phosphor.math.Vector3
import link.socket.phosphor.runtime.SceneSnapshot
import link.socket.phosphor.signal.AtmospherePattern
import link.socket.phosphor.signal.AtmosphereState
Expand Down Expand Up @@ -74,6 +76,25 @@ class VoxelFrameBuilder(
var voxelSphere: VoxelSphere = VoxelSphere(initialResolution)
private set

private var activeGlyph: GlyphLifecycle? = null

/** True if a glyph is currently being rendered. */
val hasActiveGlyph: Boolean get() = activeGlyph != null

/** Queue a glyph for display. Replaces any currently active glyph. */
fun queueGlyph(
glyph: LumosGlyph,
durationSeconds: Float = 1.5f,
) {
require(durationSeconds > 0f) { "durationSeconds must be > 0" }
activeGlyph =
GlyphLifecycle(
glyph = glyph,
totalDurationSeconds = durationSeconds,
ageSeconds = 0f,
)
}

/**
* Produce a [VoxelFrame] from [snapshot], advancing phase accumulators
* and rotations by [dt] seconds.
Expand All @@ -96,17 +117,43 @@ class VoxelFrameBuilder(
voxelSphere = voxelSphere.rebuild(atmosphere.resolution)
}

val glyphLifecycle = advanceGlyph(dt)
advancePhases(atmosphere, dt)

val pulse = 1f + sin(pulsePhase) * atmosphere.pulseAmplitude
val effectiveYSquash = atmosphere.ySquash * (config.globalYSquashOverride ?: 1f)
val glyphColor =
glyphLifecycle
?.takeIf { config.enableGlyphCarving }
?.let { NeutralColor.fromHsl(it.glyph.hue, it.glyph.saturation, it.glyph.lightness) }
val glyphShape = glyphLifecycle?.takeIf { config.enableGlyphCarving }?.let { GlyphShape.forGlyph(it.glyph) }
val glyphVisibility = glyphLifecycle?.takeIf { config.enableGlyphCarving }?.visibility ?: 0f
val glyphRotation = Vector3(orbRotationX, orbRotationY, 0f)

val cells = ArrayList<VoxelCell>(voxelSphere.voxels.size)
for (voxel in voxelSphere.voxels) {
val mix = computeMix(atmosphere, transition, voxel)
val boundaryShrink = computeBoundaryShrink(atmosphere.bipolarStrength, mix)
val color = computeVoxelColor(atmosphere, transition, mix)
val scale = atmosphere.voxelGap * pulse * boundaryShrink
val baseColor = computeVoxelColor(atmosphere, transition, mix)
val rotatedDirection = glyphShape?.let { voxel.unitDirection.rotatedBy(glyphRotation) }
val isGlyphMember =
glyphShape != null &&
rotatedDirection != null &&
voxel.facingCamera(glyphRotation, GLYPH_FACING_THRESHOLD) &&
glyphShape.contains(rotatedDirection.x, rotatedDirection.y)
val color =
if (isGlyphMember && glyphColor != null) {
NeutralColor.lerp(baseColor, glyphColor, glyphVisibility)
} else {
baseColor
}
val glyphShrink =
if (glyphLifecycle != null && config.enableGlyphCarving && !isGlyphMember) {
1f - GLYPH_BACKGROUND_SHRINK * glyphVisibility
} else {
1f
}
val scale = atmosphere.voxelGap * pulse * boundaryShrink * glyphShrink

if (config.omitBelowScale > 0f && scale < config.omitBelowScale) continue

Expand Down Expand Up @@ -145,10 +192,28 @@ class VoxelFrameBuilder(
resolution = voxelSphere.resolution,
cells = cells,
ambient = ambient,
glyph = null,
glyph =
glyphLifecycle
?.takeIf { config.enableGlyphCarving }
?.let { lifecycle ->
checkNotNull(glyphColor)
VoxelGlyphState(
glyphName = lifecycle.glyph.name,
progress = lifecycle.progress,
red = glyphColor.red,
green = glyphColor.green,
blue = glyphColor.blue,
)
},
)
}

private fun advanceGlyph(dt: Float): GlyphLifecycle? {
val next = activeGlyph?.advance(dt) ?: return null
activeGlyph = next.takeUnless { it.isComplete }
return activeGlyph
}

private fun advancePhases(
atmosphere: AtmosphereState,
dt: Float,
Expand Down Expand Up @@ -243,6 +308,8 @@ class VoxelFrameBuilder(
private const val BUMP_PHI: Float = 2.5f
private const val BUMP_PHASE: Float = 1.4f
private const val BAND_GAIN: Float = 0.4f
private const val GLYPH_FACING_THRESHOLD: Float = 0.15f
private const val GLYPH_BACKGROUND_SHRINK: Float = 0.30f

internal fun evaluatePattern(
pattern: AtmospherePattern,
Expand Down
Loading
Loading