Slice 4: actionable adjustment guidance (Codex)

- SurfaceAdjustmentInfo: high-side instruction sourced exclusively from
  SurfaceGuidance.from(displayed pitch, displayed roll).highLabel - the
  deadbanded axes supply the label hysteresis, so wording cannot flap
  at direction boundaries.
- Shared SettlingDetector exposed through the pipeline as the sole
  motion gate; strong guidance defers behind a quiet Settling state.
- RiseRun (core/sensors, pure): tan-based mm/m and in/ft, derived from
  the same deadbanded magnitude as the numeric readout; pinned at
  45 degrees = 1000 mm/m = 12 in/ft.
- Persisted MeasurementUnits preference in core/settings (metric
  default); Tools UI control remains deferred per plan.
- Guidance renders only in face-up Surface mode, hidden at level
  (no direction below the 0.1-degree deadband).

56 tests passing.

Audited-by: Claude (no findings)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jay
2026-07-13 15:43:42 -04:00
parent 422854de07
commit 027b9b96b3
7 changed files with 124 additions and 0 deletions
@@ -0,0 +1,15 @@
package com.onthelevel.core.sensors
import kotlin.math.tan
/** Converts a positive surface tilt into the conventional rise-over-run units. */
object RiseRun {
fun millimetersPerMeter(angleDegrees: Double): Double =
tan(Math.toRadians(angleDegrees)) * MILLIMETERS_PER_METER
fun inchesPerFoot(angleDegrees: Double): Double =
tan(Math.toRadians(angleDegrees)) * INCHES_PER_FOOT
private const val MILLIMETERS_PER_METER = 1_000.0
private const val INCHES_PER_FOOT = 12.0
}
@@ -0,0 +1,7 @@
package com.onthelevel.core.settings
/** Display units for construction-oriented slope guidance. */
enum class MeasurementUnits {
METRIC,
IMPERIAL,
}
@@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.doublePreferencesKey import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.onthelevel.core.sensors.EdgeCalibration import com.onthelevel.core.sensors.EdgeCalibration
import com.onthelevel.core.sensors.SurfaceCalibration import com.onthelevel.core.sensors.SurfaceCalibration
@@ -42,6 +43,14 @@ class SettingsRepository(context: Context) {
/** In-app reduced-motion preference; the system animator-scale signal is respected separately. */ /** In-app reduced-motion preference; the system animator-scale signal is respected separately. */
val reducedMotion: Flow<Boolean> = store.data.map { it[Keys.REDUCED_MOTION] ?: false } val reducedMotion: Flow<Boolean> = store.data.map { it[Keys.REDUCED_MOTION] ?: false }
/** Kept in core so every future measurement tool formats slope consistently. */
val measurementUnits: Flow<MeasurementUnits> = store.data.map { prefs ->
when (prefs[Keys.MEASUREMENT_UNITS]) {
MeasurementUnits.IMPERIAL.name -> MeasurementUnits.IMPERIAL
else -> MeasurementUnits.METRIC
}
}
suspend fun setSurfaceCalibration(calibration: SurfaceCalibration) { suspend fun setSurfaceCalibration(calibration: SurfaceCalibration) {
store.edit { store.edit {
it[Keys.SURFACE_PITCH_BIAS] = calibration.pitchBiasDegrees it[Keys.SURFACE_PITCH_BIAS] = calibration.pitchBiasDegrees
@@ -70,6 +79,10 @@ class SettingsRepository(context: Context) {
store.edit { it[Keys.REDUCED_MOTION] = enabled } store.edit { it[Keys.REDUCED_MOTION] = enabled }
} }
suspend fun setMeasurementUnits(units: MeasurementUnits) {
store.edit { it[Keys.MEASUREMENT_UNITS] = units.name }
}
// TODO(ruler): screen-ruler scale keyed by display identity/characteristics (BRIEF.md). // TODO(ruler): screen-ruler scale keyed by display identity/characteristics (BRIEF.md).
private object Keys { private object Keys {
@@ -80,5 +93,6 @@ class SettingsRepository(context: Context) {
val HAPTICS_ENABLED = booleanPreferencesKey("haptics_enabled") val HAPTICS_ENABLED = booleanPreferencesKey("haptics_enabled")
val AUDIO_CUE_ENABLED = booleanPreferencesKey("audio_cue_enabled") val AUDIO_CUE_ENABLED = booleanPreferencesKey("audio_cue_enabled")
val REDUCED_MOTION = booleanPreferencesKey("reduced_motion") val REDUCED_MOTION = booleanPreferencesKey("reduced_motion")
val MEASUREMENT_UNITS = stringPreferencesKey("measurement_units")
} }
} }
@@ -10,6 +10,7 @@ import com.onthelevel.core.sensors.OrientationMath
import com.onthelevel.core.sensors.SurfaceCalibration import com.onthelevel.core.sensors.SurfaceCalibration
import com.onthelevel.core.sensors.SurfacePresentation import com.onthelevel.core.sensors.SurfacePresentation
import com.onthelevel.core.sensors.SurfacePresentationDetector import com.onthelevel.core.sensors.SurfacePresentationDetector
import com.onthelevel.core.sensors.SettlingDetector
/** /**
* One reading through the raw → stable pipeline (BRIEF.md values 1 and 2 of 3). * One reading through the raw → stable pipeline (BRIEF.md values 1 and 2 of 3).
@@ -31,6 +32,8 @@ data class LevelReading(
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. */
val placementOk: Boolean, val placementOk: Boolean,
/** Surface-only shared motion gate; true while the phone is still settling. */
val isSettling: Boolean = false,
val isLocked: Boolean, val isLocked: Boolean,
val fireFeedback: Boolean, val fireFeedback: Boolean,
) )
@@ -61,6 +64,7 @@ class LevelPipeline(
private val aDeadband = DisplayDeadband() private val aDeadband = DisplayDeadband()
private val bDeadband = DisplayDeadband() private val bDeadband = DisplayDeadband()
private val surfacePresentationDetector = SurfacePresentationDetector() private val surfacePresentationDetector = SurfacePresentationDetector()
private val settlingDetector = SettlingDetector()
private var lastTimestampNanos: Long? = null private var lastTimestampNanos: Long? = null
fun process(g: GravitySample): LevelReading { fun process(g: GravitySample): LevelReading {
@@ -82,6 +86,7 @@ class LevelPipeline(
) )
val displayMagnitude = primaryDeadband.update(magnitude) val displayMagnitude = primaryDeadband.update(magnitude)
val presentation = surfacePresentationDetector.update(displayMagnitude) val presentation = surfacePresentationDetector.update(displayMagnitude)
val settling = settlingDetector.update(pitch, roll, g.timestampNanos)
val lock = lockDetector.update(magnitude, nowMillis) val lock = lockDetector.update(magnitude, nowMillis)
LevelReading( LevelReading(
mode = mode, mode = mode,
@@ -100,6 +105,7 @@ class LevelPipeline(
stableSurfaceRollDegrees = roll, stableSurfaceRollDegrees = roll,
surfacePresentation = presentation, surfacePresentation = presentation,
placementOk = placementOk, placementOk = placementOk,
isSettling = settling.isSettling,
isLocked = lock.isLocked, isLocked = lock.isLocked,
fireFeedback = lock.fireFeedback, fireFeedback = lock.fireFeedback,
) )
@@ -7,8 +7,10 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Settings
@@ -39,9 +41,12 @@ import com.onthelevel.core.design.LevelColors
import com.onthelevel.core.sensors.EdgeCalibration import com.onthelevel.core.sensors.EdgeCalibration
import com.onthelevel.core.sensors.LevelMode import com.onthelevel.core.sensors.LevelMode
import com.onthelevel.core.sensors.LockDetector import com.onthelevel.core.sensors.LockDetector
import com.onthelevel.core.sensors.RiseRun
import com.onthelevel.core.sensors.SensorSource import com.onthelevel.core.sensors.SensorSource
import com.onthelevel.core.sensors.SurfaceCalibration import com.onthelevel.core.sensors.SurfaceCalibration
import com.onthelevel.core.sensors.SurfaceGuidance
import com.onthelevel.core.sensors.SurfacePresentation import com.onthelevel.core.sensors.SurfacePresentation
import com.onthelevel.core.settings.MeasurementUnits
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import java.util.Locale import java.util.Locale
@@ -72,6 +77,8 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
.collectAsStateWithLifecycle(initialValue = true) .collectAsStateWithLifecycle(initialValue = true)
val reducedMotion by container.settings.reducedMotion val reducedMotion by container.settings.reducedMotion
.collectAsStateWithLifecycle(initialValue = false) .collectAsStateWithLifecycle(initialValue = false)
val measurementUnits by container.settings.measurementUnits
.collectAsStateWithLifecycle(initialValue = MeasurementUnits.METRIC)
val view = LocalView.current val view = LocalView.current
val lockDetector = remember { LockDetector() } val lockDetector = remember { LockDetector() }
@@ -174,6 +181,10 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
}, },
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
if (mode == LevelMode.SURFACE && surfacePresentation == SurfacePresentation.FACE_UP) {
Spacer(Modifier.height(8.dp))
SurfaceAdjustmentInfo(reading, measurementUnits)
}
} }
} }
@@ -195,6 +206,43 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
} }
} }
/**
* Surface-only adjustment guidance. Direction comes exclusively from
* [SurfaceGuidance] using the displayed (deadbanded) axes, so the wording has
* the same hysteresis as the pitch and roll panels instead of flapping at a
* direction boundary. The shared pipeline settling gate deliberately defers a
* strong instruction while the phone is still being placed.
*/
@Composable
private fun SurfaceAdjustmentInfo(reading: LevelReading?, units: MeasurementUnits) {
if (reading == null) return
if (reading.isSettling) {
Text(
text = "Settling",
style = MaterialTheme.typography.labelSmall,
color = LevelColors.TextDim,
)
return
}
val pitch = reading.secondaryADegrees ?: return
val roll = reading.secondaryBDegrees ?: return
val highLabel = SurfaceGuidance.from(pitch, roll).highLabel ?: return
Text(
text = highLabel,
style = MaterialTheme.typography.headlineMedium,
color = LevelColors.Amber,
textAlign = TextAlign.Center,
)
Text(
text = formatRiseRun(reading.displayPrimaryDegrees, units),
style = MaterialTheme.typography.labelSmall,
color = LevelColors.TextDim,
)
}
@Composable @Composable
private fun ValuePanel(label: String, value: Double?, modifier: Modifier = Modifier) { private fun ValuePanel(label: String, value: Double?, modifier: Modifier = Modifier) {
Surface( Surface(
@@ -237,6 +285,13 @@ private fun statusLine(isCalibrated: Boolean, kind: SensorSource.Kind): String {
internal fun formatDegrees(value: Double): String = String.format(Locale.US, "%.1f°", value) internal fun formatDegrees(value: Double): String = String.format(Locale.US, "%.1f°", value)
internal fun formatRiseRun(valueDegrees: Double, units: MeasurementUnits): String = when (units) {
MeasurementUnits.METRIC ->
String.format(Locale.US, "%.1f mm/m", RiseRun.millimetersPerMeter(valueDegrees))
MeasurementUnits.IMPERIAL ->
String.format(Locale.US, "%.2f in/ft", RiseRun.inchesPerFoot(valueDegrees))
}
private fun formatTolerance(): String = private fun formatTolerance(): String =
String.format(Locale.US, "%.2f°", LockDetector.DEFAULT_EXIT_DEGREES) String.format(Locale.US, "%.2f°", LockDetector.DEFAULT_EXIT_DEGREES)
@@ -0,0 +1,19 @@
package com.onthelevel.core.sensors
import org.junit.Assert.assertEquals
import org.junit.Test
class RiseRunTest {
@Test
fun `zero tilt has no rise over run`() {
assertEquals(0.0, RiseRun.millimetersPerMeter(0.0), 1e-9)
assertEquals(0.0, RiseRun.inchesPerFoot(0.0), 1e-9)
}
@Test
fun `forty five degrees converts to the conventional construction slopes`() {
assertEquals(1_000.0, RiseRun.millimetersPerMeter(45.0), 1e-9)
assertEquals(12.0, RiseRun.inchesPerFoot(45.0), 1e-9)
}
}
@@ -111,6 +111,14 @@ class LevelPipelineTest {
assertTrue(readings.none { it.fireFeedback }) assertTrue(readings.none { it.fireFeedback })
} }
@Test
fun `surface reading exposes the shared settling state before strong guidance`() {
val readings = run(surfacePipeline(), degrees = 2.0, fromMillis = 0, untilMillis = 1_000)
assertTrue(readings.first().isSettling)
assertFalse(readings.last().isSettling)
}
@Test @Test
fun `edge mode never locks while the phone lies flat on a table`() { fun `edge mode never locks while the phone lies flat on a table`() {
val pipeline = LevelPipeline( val pipeline = LevelPipeline(