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:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user