You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
enumclassLumosGlyph(
/** Semantic accent color in HSL, applied to glyph-member voxels. * Renderers convert this to sRGB at frame-build time. */valhue:Float,
valsaturation:Float,
vallightness: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 interfaceGlyphShape {
funcontains(screenX:Float, screenY:Float): Booleancompanionobject {
funforGlyph(glyph:LumosGlyph): GlyphShape=when (glyph) {
LumosGlyph.CHECK->CheckShapeLumosGlyph.EXCLAIM->ExclaimShapeLumosGlyph.QUESTION->QuestionShapeLumosGlyph.HEART->HeartShapeLumosGlyph.STAR->StarShapeLumosGlyph.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).*/internaldata classGlyphLifecycle(
valglyph:LumosGlyph,
valtotalDurationSeconds:Float,
valageSeconds: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.20fval fadeOut =0.80freturnwhen {
p < fadeIn -> smoothstep(0f, fadeIn, p)
p > fadeOut ->1f- smoothstep(fadeOut, 1f, p)
else->1f
}
}
funadvance(dt:Float): GlyphLifecycle= copy(ageSeconds = ageSeconds + dt)
privatefunsmoothstep(edge0:Float, edge1:Float, x:Float): Float {
val t = ((x - edge0) / (edge1 - edge0)).coerceIn(0f, 1f)
return t * t * (3f-2f* t)
}
}
Add these to VoxelFrameBuilder in the same file or via extension:
privatevar activeGlyph:GlyphLifecycle?=null/** Queue a glyph for display. Replaces any currently active glyph. */funqueueGlyph(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:
Advance the active glyph by dt at the start of the build, and clear it if isComplete.
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.
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
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.
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.
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 theVoxelFrameBuilder(#32) with glyph queueing, defines the canonical six glyphs as 2D shape predicates, and populates theVoxelGlyphStatefield 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
runtime.setAtmosphere(...)builder.queueGlyph(...)AtmosphereChoreographerObjective
Add
LumosGlyphenum (six glyphs),GlyphShapepredicate interface, and shape implementations for the six canonical glyphs. ExtendVoxelFrameBuilderwith aqueueGlyph(name, durationSeconds)method that schedules the glyph and populatesVoxelFrame.glyphfor 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:A new file
phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/GlyphShape.kt:Internal shape implementations as
objects in the same file (or a separateGlyphShapes.kt), each implementingGlyphShape:(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.|x| < 0.08 && y in -0.4..0.2. Dot: distance from(0, -0.5)< 0.12.(x² + y² − 0.6)³ − x²·y³ < 0with y flipped (since screen Y typically points down). Adjust the constant 0.6 to taste.90° + k·72°fork in 0..4. Tune for clean appearance at typical resolutions.(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:Extensions to
VoxelFrameBuilder(#32)Add these to
VoxelFrameBuilderin the same file or via extension:The
build()method (defined in #32) must be updated to:dtat the start of the build, and clear it ifisComplete.activeGlyph != nullandconfig.enableGlyphCarving:GlyphShapefor the glyph.unitDirection.xandunitDirection.y); testshape.contains(screenX, screenY). Voxels that pass are glyph members.lifecycle.visibility).lifecycle.visibilityto "pop" the glyph forward. Tune the factor for visual quality.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:phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/GlyphShapeTest.kt:containsreturns 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:progresslinearly tracksageSeconds / totalDurationSeconds.visibilityis 0 atprogress=0, ramps to 1 byprogress=0.2, holds 1 untilprogress=0.8, returns to 0 byprogress=1.0.advance(dt)returns a new instance with incremented age.isCompleteflips atageSeconds >= totalDurationSeconds.Extensions to
VoxelFrameBuilderTest.kt:queueGlyph(CHECK)causes the nextbuild()to populateVoxelFrame.glyphwithglyphName == "CHECK".glyph == null.queueGlyph(...)call mid-glyph replaces the active glyph rather than queueing behind it.Technical constraints
(screenX, screenY). No randomness, no state.VoxelFrameBuilder— does not need to be onSceneSnapshot. Glyphs are renderer-side state, not runtime-side state, because they're punctuated UX moments rather than scene-global character.GlyphShapeusesfun interfaceso future shapes can be added by consumers without touching the enum.CheckShape,ExclaimShape, etc.) areinternal objects — consumers reach them viaGlyphShape.forGlyph(...)../gradlew :phosphor-lumos:jvmTestand./gradlew ktlintFormatbefore completion. iOS compile gate (CI-enforced).API surface verification before starting
Voxel.facingCamera(orbRotation, threshold)exists infield/VoxelSphere.kt(final API per Wave 0 recon A4). If the parameter is still namedorbQuaternion, note that PHO-19 will rename it — for now use whatever name is current and adapt with PHO-19's rename.VoxelGlyphStateis onVoxelFrameper Add LumosRenderer interface and VoxelFrame data class #30.Out of scope
CognitivePhasetransitions or any other AMPERE signal (Wave 2 — anAmpereGlyphBridgeor similar).Verification
./gradlew :phosphor-lumos:jvmTestpasses all new tests.VoxelFramewith each glyph active and dump cell colors to confirm distinct accent color clusters appear in the expected screen-space regions.build()calls produces exactly the expected sequence offrame.glyph != nullthenframe.glyph == nulltransitions.