Slice 3: tactile bullseye instrument (Codex)

- SurfaceGuidance (core/sensors, pure, tested): the sole source of
  bubble position, high-side direction, and ring scale. Bubble and
  1/2/5-degree etched rings share one 5-degree visual range; the
  clamped target puts a saturated bubble exactly at the rim ring.
- SurfaceBullseye: Compose Canvas glass vial with crosshairs and
  amber bubble. Critically damped (no-overshoot) spring toward the
  pre-clamped target; velocity carries across retargets. Reduced
  motion (in-app pref or ANIMATOR_DURATION_SCALE == 0) snaps directly.
- Lock treatment: brief lime ring pulse on acquisition, persistent
  lime tolerance label while locked, neutral numeric readout. Never
  color-only; no full-screen lime state.
- Pipeline passes stable calibrated pitch/roll to the instrument;
  deadbanded readout remains authoritative. Bullseye renders only in
  the FACE_UP presentation.

53 tests passing.

Audited-by: Claude (1 finding raised and resolved)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jay
2026-07-13 15:30:45 -04:00
parent d3854ac402
commit 422854de07
5 changed files with 159 additions and 2 deletions
@@ -0,0 +1,44 @@
package com.onthelevel.core.sensors
import kotlin.math.abs
import kotlin.math.max
/** The one source of truth for Surface bubble placement and screen-relative direction. */
object SurfaceGuidance {
const val VISUAL_RANGE_DEGREES = 5.0
const val DIRECTION_DEADBAND_DEGREES = 0.1
const val CORNER_MINOR_AXIS_RATIO = 0.25
val RING_DEGREES = listOf(1.0, 2.0, 5.0)
data class Guidance(
val normalizedX: Double,
val normalizedY: Double,
val highLabel: String?,
)
fun from(pitchDegrees: Double, rollDegrees: Double): Guidance {
val x = visualPosition(rollDegrees)
val y = -visualPosition(pitchDegrees) // Compose Y grows downward; positive pitch means top is high.
return Guidance(x, y, highLabel(pitchDegrees, rollDegrees))
}
fun ringRadiusRatio(degrees: Double): Double = (degrees / VISUAL_RANGE_DEGREES).coerceIn(0.0, 1.0)
private fun visualPosition(degrees: Double): Double =
(degrees / VISUAL_RANGE_DEGREES).coerceIn(-1.0, 1.0)
private fun highLabel(pitch: Double, roll: Double): String? {
val pitchMagnitude = abs(pitch)
val rollMagnitude = abs(roll)
val dominant = max(pitchMagnitude, rollMagnitude)
if (dominant < DIRECTION_DEADBAND_DEGREES) return null
val topOrBottom = if (pitch >= 0.0) "top" else "bottom"
val leftOrRight = if (roll >= 0.0) "right" else "left"
val minor = minOf(pitchMagnitude, rollMagnitude)
return if (minor / dominant < CORNER_MINOR_AXIS_RATIO) {
if (pitchMagnitude >= rollMagnitude) "High: $topOrBottom edge" else "High: $leftOrRight edge"
} else {
"High: $topOrBottom-$leftOrRight"
}
}
}
@@ -24,6 +24,9 @@ data class LevelReading(
val secondaryADegrees: Double?, val secondaryADegrees: Double?,
/** Surface: roll when it is meaningful. Edge: plumb lean. */ /** Surface: roll when it is meaningful. Edge: plumb lean. */
val secondaryBDegrees: Double?, val secondaryBDegrees: Double?,
/** Stable calibrated Surface axes for the visual instrument; null outside Surface mode. */
val stableSurfacePitchDegrees: Double? = null,
val stableSurfaceRollDegrees: Double? = null,
/** Surface-only presentation state; null for Edge mode. */ /** Surface-only presentation state; null for Edge mode. */
val surfacePresentation: SurfacePresentation? = null, val surfacePresentation: SurfacePresentation? = null,
/** False when the device is not physically in the selected mode's geometry. */ /** False when the device is not physically in the selected mode's geometry. */
@@ -93,6 +96,8 @@ class LevelPipeline(
} else { } else {
null null
}, },
stableSurfacePitchDegrees = pitch,
stableSurfaceRollDegrees = roll,
surfacePresentation = presentation, surfacePresentation = presentation,
placementOk = placementOk, placementOk = placementOk,
isLocked = lock.isLocked, isLocked = lock.isLocked,
@@ -70,6 +70,8 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
.collectAsStateWithLifecycle(initialValue = EdgeCalibration.NONE) .collectAsStateWithLifecycle(initialValue = EdgeCalibration.NONE)
val hapticsEnabled by container.settings.hapticsEnabled val hapticsEnabled by container.settings.hapticsEnabled
.collectAsStateWithLifecycle(initialValue = true) .collectAsStateWithLifecycle(initialValue = true)
val reducedMotion by container.settings.reducedMotion
.collectAsStateWithLifecycle(initialValue = false)
val view = LocalView.current val view = LocalView.current
val lockDetector = remember { LockDetector() } val lockDetector = remember { LockDetector() }
@@ -137,10 +139,17 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
val placementOk = reading?.placementOk ?: true val placementOk = reading?.placementOk ?: true
val surfacePresentation = reading?.surfacePresentation val surfacePresentation = reading?.surfacePresentation
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
if (mode == LevelMode.SURFACE && surfacePresentation == SurfacePresentation.FACE_UP) {
val pitch = reading?.stableSurfacePitchDegrees
val roll = reading?.stableSurfaceRollDegrees
if (pitch != null && roll != null) {
SurfaceBullseye(pitch, roll, isLocked, reducedMotion)
}
}
Text( Text(
text = reading?.let { formatDegrees(it.displayPrimaryDegrees) } ?: "", text = reading?.let { formatDegrees(it.displayPrimaryDegrees) } ?: "",
style = MaterialTheme.typography.displayLarge, style = MaterialTheme.typography.displayLarge,
color = if (isLocked) LevelColors.LimeLock else LevelColors.TextPrimary, color = LevelColors.TextPrimary,
) )
// Lock state is announced with a label, never color alone (BRIEF.md). // Lock state is announced with a label, never color alone (BRIEF.md).
// The label states the tolerance (the lock's exit threshold), so the // The label states the tolerance (the lock's exit threshold), so the
@@ -158,7 +167,11 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
else -> "" else -> ""
}, },
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
color = if (placementOk) LevelColors.LimeLockText else LevelColors.TextDim, color = when {
!placementOk -> LevelColors.TextDim
isLocked -> LevelColors.LimeLockText
else -> LevelColors.TextPrimary
},
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
} }
@@ -0,0 +1,66 @@
package com.onthelevel.feature.level
import android.provider.Settings
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.onthelevel.core.design.LevelColors
import com.onthelevel.core.sensors.SurfaceGuidance
/** Glass bullseye whose target and rings are both driven by SurfaceGuidance. */
@Composable
fun SurfaceBullseye(pitchDegrees: Double, rollDegrees: Double, isLocked: Boolean, reducedMotion: Boolean) {
val guidance = SurfaceGuidance.from(pitchDegrees, rollDegrees)
val targetX = guidance.normalizedX.toFloat()
val targetY = guidance.normalizedY.toFloat()
val animatedX = remember { Animatable(0f) }
val animatedY = remember { Animatable(0f) }
val context = LocalContext.current
val systemMotionDisabled = remember(context) {
Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
}
val lockPulse = remember { Animatable(0f) }
LaunchedEffect(targetX, targetY, reducedMotion, systemMotionDisabled) {
if (reducedMotion || systemMotionDisabled) {
animatedX.snapTo(targetX)
animatedY.snapTo(targetY)
} else {
animatedX.animateTo(targetX, spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow))
animatedY.animateTo(targetY, spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow))
}
}
LaunchedEffect(isLocked) {
if (isLocked) {
lockPulse.snapTo(1f)
lockPulse.animateTo(0f, spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessLow))
} else lockPulse.snapTo(0f)
}
Canvas(Modifier.size(250.dp)) {
val radius = size.minDimension / 2f
val center = Offset(size.width / 2f, size.height / 2f)
val vialRadius = radius * .74f
drawCircle(Brush.radialGradient(listOf(Color(0xFF22272E), Color(0xFF090A0C)), center, radius), radius, center)
drawCircle(Color.White.copy(alpha = .08f), radius, center, style = Stroke(1.5f))
SurfaceGuidance.RING_DEGREES.forEach { mark ->
drawCircle(Color.White.copy(alpha = .11f), vialRadius * SurfaceGuidance.ringRadiusRatio(mark).toFloat(), center, style = Stroke(1f))
}
drawLine(Color.White.copy(alpha = .10f), Offset(center.x - vialRadius, center.y), Offset(center.x + vialRadius, center.y), 1f)
drawLine(Color.White.copy(alpha = .10f), Offset(center.x, center.y - vialRadius), Offset(center.x, center.y + vialRadius), 1f)
if (lockPulse.value > 0f) drawCircle(LevelColors.LimeLock.copy(alpha = .35f * lockPulse.value), vialRadius, center, style = Stroke(4f))
val bubble = center + Offset(animatedX.value * vialRadius, animatedY.value * vialRadius)
drawCircle(Brush.radialGradient(listOf(LevelColors.AmberHighlight, LevelColors.Amber, LevelColors.AmberDeep), bubble, radius * .18f), radius * .17f, bubble)
drawCircle(Color.White.copy(alpha = .45f), radius * .045f, bubble + Offset(-radius * .05f, -radius * .05f))
}
}
@@ -0,0 +1,29 @@
package com.onthelevel.core.sensors
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class SurfaceGuidanceTest {
@Test fun `bubble mapping follows pitch roll and clamps before animation`() {
val guidance = SurfaceGuidance.from(2.0, -3.0)
assertEquals(-0.6, guidance.normalizedX, 1e-9)
assertEquals(-0.4, guidance.normalizedY, 1e-9)
val clamped = SurfaceGuidance.from(-8.0, 9.0)
assertEquals(1.0, clamped.normalizedX, 1e-9)
assertEquals(1.0, clamped.normalizedY, 1e-9)
}
@Test fun `direction uses edges for dominant axes and corners otherwise`() {
assertEquals("High: top edge", SurfaceGuidance.from(2.0, 0.2).highLabel)
assertEquals("High: bottom-left", SurfaceGuidance.from(-2.0, -2.0).highLabel)
assertEquals("High: top-right", SurfaceGuidance.from(2.0, 2.0).highLabel)
assertNull(SurfaceGuidance.from(0.05, -0.05).highLabel)
}
@Test fun `rings share the visual degree scale`() {
assertEquals(0.2, SurfaceGuidance.ringRadiusRatio(1.0), 1e-9)
assertEquals(0.4, SurfaceGuidance.ringRadiusRatio(2.0), 1e-9)
assertEquals(1.0, SurfaceGuidance.ringRadiusRatio(5.0), 1e-9)
}
}