Skip to content

Add LumosGlyph enum and glyph carving on VoxelFrame #33

@wow-miley

Description

@wow-miley

Context

The Lumos prototype includes a glyph system: small symbolic shapes (check, exclaim, question, heart, star, lightning) that briefly "carve" through the orb's voxel surface to communicate discrete events. The check glyph fires when a task completes; the question glyph fires during CHI uncertainty escalation; the exclaim glyph fires when something needs attention.

Glyphs are load-bearing for Live Arc's UX moments — without them, the orb has no way to communicate completion versus continuation. They're also strongly differentiated visually from atmosphere changes: atmospheres are continuous parameterized states, while glyphs are punctuated single-shot animations.

This ticket adds the glyph system to :phosphor-lumos. It extends the VoxelFrameBuilder (#32) with glyph queueing, defines the canonical six glyphs as 2D shape predicates, and populates the VoxelGlyphState field on output frames.

What a glyph is, mechanically

A glyph is a 2D shape projected onto the front-facing hemisphere of the voxel sphere. For the brief duration of the glyph, voxels classified as "inside the glyph shape" get rendered with a distinct color (the glyph's accent color), while voxels outside the glyph shape may have their scale slightly reduced to make the glyph "pop" geometrically against the surrounding atmosphere.

The classification uses Voxel.facingCamera(orbRotation, threshold) from #21 to identify camera-facing voxels, then maps each facing voxel's screen-space (theta, phi → 2D projection) position against the glyph's shape predicate.

Why glyphs are different from atmospheres

Atmosphere Glyph
Duration Continuous (until explicitly changed) Punctuated (typical ~1.5s)
Driver runtime.setAtmosphere(...) builder.queueGlyph(...)
Visual unit Whole orb expressing a character Discrete symbol superimposed on the orb
Animation Crossfaded by AtmosphereChoreographer Lifecycle envelope (fade-in, hold, fade-out)

Objective

Add LumosGlyph enum (six glyphs), GlyphShape predicate interface, and shape implementations for the six canonical glyphs. Extend VoxelFrameBuilder with a queueGlyph(name, durationSeconds) method that schedules the glyph and populates VoxelFrame.glyph for the duration. Voxels classified as glyph-member get their color overridden by the glyph's accent color in the output frame.

Expected outcomes

A new file phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosGlyph.kt:

/**
 * Canonical glyph identifiers for the Lumos voxel-orb visualization.
 * Each glyph carries a 2D shape predicate plus a semantic accent color
 * suggesting its meaning.
 *
 * Glyphs are punctuated single-shot animations — distinct from
 * 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),
}

A new file phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/GlyphShape.kt:

/**
 * A 2D shape predicate that classifies points in [-1, 1]^2 screen-space
 * as inside-the-glyph or outside. Used to determine which camera-facing
 * voxels are "glyph members" during glyph rendering.
 *
 * Implementations are stateless and may be cached. 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 shape implementations as objects in the same file (or a separate GlyphShapes.kt), each implementing GlyphShape:

  • CheckShape: a chevron consisting of two line segments. Roughly, points (x, y) are inside if they fall within a distance threshold of either the line from (-0.5, 0.0) to (-0.1, -0.4) or the line from (-0.1, -0.4) to (0.5, 0.4). Tune thresholds to give the glyph good visual weight at typical voxel resolutions.
  • ExclaimShape: a vertical bar (the stem of the exclamation point) plus a dot below. Bar: |x| < 0.08 && y in -0.4..0.2. Dot: distance from (0, -0.5) < 0.12.
  • QuestionShape: a curved arc plus a dot. Approximate the arc with circle-segment math; use the dot pattern from EXCLAIM at the bottom.
  • HeartShape: classic heart curve via (x² + y² − 0.6)³ − x²·y³ < 0 with y flipped (since screen Y typically points down). Adjust the constant 0.6 to taste.
  • StarShape: five-point star. Compute as union of five rotated triangles, or sample against the standard star polygon at angles 90° + k·72° for k in 0..4. Tune for clean appearance at typical resolutions.
  • LightningShape: zig-zag bolt — two or three line segments forming a "Z" or "N" shape. E.g., line (0.2, 0.5) to (-0.1, 0.0), then (-0.1, 0.0) to (0.1, 0.0), then (0.1, 0.0) to (-0.2, -0.5).

These shape implementations are approximations of recognizable symbols, not pixel-perfect glyphs. At ~1500 voxels with rotation, exact pixel-accuracy isn't possible — what matters is silhouette recognizability. The agent implementing this can iterate the constants until each glyph reads cleanly.

A new file phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/GlyphLifecycle.kt:

/**
 * State for an active glyph. Tracks remaining time and emits the
 * progress envelope (0..1, with fade-in / hold / fade-out shape).
 */
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
  
    /** Visibility envelope: 0 at start, ramps to 1 over first ~20% of duration,
     *  holds 1 for middle ~60%, ramps back to 0 over last ~20%. Smoothstep
     *  applied to fade edges. */
    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)
    }
}

Extensions to VoxelFrameBuilder (#32)

Add these to VoxelFrameBuilder in the same file or via extension:

private var activeGlyph: GlyphLifecycle? = null

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

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

The build() method (defined in #32) must be updated to:

  1. Advance the active glyph by dt at the start of the build, and clear it if isComplete.
  2. If activeGlyph != null and config.enableGlyphCarving:
    • Resolve the GlyphShape for the glyph.
    • For each voxel: project to screen-space (rotated unitDirection.x and unitDirection.y); test shape.contains(screenX, screenY). Voxels that pass are glyph members.
    • Glyph-member voxels get their color overridden by the glyph's accent color (lerping the atmosphere color toward the glyph color by lifecycle.visibility).
    • Optionally, non-glyph-member voxels have their scale reduced by ~30% multiplied by lifecycle.visibility to "pop" the glyph forward. Tune the factor for visual quality.
    • Populate VoxelFrame.glyph = VoxelGlyphState(glyphName = glyph.name, progress = lifecycle.progress, red, green, blue) where the RGB is the glyph's accent color in sRGB.

Unit tests

phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/LumosGlyphTest.kt:

  • Enum has exactly six values with the documented names.
  • Each glyph's accent color resolves to a non-grey sRGB (sanity check: saturation > 0).

phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/GlyphShapeTest.kt:

  • Each shape's contains returns true for at least one point and false for at least one other (basic sanity).
  • GlyphShape.forGlyph(CHECK) returns the CHECK shape and similarly for all six.

phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/GlyphLifecycleTest.kt:

  • progress linearly tracks ageSeconds / totalDurationSeconds.
  • visibility is 0 at progress=0, ramps to 1 by progress=0.2, holds 1 until progress=0.8, returns to 0 by progress=1.0.
  • advance(dt) returns a new instance with incremented age.
  • isComplete flips at ageSeconds >= totalDurationSeconds.

Extensions to VoxelFrameBuilderTest.kt:

  • queueGlyph(CHECK) causes the next build() to populate VoxelFrame.glyph with glyphName == "CHECK".
  • After the glyph's duration elapses, subsequent frames have glyph == null.
  • Glyph-member voxels in the rendered frame have RGB colors close to the glyph's accent color (sample a known voxel inside the glyph shape and verify).
  • A second queueGlyph(...) call mid-glyph replaces the active glyph rather than queueing behind it.

Technical constraints

  • KMP common code only.
  • All shape predicates are pure functions of (screenX, screenY). No randomness, no state.
  • Lifecycle state lives entirely inside VoxelFrameBuilder — does not need to be on SceneSnapshot. Glyphs are renderer-side state, not runtime-side state, because they're punctuated UX moments rather than scene-global character.
  • GlyphShape uses fun interface so future shapes can be added by consumers without touching the enum.
  • Shape constants (CheckShape, ExclaimShape, etc.) are internal objects — consumers reach them via GlyphShape.forGlyph(...).
  • No anthropomorphizing language. Glyphs are symbolic shapes, not "expressions" or "reactions". Lumos is referred to with the pronoun "it".
  • Run ./gradlew :phosphor-lumos:jvmTest and ./gradlew ktlintFormat before completion. iOS compile gate (CI-enforced).

API surface verification before starting

  1. Confirm Voxel.facingCamera(orbRotation, threshold) exists in field/VoxelSphere.kt (final API per Wave 0 recon A4). If the parameter is still named orbQuaternion, note that PHO-19 will rename it — for now use whatever name is current and adapt with PHO-19's rename.
  2. Confirm VoxelGlyphState is on VoxelFrame per Add LumosRenderer interface and VoxelFrame data class #30.

Out of scope

  • Driving glyph emission from CognitivePhase transitions or any other AMPERE signal (Wave 2 — an AmpereGlyphBridge or similar).
  • Glyph queueing (FIFO) — for now, queuing a new glyph replaces the active one. Queueing semantics can be added later if needed.
  • Custom user-defined glyphs registered at runtime. The six canonical glyphs are the MVP set; future tickets can extend the enum.
  • Animated shape morphing between glyphs.
  • CLI projection of glyphs (Wave 2).

Verification

  • ./gradlew :phosphor-lumos:jvmTest passes all new tests.
  • Manual visual verification: produce a VoxelFrame with each glyph active and dump cell colors to confirm distinct accent color clusters appear in the expected screen-space regions.
  • A 1.5s glyph display followed by a 1.5s no-glyph period in successive build() calls produces exactly the expected sequence of frame.glyph != null then frame.glyph == null transitions.

Metadata

Metadata

Assignees

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions