Compare commits

..

17 Commits

Author SHA1 Message Date
thejayman77 f8aa27e6cb Doc cleanup: handoff + load-listener comment match shipped bullseye audio
Codex re-audit of 0cb315f was functional-green (81 tests, clean build,
device-green) with two non-runtime doc findings:
- SOUND_DESIGN_HANDOFF.md still described the retired "Level on lock,
  once, then silence" behavior. Now documents immediate bullseye
  alignment (enter <=0.2 / exit >=0.35 spatial hysteresis, no dwell) and
  the looping level.wav, with the velocity lock kept separate for
  lime/label/haptic.
- The SoundPool load listener's comment still said "one-shot Level";
  updated to describe the pending-loop-start path.

No code behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 11:47:23 -04:00
thejayman77 0cb315fdcc Audio: instant bullseye cue on alignment, decoupled from the held lock
Three-way consensus (Jay/Codex/Claude): one sound, split timing not identity.
The bullseye sound now means 'centered right now' and fires the instant you're
aligned - even a fast pass over center - so the zone that needs the most help
locating always announces itself. It no longer waits on the velocity+dwell
lock, which was the delay near center.

- SurfaceTickPolicy: outside the center zone, proximity ticks (unchanged
  anchor scheduler). Inside, emit ALIGNED every frame -> caller loops level.wav.
  Entry is immediate (no dwell, no velocity gate). Alignment uses SPATIAL
  hysteresis (enter <=0.2, rearm only after leaving >=0.35) - not a time
  debounce, so a legitimate quick re-crossing still announces. Ticks resume
  immediately on exit.
- Alignment error = hypot(stable pitch, stable roll) - the SAME axes that drive
  the bubble, so the sound can't lag the visual (Codex's implementation note).
- Player: level.wav loops while aligned (ensureLevelLooping, idempotent),
  stopped before every restart and on exit; pending-load queue retained.
- The held-level confirmation (persistent lime, label, haptic) stays on the
  velocity-aware LockDetector; the locating sound is fully decoupled from it.

Tests: immediate cue on fast crossing, one start / ticks-suppressed while
inside, stop+resume-ticks on exit, spatial-hysteresis rearm, tick reactivity.
81 tests passing; assembleDebug clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 11:18:04 -04:00
thejayman77 604d61ebb2 Voice init-race fix + velocity-aware Surface lock
Voice (final P1 from audit): the policy is no longer consumed until TTS is
ready, so a phrase can't be queued and then spoken stale after init. When
ready, the policy evaluates the CURRENT reading (silent if moving, correct
instruction if settled). VoiceSpeaker drops its pending-text queue and just
exposes isReady; the policy is remember(speaker) so each VOICE session starts
fresh. Covers all of Codex's cancellation cases (movement, unlock, invalid
placement, foreground loss, mode change, shutdown).

Velocity-aware Surface lock (Jay's observation + Codex): the fixed 400 ms
dwell made a slowly-arriving bubble wait, delaying the level sound, while a
fast crossing could still lock. LockDetector now takes a movement rate:
- error <=0.2 deg AND rate <=0.3 deg/s -> lock after a short 175 ms confirm;
- faster than that -> dwell never accumulates (a center fly-through never
  locks); slowing to a stop inside the zone starts a fresh confirmation;
- 0.35 deg exit hysteresis and the single shared lock (sound/lime/haptic)
  unchanged.
LevelPipeline feeds SettlingDetector.movementRateDegreesPerSecond (was
discarded); Edge passes null -> classic fixed 400 ms dwell. Simple gated
machine, no adaptive-dwell formula.

Tests: slow-entry-locks-promptly, fast-traversal-never-locks, intermediate-
speed-no-accumulate, slowing-inside-zone-fresh-dwell, exit hysteresis,
feedback debounce, Edge fixed-dwell fallback. 82 tests passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 10:27:47 -04:00
thejayman77 3ebb36f265 Address Codex audit of ticking (36c0cfa): dynamic + init-race fixes
P1 - Tick cadence now reacts immediately to changing tilt. The due time is
recomputed every update from the last-tick anchor plus the CURRENT interval,
so dropping from 5deg to 0.4deg accelerates to the fast cadence at once
instead of waiting out the stale slow interval. Anchor advances by whole
intervals (no drift) and resyncs to now if a full interval behind (no burst).
Added far->near and near->far transition tests.

P1 - One-shot cues no longer lost to async loading. SonarSoundPool queues a
Level asked for before its sample loads and plays it on load-complete (and
cancels the queued Level on unlock/gate). VoiceSpeaker queues the latest
phrase requested before TTS finishes init and speaks it on ready. The policy
fires these once, so they can't rely on retries.

P2 - Unlock stops the level.wav tail BEFORE playing the resumed tick (order
was reversed, allowing a brief overlap).

P2 - Audio-mode preference write moved from the screen's rememberCoroutineScope
to container.applicationScope, so a quick navigation can't cancel it (matches
the earlier persistence hardening).

Cleanup: AudioAssistMode and LevelPipeline comments now describe ticking, not
the retired radar pings/homing bands; AUDIO_DESIGN_LOG cadence principle
reconciled with the 125 ms continuous-tick near rate.

78 tests passing; assembleDebug clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 10:01:52 -04:00
thejayman77 36c0cfa5ab Audio: Surface proximity ticking + Voice settled-change; stage Edge staircase
Replaces the hands-free audio assist after extensive on-device iteration
(full journey + rejected approaches in AUDIO_DESIGN_LOG.md; current design
in SOUND_DESIGN_HANDOFF.md, from Jay's sound-design sessions).

Surface (TONES) = continuous proximity ticking: tick.wav repeats faster as
you near level (axis-agnostic warmer/colder, the rate is the message).
- Exponential rate in stable tilt error, clamped 1.5/s (>=~5 deg) to 8/s
  (<=~0.4 deg), hard-capped; constant loudness (acceleration is the signal).
- Deadline scheduler off a monotonic clock (SystemClock.elapsedRealtime):
  advances a next-tick deadline, so no drift and no catch-up bursts; <=1 tick
  per sensor update; immediate tick on re-entry (enable/return FACE_UP/unlock).
- Lock reuses LockDetector.isLocked (dwell + hysteresis, no second audio
  threshold): stop ticking, play level.wav once, silence while held. The
  1.39s level stream is retained and stopped on unlock/disable so a fresh
  tick can't overlap its tail.
- Pure SurfaceTickPolicy (unit-tested: anchors/clamps, monotonic rates,
  gating, immediate re-entry, no catch-up burst, level-once, resume on unlock).

Voice (VOICE) = settled-change announcer: one correction on the dominant
axis, silent while moving, speaks again on settle only if direction changed,
crossed coarse->fine, or reached lock. No periodic repeat. (VoiceGuidancePolicy,
unit-tested.)

Assets: tick.wav + level.wav downsampled to mono 44.1kHz in res/raw (from
Jay's SoundQ-derived 96k masters). AudioAssistMode adds OFF/TONES/VOICE.

Retired the settle-gated two-ding packet scheduler (AudioAssistStateMachine +
its ding assets) - superseded by ticking for Surface; logged as tested/rejected.

Edge staircase (1-D, future): 5 pitch-contour masters staged in
sonar-staircase-v3/ for when Edge mode is built.

76 tests passing; assembleDebug clean; blessed on-device.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 09:43:18 -04:00
thejayman77 e5d15c920f Serialize app-scope writes via Main.immediate (Codex hardening note)
Non-blocking follow-up from the audit: applicationScope ran on
Dispatchers.Default, so two very fast conflicting persistence writes
(recalibrate then immediately Reset) had no ordering guarantee. Main.immediate
launches the coroutine body synchronously in call order, so the DataStore
edits enqueue deterministically; DataStore still does the IO on its own
dispatcher. Only lightweight persistence uses this scope.

64 tests passing; assembleDebug clean; runs on device.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 11:21:57 -04:00
thejayman77 daf19c34b3 Address Codex audit: Tools cards, 4-point validation, durable persistence
P1 - Tools screen no longer exposes dead 'coming with the feature build'
cards. Replaced the stale Calibration and Units/Feedback cards with a
single working Settings card that opens the Settings screen.

P1 - Detailed (4-point) calibration now validates rotation quality, not
just the final mean. A 4-point set holds two independent 180-flip bias
estimates - the (0,180) and (90,270) pairs; a malformed turn makes them
diverge even when the mean stays small (which the 3-degree magnitude
guard can't see). isRotationConsistent rejects divergence beyond 0.4
degrees. The disagreement scales with the surface's true tilt, so it's
lenient near level and strict on a steeper permitted surface. Test
proves a deliberately 10-degree-over-rotated set is rejected and a clean
set passes.

P2 - Calibration persistence moved from the screen's rememberCoroutineScope
to a container-owned applicationScope, so the write survives an immediate
Done/back after 'saved'. Preference writes moved there too.

Cleanup: haptic copy is mode-neutral ('when the level locks'); Settings
no longer keeps the screen awake (calibration still does); calibration
method renamed Thoroughness -> Method with Standard/Detailed labels and
accurate 'Four positions, a quarter-turn apart' / 'Averages more sensor
noise' copy; lime now marks the active-calibration status (confirmed
state), consistent with lime = level/confirmed.

64 tests passing; assembleDebug clean; verified on device.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 11:15:29 -04:00
thejayman77 85a8ce1136 Calibration UX overhaul + Settings screen (Claude, driving)
Fixes a real defect and reworks the calibration/settings flow per Jay's
on-device review.

Bug: the second capture hung on 'capturing'. The completion code wrote
capture=null (a LaunchedEffect key) then awaited the suspend
setSurfaceCalibration BEFORE setting phase=COMPLETE; at the suspension
Compose cancelled the effect mid-write, stranding the transition. The
effect body is now fully synchronous and persistence runs on an
independent scope.launch. Verified end-to-end on device.

Settings screen (new): reached from the Level header gear (was: gear
jumped straight into calibration). Calibration is a sleek button in its
own section; Units, Haptics, and Reduce-motion prefs get a home; Done is
pinned to the bottom while the list scrolls independently.

Calibration screen: two stages. OVERVIEW shows the current correction
(Pitch/Roll offsets), the Thoroughness selector, and Calibrate / Reset
to phone defaults / Done. CAPTURING shows ONLY capture + Cancel; Cancel
restores the prior calibration (saved value is never touched mid-flow).
Instructions centered; outlined buttons given visible borders.

Thoroughness: Simple (2-point, 0/180) or Thorough (4-point,
0/90/180/270). Generalized deriveSurfaceFromSamples averages a symmetric
rotation set - the true tilt sums to zero, so the mean is the device
bias; 4-point also cancels each axis twice and averages more noise. Two
new unit tests (2-point equivalence, 4-point bias recovery).

61 tests passing; assembleDebug clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 10:37:35 -04:00
thejayman77 721fccd5ce Surface polish: motion fix, instrument redesign, readout typography (Codex + Claude)
Driven by on-device iteration on a Galaxy S24+ (live ADB install/
screenshot loop), gated by a real-device motion pass at animator
scale 1x and frame stats (p95 8ms, 0.85% legacy jank).

Motion (Codex): X/Y axes animate in independent concurrent effects.
The prior sequential animateTo calls starved Y under ~50Hz
retargeting - diagonal movement traced an L and the second axis
caught up seconds late. Reduced-motion/system-disabled paths snap
both axes.

Instrument (Claude, on Codex's base):
- Fluorescent yellow-green fluid (matches real spirit-level dye);
  amber retained for guidance text.
- Bubble as a true void: borderless symmetric gradient orb,
  translucent interior (marks read through it), soft bright
  refraction band, contact shadow, fluorescent halo, window-style
  reflection + glint. Velocity-squash experiment tried and removed -
  shape fidelity beats the flourish.
- Bubble (0.12R) nests inside the 1-degree target ring; ring turns
  lime on VISUAL containment of the drawn bubble (lock label/pulse/
  haptic remain on LockDetector truth).
- Machined bezel, etched cardinal/45 ticks, per-ring degree labels
  via the same SurfaceGuidance mapping. dp-scaled strokes replace
  raw-pixel hairlines; contrast lifted for real screens.
- Red center target dot beneath the translucent bubble (Codex).

Text hierarchy: guidance suppressed while locked (no correction
advice under a 'Flat within' verdict), demoted to titleLarge, and
raised into the empty status line's slot. Primary readout optically
dead-centered via an invisible leading degree twin; degree symbol
.55em at TextDim with cap-height baseline shift (.38 - metrics pair,
resize together).

59 tests passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 07:34:58 -04:00
thejayman77 30417a5f98 Slice 5: Audio Level Assist + fixed guidance slot (Codex)
- AudioAssistStateMachine (core/audio, pure, tested): proximity bands
  with enter/exit hysteresis (close 1.0/1.2, near 3.0/3.4 degrees),
  cadence preserved across band changes (no threshold double-ping),
  lock cue driven solely by LockDetector's fireFeedback transition -
  no second lock threshold, and the haptic debounce paces the ping.
- Silence gates: disabled, backgrounded, settling, invalid placement,
  or outside FACE_UP all reset and mute; all five unit-tested.
- SonarSoundPool: locally synthesized WAV pings via SoundPool with
  USAGE_ASSISTANCE_SONIFICATION; zero audio-focus APIs (verified by
  grep); player exists only while enabled and resumed, released on
  composition disposal.
- Persisted SONAR ON/OFF header control using the existing
  audioCueEnabled preference; off by default; state shown as text,
  never color alone.
- Guidance slot fixed at 56dp so settling/correction/level copy no
  longer shifts the instrument (Slice 4 polish note).

59 tests passing.

Audited-by: Claude (no findings)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:43:07 -04:00
thejayman77 027b9b96b3 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>
2026-07-13 15:43:42 -04:00
thejayman77 422854de07 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>
2026-07-13 15:30:45 -04:00
thejayman77 d3854ac402 Slice 2: guided Surface calibration flow (Codex)
- SurfaceCalibrationScreen: two-sample guided flow from the Level header.
  Captures read SensorSource.gravity directly - stored calibration is
  never applied to either sample. Exact 180-degree same-plane guidance,
  clear-saved-calibration action, Surface persistence only.
- SettlingDetector (core/sensors, shared): 1.0 deg/s enter, 0.3 deg/s
  exit held 500 ms; mid-band movement resets the quiet dwell without
  leaving Settling (audit finding 1).
- StableSurfaceCapture (core/sensors): continuous 1.5 s settled window
  with the 5-degree calibration-surface guard; restarts on motion.
- SurfaceCalibrationValidation (core/sensors): 3-degree bound on the
  combined two-axis bias magnitude, unit-tested both sides of the
  boundary (audit finding 2).
- Lock feedback suppression is structural: the calibration route removes
  LevelScreen from composition, cancelling its sensor collection.

50 tests passing.

Audited-by: Claude (2 findings raised and resolved)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 14:51:28 -04:00
thejayman77 8d942a88b4 Slice 1: honest full-range Surface readings, self-gating lock (Codex)
- Surface tilt magnitude stays visible 0-180 degrees; never blanked by
  placement. Lock is self-gating (only near-zero magnitude can enter),
  so the placement gate now drives hints only. Edge gating unchanged.
- Pitch/Roll suppressed as ambiguous at >=80 degrees; explicit
  screen-down state past 90 degrees with the true magnitude retained.
- SurfacePresentationDetector classifies from the same deadbanded
  stable magnitude the UI displays, with 1-degree hysteresis at both
  boundaries and a direct face-up -> screen-down transition.
- Tests: angle sweep (30/80/near-vertical/screen-down), hysteretic
  boundary tests, and a sustained 30-degree run proving a steep phone
  never locks or fires feedback. 43 tests passing.

Audited-by: Claude (2 findings raised and resolved)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 12:10:05 -04:00
thejayman77 0c197894ff Revise Surface plan per audit: near-vertical spec, calibration guards, shared settling, pure guidance mapping (Codex) 2026-07-13 11:52:52 -04:00
thejayman77 abff397364 Add Surface Level attack plan (Codex) and Gradle daemon JVM pin 2026-07-13 10:29:04 -04:00
thejayman77 99c2b12192 Address Codex measurement-core review
- Calibration: still derived in angle space via the 180-degree flip, but now
  APPLIED in vector space as a reference-orientation rotation (Rodrigues
  alignment for Surface, Z-rotation for Edge), exact away from zero; tests
  at 30 degrees, cross-axis, and edge-polarity cases.
- Angle relative zero: stores the gravity direction vector; relative reading
  is the angle between directions, so cross-axis movement is honest.
- Edge mode: precise geometry documented (either long edge down, gravity
  along +/-X), placement-validity guard so e.g. Edge mode never locks on a
  phone lying flat; UI shows repositioning hints.
- Lock/readout coherence: locked label states the tolerance (exit threshold)
  so the rounded readout can never contradict it; pipeline acceptance tests
  pin the invariant.
- Sensor-vector contract: documented and pinned by SensorContractTest. The
  suggested negation of gravity/accelerometer fallbacks is NOT applied: per
  Android SensorEvent docs, a stationary flat device reads +9.81 on Z (the
  gravity reaction), matching the rotation-vector path as-is. The contract
  tests prove all paths agree.

38 tests passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:45:53 -04:00
56 changed files with 3870 additions and 157 deletions
+87
View File
@@ -0,0 +1,87 @@
# Audio Assist — design log
Hands-free audio to level something without watching the screen. Setting cycles
**OFF / TONES / VOICE** from the Level header. Plays on `USAGE_MEDIA` (so the volume slider
controls it) and never takes audio focus.
> **Source of truth for the current design is [`SOUND_DESIGN_HANDOFF.md`]**, from Jay's
> dedicated sound-design sessions. This log records the *journey* — especially rejected
> approaches — so they don't get rediscovered. Where the two ever disagree, the handoff wins.
## Chosen direction (current)
Two modes / two mechanisms, one sound identity (all derived from a 500 Hz blip):
**SURFACE (2-D) — TONES.** One vocabulary: **faster ticks = approaching, the bullseye sound =
centered right now.** Timing is split, not sound identity:
- *Outside the center zone:* `tick.wav` (60 ms) repeats faster as you near level — axis-agnostic
"warmer/colder", rate is the message. No settle gate. Rate is exponential in the error,
clamped **1.5/s (≥~5°) → 8/s (≤~0.4°)**, hard-capped. **Constant loudness** (acceleration is
the signal). Scheduling is anchored to the last tick off a **monotonic clock**, due-time
recomputed each frame from the current interval → immediate reaction to changing tilt, no
drift, no catch-up burst, ≤1 tick per update.
- *Inside the center zone:* the ticks give way to the looping `level.wav` **immediately — no
dwell, no velocity gate — even on a fast pass**, because a momentary alignment is real,
locating information (that little zone needs the most help). It stops the instant you leave.
Alignment uses **spatial hysteresis** (enter ≤0.2°, rearm only after leaving ≥0.35°) — not a
time debounce, which would hide a legitimate quick re-crossing — and is measured from the
**same hypot(stable pitch, stable roll)** that drives the bubble, so the sound can't lag the
visual. `level.wav`'s stream id is retained and stopped before every restart / on exit.
- The stronger **"held level" confirmation — persistent lime, the "Flat within 0.35°" label,
and the haptic — is decided separately** by the velocity-aware `LockDetector` (≤0.2° + ≤0.3°/s
→ 175 ms confirm). The locating *sound* never waits on it; a fast fly-through gets the bullseye
cue but not the "you nailed it" confirmation.
**EDGE (1-D, future) — the "sonar staircase."** Direction + magnitude as a 4-blip pitch
contour (rising = raise it, falling = lower it, steepness = how far; level sounds flat), one
report per move→settle. Five pre-rendered assets staged in `sonar-staircase-v3/`; wired when
Edge mode is built.
**VOICE — deliberately separate, a SETTLED-CHANGE announcer (not a timer).** Speaks one
correction, then stays silent while moving; when it settles again it speaks only if the
dominant direction changed, it crossed coarse→fine, or it reached lock ("that's level"). A
settle still needing the same coarse nudge gets silence. No periodic repeat, no bed under it.
Implementation: pure `SurfaceTickPolicy` (monotonic deadline scheduler, exponential rate,
unit-tested) + `VoiceGuidancePolicy`; the SoundPool layer stays dumb (play a tick / play &
stop level). Sounds are Jay's SoundQ-derived blips (`tick.wav`, `level.wav`) downsampled to
mono 44.1 kHz in `res/raw`.
## Rejected approaches (and why)
1. **Two fixed proximity bands + lock ping** (original). Worked but coarse; the near/close jump
was audible ("a lower ding when close").
2. **Continuous cadence** — beep interval shrinks smoothly toward center (parking-sensor).
→ "gets a bit much." Monotonous over a long adjustment.
3. **Rising-pitch discrete beeps.** Better, but still same-note-ish and busy.
4. **Continuous glide tone** — one warm tone, pitch low→treble by closeness, resolving into the
aura. → "the constant sound works but it's too much; not clicking." A continuous tone gives
the ear **no silence to rest on**, so it fatigues; it also bypassed the tested scheduler.
5. **Radar doublet on a per-band cadence** — two sonar pings ~110 ms apart, repeating on a
1.3/0.9/0.6 s cadence while off-level. The literal two-ping "pew-pew" read as UI/radar beeps,
not sonar, and the continued cadence was still a clock. Kept the bands/pitch, dropped the
cadence in favor of one packet per settle.
6. **Continuous forcefield aura bed** (looping CC0 ambience while locked). Seamless on a PC
media player but **clicked at the loop point through Android SoundPool**. Pulled; a gapless
bed needs AudioTrack/ExoPlayer.
7. **Settle-gated two-ding "status packet"** (banded pitch + gap, one packet per move→settle,
fuller arrival ding at lock). Built, unit-tested, on-device tested. Coherent, but on the
bench the settle-then-report model felt sparse/laggy for the fiddly 2-D surface adjustment —
you want live "warmer/colder" while you're actually moving it, not a report after you stop.
Superseded by continuous ticking for Surface. (The settle-then-report shape lives on for
VOICE and for future EDGE, where a discrete per-settle report fits the 1-D task.)
## Guiding principles (learned)
- Cue *character* decides tolerance more than raw rate. As **discrete report cues**, ~180 ms
spacing felt alarm-like and ~600 ms felt calm — but as a **continuous proximity tick** an
8/s (125 ms) near rate reads as pleasant acceleration, not an alarm (it's a Geiger/parking
sensor, and the ear expects it to quicken as you close in). Judge by feel on the phone.
- **Volume** is a poor information channel (phone volume + room noise make loudness
unreliable) — let rate/pitch carry the message and keep loudness constant.
- Keep the SoundPool layer dumb; put decisions in tested pure logic.
- The **centered** sound needs source loudness/spectrum presence, not just a bigger volume
number.
- No reverb/comb tails on phone speakers — they smear and lengthen fatigue.
- Reuse the real lock state for the centered handoff; never invent a second audio threshold.
+9 -3
View File
@@ -23,12 +23,18 @@ Implemented and tested:
- `core/sensors` — sensor selection (game rotation vector → gravity → low-passed
accelerometer), device-frame gravity stream, and the pure measurement math:
orientation mapping, two-sample per-mode calibration, EMA smoothing,
lock hysteresis/dwell/debounce, display deadband. All unit-tested.
orientation mapping, two-sample per-mode calibration (derived in angle space,
**applied in vector space** so it stays correct away from zero), EMA smoothing,
lock hysteresis/dwell/debounce, display deadband, placement validity, and the
sensor-vector contract (all sources emit device-frame world-up; flat = +9.81 on Z,
pinned by `SensorContractTest`). All unit-tested.
- `core/settings` — DataStore preferences and per-mode calibration persistence.
- `core/billing` — entitlement interface with a stub (free-tier) implementation.
- `feature/level`, `feature/angle` — live numeric scaffolds wired to the real
sensor pipeline, with manual Surface|Edge selection, lock state, hold-to-zero.
sensor pipeline, with manual Surface|Edge selection, placement-validity hints,
lock state (locked label states the tolerance so it can never contradict the
rounded readout), and directional hold-to-zero (vector reference, not magnitude
subtraction).
- `feature/ruler`, `feature/tools` — static placeholders.
Not yet implemented (deliberately — see TODO markers):
+147
View File
@@ -0,0 +1,147 @@
# On The Level — Audio Feedback Handoff
Handoff from the sound-design session (2026-07-16) to the app implementation.
The design is final and approved by Jay; assets are ready to ship.
Two modes, two mechanisms, **one sound identity** — every sound derives from
the same 500 Hz blip:
- **Edge mode (1-D)**: sonar staircase, one discrete report per settle event.
- **Surface mode (2-D)**: continuous proximity ticking — tick rate rises as
you approach the bullseye; the moment you're on center, the ticks give way to
the looping `level.wav` bullseye sound. One vocabulary: faster ticks = closer,
bullseye sound = centered right now.
The staircase does NOT ship in surface mode. All earlier discussion of 2-D
dominant-axis mapping is dead — surface is axis-agnostic warmer/colder by
design, because leveling a surface shouldn't require decoding anything a
bubble level wouldn't ask of you.
## Surface mode: proximity ticking
Assets: `tick.wav` (in AudioGenerator's `outputs/surface-proximity/`) — the
same 500 Hz blip shortened to 60 ms with a soft release. The app plays this
single asset and schedules repetitions; rate is computed, not baked into
loops. Arrival = the shared `level.wav` (already in this repo under
`sonar-staircase-v3/`).
Rules — these are the difference between this and the old rejected
"annoying mode":
1. Map total error → tick rate **exponentially**, ~1.5 ticks/s (far) to
~8 ticks/s (near). Hard cap at 8/s; beyond ~10/s it fuses into a buzz.
2. **Loudness stays constant** as rate rises. The acceleration is the signal;
volume ramping is what turns it into a panic siren.
3. On entering the center zone: stop ticking and **immediately** start the
looping `level.wav` — no dwell, no velocity gate, even on a fast pass. A
momentary alignment is real locating information; that little zone needs the
most help. `level.wav` loops while you rest on center and stops the instant
you leave (ticks resume).
4. **Spatial hysteresis** on the zone (enter ≤0.2°, rearm only after leaving
≥0.35°) — *not* a time debounce, which would hide a legitimate quick
re-crossing. Alignment is measured from the same hypot(pitch, roll) that
drives the bubble, so the sound can't lag the visual.
5. Ticking runs continuously outside the zone, no settle gate — the realtime
rate IS the feedback. (The settle-then-sound rule applies to edge mode.)
6. The stronger **"held level" confirmation — persistent lime, the on-screen
label, and the haptic — is decided separately** by the velocity-aware lock
(≤0.2° and moving slowly for ~175 ms). The locating *sound* never waits on
it: a fast fly-through gets the bullseye cue but not the "you nailed it."
Rejected candidates kept for reference in AudioGenerator: the sustained
"energy field" loops (`bullseye-field-*.wav`) and the alternate ticks
(`outputs/tick-candidates/`). Do not ship them.
## Edge mode: the "sonar staircase"
Leveling feedback is a short train of four sonar blips whose **pitch contour is
the message**:
| Reading | Sound | Pitch steps (semitones per blip) |
|---|---|---|
| Level | flat train — all four blips at the same pitch | 0, 0, 0, 0 |
| Slightly low (raise it a little) | gentle rising staircase | 0, +1, +2, +3 |
| Very low (raise it a lot) | steep rising staircase | 0, +3, +6, +9 |
| Slightly high | gentle falling staircase | 0, 1, 2, 3 |
| Very high | steep falling staircase | 0, 3, 6, 9 |
Why this works:
- **Direction** = contour direction (rising = go higher; falling = go lower).
Never invert this mapping.
- **Magnitude** = contour steepness. Humans are poor at absolute pitch but
excellent at relative pitch, so a staircase is decodable without a reference.
- **Level literally sounds "flat."** The metaphor is self-explaining, and the
flat train doubles as the reward/confirmation state.
- **Constant rhythm at every zone.** All five sounds share the same 175 ms blip
cadence — a far-off reading is exactly as fast as a near one. Direction is
audible by blip 2 (~200 ms), full reading by ~525 ms; the rest is decay.
## When to play it (edge mode)
Staircase feedback fires **only after movement followed by settling** (same
trigger as the app's voice feedback). One staircase per settle event.
## The assets
`outputs/sonar-staircase-v3/` (copy into the app bundle):
| File | Duration |
|---|---|
| `level.wav` | 1.39 s |
| `slightly-low.wav` | 1.36 s |
| `very-low.wav` | 1.32 s |
| `slightly-high.wav` | 1.42 s |
| `very-high.wav` | 1.50 s |
All are 96 kHz stereo PCM-16 WAV, peak-normalized to 0.85. Downsampling to
44.1/48 kHz for the bundle is fine. The 500 Hz fundamental sits comfortably in
phone-speaker range, including the 9 st variant (~297 Hz).
## Anatomy of the sound (for regeneration or runtime synthesis)
- **Blip**: a single pure 500 Hz sine blip (100% tonal purity), ~165 ms
including its natural decay, extracted from Jay's licensed SoundQ file
(`~/Downloads/SoundQ Audio/UIBeep_Beep Sonar_PSE_BW-BD3HL_OmEo5.wav`,
segment 515680 ms). 4 ms attack fade, 30 ms release fade.
- **Train**: 4 blips, onsets every 175 ms, pitched per the table above.
Pitch shifts are plain resampling (varispeed — pitch and length change
together). On Android, `SoundPool.setRate()` reproduces this exactly, and
its 0.52.0 range covers the full ±12 semitones used here.
- **Tail**: NOT reverb. The decay is *rhythmic*: the final blip repeats 4 more
times at the same 175 ms cadence, each repeat 8 dB, holding the last blip's
pitch (the tail "holds up the answer"). Jay specifically rejected a smooth
reverb tail — the pulse must survive into silence. This matches the source
file's own decay behavior (measured: repeats every ~180 ms, ~6 dB/repeat).
Generator script: `build_staircase.py` in this folder (AudioGenerator).
Every parameter above is a named constant at the top. Run with
`.venv/bin/python build_staircase.py`; outputs to `outputs/sonar-staircase-v3/`.
## Implementation notes
1. **Hysteresis on tier boundaries (important).** With discrete tiers, a
reading sitting at the slightly/very threshold will flip-flop between
sounds on consecutive settles and feel indecisive. Require ~15% past a
boundary before switching tiers. Same for the level/slightly boundary.
2. **Three tiers is deliberate — don't add a fourth.** Absolute-judgment
limits make >3 discrete steepness levels hard to tell apart in the field.
If finer feedback is ever wanted, synthesize the blips at runtime instead
(enveloped sine oscillators via AudioTrack or Oboe) and make steepness
*continuous* — proportional to the error angle. Continuous mapping
sidesteps the categorization limit entirely; users only compare successive
readings ("shallower than last time = converging").
3. **Interruption**: if a new settle event lands while a staircase is still
playing its tail, cut it and play the new reading — the tail is decoration,
the fresh reading is information.
4. **Haptic pairing (optional)**: a light haptic tap synced to the level.wav
blips makes the "arrived" state feel physical; the tool is usually pressed
against a surface, so it lands well.
## Licensing
The blip derives from Jay's SoundQ "Lifetime License" pack — perpetual
royalty-free use, which for SFX marketplaces normally covers embedding in
apps; standalone redistribution of the sounds is what's prohibited. If any
doubt ever arises: the blip is a mathematically pure 500 Hz sine with simple
envelopes, so a from-scratch synthesized replacement (zero licensing) is
trivial and indistinguishable — the design carries the value, not the sample.
+168
View File
@@ -0,0 +1,168 @@
# Surface Level — Implementation Attack Plan
**Status:** Audited; ready for implementation after this revision.
**Scope:** Surface Level only. Do not change Angle, Edge, ruler, or billing in this stage.
## Outcome
Build a trustworthy two-axis surface-level tool for a phone lying flat,
screen-up, on a surface. It must feel like a physical bullseye vial while
giving clearer, more actionable information than a physical level.
The user should be able to answer three questions at a glance or by sound:
1. Is this surface level?
2. How far off is it?
3. Which side/corner needs adjustment, and by roughly how much?
## Existing foundation to preserve
- Unified device-frame world-up sensor contract.
- Raw sensor -> stable measurement -> animated display separation.
- Vector-space Surface calibration model and two-sample calibration math.
- EMA smoothing, lock hysteresis/dwell/debounce, deadband, and current tests.
- The current numeric Surface reading, pitch/roll values, and haptic lock hook.
## Product decisions to implement
- Surface means the phone is intended to lie flat, screen-up, on the plane being
measured. Its level-lock behavior is valid only in that geometry.
- Numeric measurements are never hidden because the phone is steeply oriented.
Surface tilt magnitude remains authoritative from 0 to 180 degrees. Outside
the ideal face-up geometry, show the actual magnitude and a quiet placement
hint; suppress level-lock feedback rather than replacing the reading with `—`.
- Above 80 degrees of tilt, de-emphasize/suppress Pitch and Roll because their
off-axis parameterization becomes noisy near vertical. Above 90 degrees,
identify the screen-down-side state rather than pretending the bullseye is a
useful placement tool. The magnitude remains visible in both cases.
- The central visualization is a bullseye vial. It has a finite visual range:
beyond that range the bubble rests at the rim, while numeric information remains
full-range and authoritative.
- Calibration corrects device/setup bias. It is not "make this desk level."
A later relative-reference feature is separate and out of scope here.
## Work sequence
### 1. Make measurement behavior honest at every angle
- Refactor Surface placement gating so it only controls the level-lock state,
lock haptic, and geometry-specific guidance.
- Keep the primary tilt magnitude visible from 0-180 degrees. Keep Pitch and
Roll visible only where their meaning is stable (below 80 degrees tilt).
- Add tests at flat, near level, 30 degrees, 80 degrees, near vertical, and
screen-down. Verify magnitude stays correct, off-axis values are suppressed
in their ambiguous range, and invalid placement never locks.
- Keep the existing lock tolerance semantics: a lock label states the tolerance
rather than pretending any non-zero value is exactly flat.
### 2. Add the guided Surface calibration flow
- Add a Surface Calibration route from the Level header.
- Explain that the phone may be calibrated on any firm, stable, *roughly level*
surface; it need not be perfectly level. Warn and ask for a flatter surface
when the measured tilt exceeds 5 degrees.
- Capture the two samples from the uncalibrated sensor path. Never derive a new
calibration from readings that an existing calibration has already corrected.
- Step 1: settle, collect a 1.5-second stable window, capture.
- Step 2: rotate the phone exactly 180 degrees in the same plane, settle, then
collect and capture another 1.5-second stable window.
- Use the shared settling detector: enter Settling above 1.0 degrees/second of
two-axis plane movement; leave it only after movement is at or below 0.3
degrees/second for 500 ms. Reject/retry windows that are not settled.
- Reject/retry a derived calibration bias above 3 degrees as evidence that the
device, setup, or turn was inconsistent. Suppress lock feedback during the flow.
- Persist only the resulting Surface calibration; never alter Edge calibration.
- State that the calibration applies to the current phone/case/contact setup.
- On completion, show a concise success result and a way to clear/re-run it.
### 3. Build the tactile bullseye instrument
- Use Compose Canvas (or an equivalent composable drawing layer) for a scalable
circular vial, etched rings, crosshairs, target zone, and bubble.
- Glass, fluid, and meniscus effects must improve depth/readability; avoid a
decorative or excessively glossy treatment.
- Bubble target position derives directly from stable calibrated pitch/roll.
- Derive bubble target, high/low instruction, and rise/run direction from one
pure SurfaceGuidance function. Its hysteretic next-state input must prevent
quadrant labels from flapping at an axis boundary. Use an edge label when the
minor axis is under 25% of the dominant axis; otherwise use a corner label.
- Bubble movement uses a critically damped spring (damping ratio >= 1) toward
that real target: no overshoot, random drift, autonomous wobble, or animation
that contradicts the number. Clamp the target to the vial's visual range before
applying the spring so the bubble settles at the rim.
- Etched rings are real visual calibration marks, not decoration: map them to
explicit degree values (initially 1, 2, and 5 degrees) using the same pure
measurement-to-position mapping as the bubble.
- Respect disabled system animations and the app's reduced-motion preference by
direct-positioning the deadbanded bubble value instead of running the spring.
- Show a large total tilt beside/below the vial, then Pitch and Roll below it.
- At level lock, use the restrained lime state and an explicit text label.
### 4. Add actionable adjustment information
- Derive a screen-relative high/low direction from calibrated Pitch and Roll.
Use language such as `High: lower-left`; do not claim a compass direction.
- Display slope as both degrees and a useful normalized rise/run value:
millimetres per metre and/or inches per foot based on the selected unit system.
- Treat exact board/counter dimensions and calculated shim/foot height as a later
Adjustment Assist feature, not a requirement of this stage.
- Use the shared settling detector from calibration. When it is settling, show a
subtle `Settling` state and defer strong correction guidance until the stable
measurement is trustworthy.
- Add a persisted units preference in core/settings for rise/run formatting;
the Tools settings UI remains deferred.
### 5. Add optional hands-free Audio Level Assist
- Surface-only, off by default, explicitly user-enabled.
- Use a soft sonar-like pulse with a gentle attack and naturally fading tail;
never a harsh alarm or rapid smoke-detector cadence.
- Silent while the shared settling detector is active or materially outside the
usable placement geometry.
- Near level: occasional low-key proximity pulses with hysteresis between audio
proximity bands, so band changes cannot chatter.
- Locked: one distinct soft lock ping; optionally provide a sparse repeat-while-
locked preference so a user adjusting a large plank can hear that it remains level.
- Reuse the existing lock dwell/hysteresis/debounce so sound cannot chatter.
- Use SoundPool with `USAGE_ASSISTANCE_SONIFICATION`; mix with media rather than
taking audio focus for intermittent pulses. Respect media volume, system sound
settings, app lifecycle, and the user's Audio Assist preference. Release audio
resources with the tool composable/flow collection.
## Surface acceptance criteria
- A stable phone on a known level plane achieves lock without repeated feedback.
- A 180-degree calibration from uncalibrated stable samples on a stable,
roughly-level plane improves repeatability without zeroing that plane's real
slope; inconsistent/moving captures are rejected.
- A screen-up phone at 30, 80, near 90, and screen-down angles always shows a
correct magnitude. Pitch/Roll are suppressed at their documented ambiguous
range, and the Surface tool never falsely claims a lock.
- Bubble direction, high/low instruction, and rise/run direction agree in all
four quadrants through one pure, unit-tested guidance function.
- Bubble position is a pure mapping of stable measurement plus a no-overshoot
spring. Its etched rings match that mapping's documented degree marks.
- Audio can guide a user to lock without viewing the screen and stays quiet when
disabled, settling, backgrounded, or outside the Surface geometry.
- Manual checks cover phone-with-case, phone-without-case, a firm tabletop, and
a large board adjusted by one person.
## Explicitly deferred
- Angle-screen redesign and signed relative-angle semantics.
- Edge/Plumb visual and geometry changes.
- Saved measurements, target-angle alerts, screen ruler, and Play Billing.
- Board dimension/shim calculator and automatic flat/edge switching.
## Questions for Claude's audit
1. Does the relaxed placement gate preserve truthful full-range Surface numbers
while preventing false lock feedback?
2. Is the two-sample calibration flow mathematically and UX-wise safe for the
stated use case, including phone cases and non-level calibration surfaces?
3. Is high/low mapping unambiguous and testable for all pitch/roll quadrants?
4. Does the bullseye animation remain derived from measurement truth and respect
reduced-motion/system-animation settings?
5. Can the audio state machine reuse lock semantics without audio-focus,
lifecycle, or repeated-feedback problems?
6. Is any proposed work outside this single-stage scope?
@@ -26,7 +26,9 @@ import androidx.navigation.compose.rememberNavController
import com.onthelevel.core.design.OnTheLevelTheme
import com.onthelevel.feature.angle.AngleScreen
import com.onthelevel.feature.level.LevelScreen
import com.onthelevel.feature.level.SurfaceCalibrationScreen
import com.onthelevel.feature.ruler.RulerScreen
import com.onthelevel.feature.tools.SettingsScreen
import com.onthelevel.feature.tools.ToolsScreen
class MainActivity : ComponentActivity() {
@@ -87,12 +89,29 @@ private fun AppRoot(container: AppContainer) {
startDestination = "level",
modifier = Modifier.padding(padding),
) {
composable("level") { LevelScreen(container) }
composable("level") {
LevelScreen(container, onOpenSettings = {
navController.navigate("settings")
})
}
composable("angle") { AngleScreen(container) }
composable("tools") {
ToolsScreen(onOpenRuler = { navController.navigate("ruler") })
ToolsScreen(
onOpenRuler = { navController.navigate("ruler") },
onOpenSettings = { navController.navigate("settings") },
)
}
composable("ruler") { RulerScreen(container) }
composable("settings") {
SettingsScreen(
container,
onOpenSurfaceCalibration = { navController.navigate("surface-calibration") },
onBack = { navController.popBackStack() },
)
}
composable("surface-calibration") {
SurfaceCalibrationScreen(container, onBack = { navController.popBackStack() })
}
}
}
}
@@ -7,6 +7,9 @@ import com.onthelevel.core.billing.StubProEntitlementRepository
import com.onthelevel.core.sensors.AndroidSensorSource
import com.onthelevel.core.sensors.SensorSource
import com.onthelevel.core.settings.SettingsRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
/**
* Manual application container — deliberately no DI framework for an app this size
@@ -15,6 +18,18 @@ import com.onthelevel.core.settings.SettingsRepository
class AppContainer(context: Context) {
private val appContext = context.applicationContext
/**
* Outlives any single screen. Used for durable fire-and-forget persistence
* (e.g. saving a calibration): a screen-scoped coroutine would be cancelled if
* the user leaves the moment the write is launched.
*
* Main.immediate (not Default) so writes launched from UI callbacks enqueue in
* call order — two fast conflicting writes (recalibrate then Reset) then reach
* DataStore deterministically. Only lightweight persistence runs here; DataStore
* still performs the actual IO on its own dispatcher.
*/
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val sensorSource: SensorSource by lazy { AndroidSensorSource(appContext) }
val settings: SettingsRepository by lazy { SettingsRepository(appContext) }
val entitlement: ProEntitlementRepository by lazy { StubProEntitlementRepository() }
@@ -0,0 +1,106 @@
package com.onthelevel.core.audio
import com.onthelevel.core.sensors.SurfacePresentation
import kotlin.math.pow
/**
* Pure scheduler for Surface TONES. One audible vocabulary: faster ticks mean "approaching",
* and the bullseye sound means "you are centered right now" (SOUND_DESIGN_HANDOFF.md + the
* three-way audio consensus).
*
* Two states, split by TIMING not by sound identity:
* - Outside the center zone: proximity ticks whose rate rises as you near level (exponential,
* clamped). Continuous "warmer/colder"; the rate is the message.
* - Inside the center zone: emit [Cue.ALIGNED] every frame so the caller holds the bullseye
* sound. Entry is IMMEDIATE — no dwell, no velocity gate — so even a fast pass over center
* is announced (that's the locating cue). Ticks are suppressed while aligned.
*
* Alignment uses SPATIAL hysteresis (enter ≤ [alignEnterDegrees], rearm only after leaving
* ≥ [alignExitDegrees]) — not a time debounce, which would hide a legitimate quick re-crossing.
* The error is the same hypot(pitch, roll) that drives the bubble, so the sound can't lag the
* visual. The stronger "held level" confirmation (lime/label/haptic) is decided separately by
* the velocity-aware LockDetector; this policy does not gate that.
*/
class SurfaceTickPolicy(
private val nearDegrees: Double = NEAR_DEGREES,
private val farDegrees: Double = FAR_DEGREES,
private val maxRatePerSecond: Double = MAX_RATE,
private val minRatePerSecond: Double = MIN_RATE,
private val alignEnterDegrees: Double = ALIGN_ENTER_DEGREES,
private val alignExitDegrees: Double = ALIGN_EXIT_DEGREES,
) {
enum class Cue { TICK, ALIGNED }
private var lastTickAtMillis: Long? = null
private var aligned = false
fun update(input: Input): Cue? {
if (!input.isEnabled || !input.isAppForeground || !input.placementOk ||
input.surfacePresentation != SurfacePresentation.FACE_UP
) {
reset()
return null
}
// Spatial hysteresis: enter the zone at the tight threshold, rearm only after
// swinging back out past the loose one. No time debounce.
if (aligned) {
if (input.errorDegrees >= alignExitDegrees) aligned = false
} else {
if (input.errorDegrees <= alignEnterDegrees) aligned = true
}
if (aligned) {
lastTickAtMillis = null // so ticks resume immediately the moment we exit
return Cue.ALIGNED
}
// Outside the zone: proximity ticks, anchored to the last tick, due-time recomputed
// every frame from the current interval (immediate reaction to changing tilt; no drift,
// no catch-up burst). At most one tick per update.
val now = input.nowMillis
val anchor = lastTickAtMillis
if (anchor == null) {
lastTickAtMillis = now // re-entry / just exited the zone: tick immediately
return Cue.TICK
}
val interval = intervalMillis(input.errorDegrees)
val due = anchor + interval
if (now >= due) {
lastTickAtMillis = if (now - due >= interval) now else due
return Cue.TICK
}
return null
}
/** Ticks/second: [maxRatePerSecond] at/inside [nearDegrees], [minRatePerSecond] at/beyond [farDegrees]. */
fun tickRatePerSecond(errorDegrees: Double): Double {
val t = ((errorDegrees - nearDegrees) / (farDegrees - nearDegrees)).coerceIn(0.0, 1.0)
return maxRatePerSecond * (minRatePerSecond / maxRatePerSecond).pow(t) // geometric = exponential
}
fun intervalMillis(errorDegrees: Double): Long = (1000.0 / tickRatePerSecond(errorDegrees)).toLong()
fun reset() {
lastTickAtMillis = null
aligned = false
}
data class Input(
val isEnabled: Boolean,
val isAppForeground: Boolean,
val placementOk: Boolean,
val surfacePresentation: SurfacePresentation?,
val errorDegrees: Double,
val nowMillis: Long,
)
companion object {
const val NEAR_DEGREES = 0.4
const val FAR_DEGREES = 5.0
const val MAX_RATE = 8.0
const val MIN_RATE = 1.5
const val ALIGN_ENTER_DEGREES = 0.2
const val ALIGN_EXIT_DEGREES = 0.35
}
}
@@ -0,0 +1,117 @@
package com.onthelevel.core.audio
import com.onthelevel.core.sensors.SurfacePresentation
import kotlin.math.abs
/**
* Pure policy for spoken Surface leveling guidance — a SETTLED-CHANGE announcer, not a
* timer. It talks like a person watching the bubble: ONE correction on the axis that's
* most off, naming the low side to RAISE, softened to "a little" when close, and "that's
* level" once.
*
* It speaks only when there's something new to say. While the board is MOVING it stays
* silent (but remembers movement happened); when it SETTLES again it re-assesses and
* speaks only if the dominant direction changed, the correction crossed coarse→fine, or
* it reached lock. A board that settles still needing the same coarse nudge gets silence —
* the person already has that instruction. There is no periodic repeat.
*
* Sign conventions (OrientationMath): pitch>0 = top edge high, roll>0 = right edge high.
* To level you raise the LOW side, so top-high -> raise bottom, right-high -> raise left.
*/
class VoiceGuidancePolicy(
private val fineThresholdDegrees: Double = FINE_THRESHOLD_DEGREES,
private val dominanceMarginDegrees: Double = DOMINANCE_MARGIN_DEGREES,
) {
enum class Direction { RAISE_LEFT, RAISE_RIGHT, RAISE_TOP, RAISE_BOTTOM }
sealed interface Phrase {
data class Raise(val direction: Direction, val fine: Boolean) : Phrase
data object Level : Phrase
}
private var spokenDirection: Direction? = null
private var spokenFine = false
private var levelAnnounced = false
// True right after movement, so the next settled frame re-assesses. Starts true so the
// very first settled reading gives an instruction.
private var awaitingSettledAssessment = true
fun update(input: Input): Phrase? {
if (!input.isEnabled || !input.isAppForeground || !input.placementOk ||
input.presentation != SurfacePresentation.FACE_UP
) {
reset()
return null
}
if (input.isLocked) {
spokenDirection = null // re-announce direction after any unlock
awaitingSettledAssessment = false
if (levelAnnounced) return null
levelAnnounced = true
return Phrase.Level
}
levelAnnounced = false
// Moving: stay silent, but note that we must re-assess once it settles. Preserve
// the last spoken direction/tier (do NOT fully reset — that caused the repeats).
if (input.isSettling) {
awaitingSettledAssessment = true
return null
}
// Settled and off-level: speak only on a meaningful change since the last settle.
val direction = dominantDirection(input.pitchDegrees, input.rollDegrees)
val fine = maxOf(abs(input.pitchDegrees), abs(input.rollDegrees)) <= fineThresholdDegrees
val speak = awaitingSettledAssessment && (
spokenDirection == null || // first instruction
direction != spokenDirection || // dominant direction changed
(fine && !spokenFine) // crossed coarse -> fine
)
awaitingSettledAssessment = false
return if (speak) {
spokenDirection = direction
spokenFine = fine
Phrase.Raise(direction, fine)
} else {
null
}
}
private fun dominantDirection(pitch: Double, roll: Double): Direction {
val pitchDir = if (pitch > 0) Direction.RAISE_BOTTOM else Direction.RAISE_TOP
val rollDir = if (roll > 0) Direction.RAISE_LEFT else Direction.RAISE_RIGHT
val prev = spokenDirection
val ambiguous = abs(abs(pitch) - abs(roll)) < dominanceMarginDegrees
if (ambiguous && prev != null) {
// Stay on whichever axis we're already coaching, to avoid flapping on a diagonal.
if (prev == Direction.RAISE_TOP || prev == Direction.RAISE_BOTTOM) return pitchDir
return rollDir
}
return if (abs(pitch) >= abs(roll)) pitchDir else rollDir
}
fun reset() {
spokenDirection = null
spokenFine = false
levelAnnounced = false
awaitingSettledAssessment = true
}
data class Input(
val isEnabled: Boolean,
val isAppForeground: Boolean,
val placementOk: Boolean,
val presentation: SurfacePresentation?,
val isSettling: Boolean,
val pitchDegrees: Double,
val rollDegrees: Double,
val isLocked: Boolean,
val nowMillis: Long,
)
companion object {
const val FINE_THRESHOLD_DEGREES = 1.0
const val DOMINANCE_MARGIN_DEGREES = 0.3
}
}
@@ -13,7 +13,8 @@ import androidx.compose.ui.unit.sp
/**
* Palette from the concept board (resources/Bubble Level Concepts.dc.html):
* deep graphite glass, warm amber spirit fluid, lime for the locked state.
* deep graphite glass, warm amber guidance, fluorescent spirit fluid, and lime
* for the locked state.
* The design is dark-only in v1 — this is an instrument, not a document.
* Per BRIEF.md, color never carries state alone; labels and numbers always accompany it.
*/
@@ -27,6 +28,16 @@ object LevelColors {
val AmberHighlight = Color(0xFFFFEEB0)
val AmberDeep = Color(0xFFE0961E)
// Fluorescent yellow-green is both familiar from physical spirit levels and
// substantially more legible than amber against the graphite vial.
val VialLime = Color(0xFFD9FF38)
val VialHighlight = Color(0xFFF7FFD5)
val VialDeep = Color(0xFF86B800)
val VialTarget = Color(0xFFFF6868)
/** Stronger neutral surface reserved for live numeric instrument panels. */
val ReadoutPanel = Color(0xFF242931)
val LimeLock = Color(0xFFCBEF5C)
val LimeLockText = Color(0xFFEAFFC2)
@@ -49,12 +49,13 @@ class AndroidSensorSource(context: Context) : SensorSource {
val sample = when (sensorKind) {
SensorSource.Kind.GAME_ROTATION_VECTOR -> {
SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
// R maps device → world; world-up expressed in the device frame
// is R's third row. Scale to standard gravity.
// Converted to the GravitySample contract (device-frame
// world-up); see RotationVectorMath and SensorContractTest.
val (x, y, z) = RotationVectorMath.worldUpDeviceFrame(rotationMatrix)
GravitySample(
x = rotationMatrix[6] * STANDARD_GRAVITY,
y = rotationMatrix[7] * STANDARD_GRAVITY,
z = rotationMatrix[8] * STANDARD_GRAVITY,
x = x * STANDARD_GRAVITY,
y = y * STANDARD_GRAVITY,
z = z * STANDARD_GRAVITY,
timestampNanos = event.timestamp,
)
}
@@ -1,9 +1,16 @@
package com.onthelevel.core.sensors
/**
* Gravity (reaction) vector in the DEVICE coordinate frame, in m/s².
* THE SENSOR-VECTOR CONTRACT: world-up (the gravity REACTION, not the gravity force)
* expressed in the DEVICE coordinate frame, in m/s².
* Android convention: x → right edge, y → top edge, z → out of the screen.
* A phone lying screen-up on a level surface reads approximately (0, 0, +9.81).
*
* A phone lying flat screen-up on a level surface reads approximately (0, 0, +9.81).
* This is what Android's TYPE_ACCELEROMETER and TYPE_GRAVITY natively report at rest —
* per the SensorEvent docs, a stationary flat device reads +9.81 on Z ("acceleration of
* the device (0 m/s²) minus the force of gravity (9.81 m/s²)"). The rotation-vector
* path is converted to the same convention via [RotationVectorMath]. All three sources
* therefore agree without sign adjustment; SensorContractTest pins this.
*/
data class GravitySample(
val x: Double,
@@ -12,5 +19,20 @@ data class GravitySample(
val timestampNanos: Long,
)
/** The two physical orientations v1 supports (BRIEF.md). Selected manually — never auto-switched. */
/**
* The two physical orientations v1 supports (BRIEF.md). Selected manually — never
* auto-switched.
*
* SURFACE: device lying flat, screen up, on the surface being measured. Gravity
* predominantly along +Z.
*
* EDGE: device standing upright on either LONG edge (the edges parallel to the device
* Y axis) against the surface being measured — like a torpedo level. Screen plane
* roughly vertical; gravity predominantly along ±X. The level reading is the tilt of
* the resting edge from horizontal; plumb lean (screen tilted from vertical) is a
* separate, secondary reading.
*
* Placement is validated with [OrientationMath.isPlacementValid]; readings and lock
* are suppressed when the device is not in the selected mode's geometry.
*/
enum class LevelMode { SURFACE, EDGE }
@@ -4,18 +4,41 @@ import kotlin.math.abs
/**
* Level-lock state machine with hysteresis, dwell, and haptic debounce (BRIEF.md
* §Sensor and measurement architecture). Operates ONLY on the stable calibrated
* measurement — the same value the numeric readout shows. The lock must never
* disagree with the number on screen.
* §Sensor and measurement architecture). Operates on the stable calibrated measurement —
* the same value the numeric readout shows, so the lock never disagrees with the number.
*
* Time is injected (callers pass `nowMillis`) so transitions are unit-testable.
* Acquisition is VELOCITY-AWARE when a movement rate is supplied (Surface): a bubble eased
* gently into center (≤ [slowRateThresholdDegPerSec]) locks after a short [dwellMillis]
* confirmation, but a bubble flying across center never accumulates lock time — the dwell
* timer only runs while inside the zone AND moving slowly, and resets otherwise (so slowing
* to a stop inside the zone starts a fresh confirmation). This replaces a fixed long dwell,
* which forced even a slow, deliberate arrival to wait (and delayed the "level" sound).
*
* When no rate is supplied (`rateDegPerSec == null`, e.g. Edge), it falls back to the
* classic fixed [settledDwellMillis] with no velocity gate.
*
* The single lock drives sound, the lime visual, and the haptic together — there is no
* separate audio threshold. Time is injected (callers pass `nowMillis`) for testability.
*/
class LockDetector(
private val enterThresholdDegrees: Double = 0.2,
private val exitThresholdDegrees: Double = 0.35,
private val dwellMillis: Long = 400,
private val feedbackDebounceMillis: Long = 3_000,
private val enterThresholdDegrees: Double = DEFAULT_ENTER_DEGREES,
private val exitThresholdDegrees: Double = DEFAULT_EXIT_DEGREES,
private val dwellMillis: Long = DEFAULT_DWELL_MILLIS,
private val settledDwellMillis: Long = DEFAULT_SETTLED_DWELL_MILLIS,
private val slowRateThresholdDegPerSec: Double = DEFAULT_SLOW_RATE_DEG_PER_SEC,
private val feedbackDebounceMillis: Long = DEFAULT_FEEDBACK_DEBOUNCE_MILLIS,
) {
companion object {
// BRIEF.md: enter at no more than 0.2°, exit at at least 0.35°. The exit threshold
// doubles as the tolerance stated next to the locked label (Codex review).
const val DEFAULT_ENTER_DEGREES = 0.2
const val DEFAULT_EXIT_DEGREES = 0.35
const val DEFAULT_DWELL_MILLIS = 175L // velocity-gated: short, because a fast crossing can't accumulate
const val DEFAULT_SETTLED_DWELL_MILLIS = 400L // no-velocity fallback (Edge)
const val DEFAULT_SLOW_RATE_DEG_PER_SEC = 0.3
const val DEFAULT_FEEDBACK_DEBOUNCE_MILLIS = 3_000L
}
init {
require(exitThresholdDegrees > enterThresholdDegrees) {
"Hysteresis requires exit > enter threshold"
@@ -32,14 +55,19 @@ class LockDetector(
private var withinEnterSinceMillis: Long? = null
private var lastFeedbackAtMillis: Long? = null
fun update(stableTiltDegrees: Double, nowMillis: Long): Result {
fun update(stableTiltDegrees: Double, rateDegPerSec: Double?, nowMillis: Long): Result {
val magnitude = abs(stableTiltDegrees)
var fire = false
if (!locked) {
if (magnitude <= enterThresholdDegrees) {
val inZone = magnitude <= enterThresholdDegrees
// With a velocity signal, require slow motion to accumulate; a fast center
// crossing (rate above the threshold) never builds lock time.
val slowEnough = rateDegPerSec == null || rateDegPerSec <= slowRateThresholdDegPerSec
val requiredDwell = if (rateDegPerSec == null) settledDwellMillis else dwellMillis
if (inZone && slowEnough) {
val since = withinEnterSinceMillis ?: nowMillis.also { withinEnterSinceMillis = it }
if (nowMillis - since >= dwellMillis) {
if (nowMillis - since >= requiredDwell) {
locked = true
val last = lastFeedbackAtMillis
if (last == null || nowMillis - last >= feedbackDebounceMillis) {
@@ -48,6 +76,8 @@ class LockDetector(
}
}
} else {
// Out of zone or moving too fast: reset, so a fresh slow-and-centered spell
// starts the confirmation dwell over.
withinEnterSinceMillis = null
}
} else if (magnitude >= exitThresholdDegrees) {
@@ -4,6 +4,7 @@ import kotlin.math.abs
import kotlin.math.acos
import kotlin.math.asin
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sqrt
import kotlin.math.tan
@@ -61,6 +62,37 @@ object OrientationMath {
}
const val VERTICAL_GRADE_CUTOFF_DEGREES = 89.5
/**
* Unsigned angle between two gravity directions — the directional basis for
* relative zero in the angle meter. Unlike subtracting tilt magnitudes, this
* accounts for the axis of movement: zeroing at 10° pitch and moving to 10°
* roll reports the true ~14° orientation change, not 0°.
*/
fun angleBetweenDegrees(a: GravitySample, b: GravitySample): Double {
val na = norm(a)
val nb = norm(b)
if (na == 0.0 || nb == 0.0) return 0.0
val cosine = (a.x * b.x + a.y * b.y + a.z * b.z) / (na * nb)
return Math.toDegrees(acos(cosine.coerceIn(-1.0, 1.0)))
}
/**
* True when the device is physically in the selected mode's geometry (within
* [PLACEMENT_TOLERANCE_DEGREES] of it). Guards against, e.g., Edge mode reading
* "level" for a phone lying flat on a table — asin(gy) is near zero there too,
* but the measurement is meaningless and must not lock.
*/
fun isPlacementValid(g: GravitySample, mode: LevelMode): Boolean {
val n = norm(g)
if (n == 0.0) return false
return when (mode) {
LevelMode.SURFACE -> g.z / n >= PLACEMENT_MIN_COS
LevelMode.EDGE -> abs(g.x) / n >= PLACEMENT_MIN_COS
}
}
const val PLACEMENT_TOLERANCE_DEGREES = 45.0 // TODO(tune) against real handling
private val PLACEMENT_MIN_COS = cos(Math.toRadians(PLACEMENT_TOLERANCE_DEGREES))
private fun norm(g: GravitySample): Double = sqrt(g.x * g.x + g.y * g.y + g.z * g.z)
}
@@ -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,26 @@
package com.onthelevel.core.sensors
/**
* Pure conversion from a rotation matrix to the device-frame world-up direction,
* split out of AndroidSensorSource so the sensor-contract tests can prove the
* rotation-vector path matches the gravity/accelerometer convention.
*/
object RotationVectorMath {
/**
* [rotationMatrix] is the row-major 3×3 device→world matrix produced by
* SensorManager.getRotationMatrixFromVector. World-up expressed in the device
* frame is Rᵀ·(0,0,1) — the matrix's third row. Multiplied by standard gravity
* this matches what TYPE_GRAVITY reports for the same orientation.
*/
fun worldUpDeviceFrame(rotationMatrix: FloatArray): Triple<Double, Double, Double> {
require(rotationMatrix.size >= 9) { "Expected a 3x3 rotation matrix" }
return Triple(
rotationMatrix[6].toDouble(),
rotationMatrix[7].toDouble(),
rotationMatrix[8].toDouble(),
)
}
const val STANDARD_GRAVITY = 9.80665
}
@@ -0,0 +1,70 @@
package com.onthelevel.core.sensors
import kotlin.math.hypot
/** Shared two-axis motion gate for calibration, guidance, and Audio Assist. */
class SettlingDetector(
private val enterRateDegreesPerSecond: Double = ENTER_RATE_DEGREES_PER_SECOND,
private val exitRateDegreesPerSecond: Double = EXIT_RATE_DEGREES_PER_SECOND,
private val settledDwellMillis: Long = SETTLED_DWELL_MILLIS,
) {
data class Result(val isSettling: Boolean, val movementRateDegreesPerSecond: Double)
private var lastPitchDegrees: Double? = null
private var lastRollDegrees: Double? = null
private var lastTimestampNanos: Long? = null
private var settledSinceMillis: Long? = null
private var isSettling = true
fun update(pitchDegrees: Double, rollDegrees: Double, timestampNanos: Long): Result {
val previousPitch = lastPitchDegrees
val previousRoll = lastRollDegrees
val previousTimestamp = lastTimestampNanos
lastPitchDegrees = pitchDegrees
lastRollDegrees = rollDegrees
lastTimestampNanos = timestampNanos
if (previousPitch == null || previousRoll == null || previousTimestamp == null) {
return Result(isSettling = true, movementRateDegreesPerSecond = 0.0)
}
val dtSeconds = (timestampNanos - previousTimestamp) / 1e9
if (dtSeconds <= 0.0) {
reset()
return Result(isSettling = true, movementRateDegreesPerSecond = 0.0)
}
val rate = hypot(pitchDegrees - previousPitch, rollDegrees - previousRoll) / dtSeconds
val nowMillis = timestampNanos / 1_000_000
when {
rate > enterRateDegreesPerSecond -> {
isSettling = true
settledSinceMillis = null
}
isSettling && rate > exitRateDegreesPerSecond -> {
// A mid-band hand nudge is not enough to leave Settling, but it is
// enough to prove the signal has not been continuously quiet for
// the required dwell window.
settledSinceMillis = null
}
isSettling && rate <= exitRateDegreesPerSecond -> {
val since = settledSinceMillis ?: nowMillis.also { settledSinceMillis = it }
if (nowMillis - since >= settledDwellMillis) isSettling = false
}
}
return Result(isSettling = isSettling, movementRateDegreesPerSecond = rate)
}
fun reset() {
lastPitchDegrees = null
lastRollDegrees = null
lastTimestampNanos = null
settledSinceMillis = null
isSettling = true
}
companion object {
const val ENTER_RATE_DEGREES_PER_SECOND = 1.0
const val EXIT_RATE_DEGREES_PER_SECOND = 0.3
const val SETTLED_DWELL_MILLIS = 500L
}
}
@@ -0,0 +1,49 @@
package com.onthelevel.core.sensors
/** Uncalibrated, smoothed Surface sample used by guided calibration. */
data class SurfaceCalibrationSample(
val pitchDegrees: Double,
val rollDegrees: Double,
val tiltDegrees: Double,
val timestampNanos: Long,
val isSettling: Boolean,
)
/** Mean of one settled 1.5-second calibration capture. */
data class CapturedSurfaceSample(val pitchDegrees: Double, val rollDegrees: Double)
/** Collects a continuous settled capture on a roughly-level surface. */
class StableSurfaceCapture(
private val durationMillis: Long = CAPTURE_DURATION_MILLIS,
private val maximumTiltDegrees: Double = MAXIMUM_CALIBRATION_TILT_DEGREES,
) {
private var startedAtNanos: Long? = null
private var pitchTotal = 0.0
private var rollTotal = 0.0
private var count = 0
fun add(sample: SurfaceCalibrationSample): CapturedSurfaceSample? {
if (sample.isSettling || sample.tiltDegrees > maximumTiltDegrees) {
reset()
return null
}
val startedAt = startedAtNanos ?: sample.timestampNanos.also { startedAtNanos = it }
pitchTotal += sample.pitchDegrees
rollTotal += sample.rollDegrees
count += 1
if ((sample.timestampNanos - startedAt) / 1_000_000 < durationMillis || count < 2) return null
return CapturedSurfaceSample(pitchTotal / count, rollTotal / count)
}
fun reset() {
startedAtNanos = null
pitchTotal = 0.0
rollTotal = 0.0
count = 0
}
companion object {
const val CAPTURE_DURATION_MILLIS = 1_500L
const val MAXIMUM_CALIBRATION_TILT_DEGREES = 5.0
}
}
@@ -0,0 +1,36 @@
package com.onthelevel.core.sensors
import kotlin.math.hypot
/**
* Guards calibration persistence against a bad calibration.
*/
object SurfaceCalibrationValidation {
const val MAXIMUM_BIAS_MAGNITUDE_DEGREES = 3.0
const val PAIR_AGREEMENT_TOLERANCE_DEGREES = 0.4
/** The combined two-axis correction must be small; a large mean means something moved. */
fun isAcceptable(calibration: SurfaceCalibration): Boolean =
hypot(calibration.pitchBiasDegrees, calibration.rollBiasDegrees) <= MAXIMUM_BIAS_MAGNITUDE_DEGREES
/**
* A 4-point set (0°/90°/180°/270°) contains TWO independent 180°-flip bias
* estimates: the (0,180) pair and the (90,270) pair. A clean turn makes them
* agree; a malformed rotation makes them diverge even when the overall mean stays
* plausibly small — which the magnitude guard alone cannot detect. Reject when the
* two estimates disagree beyond tolerance.
*
* The disagreement scales with the surface's true tilt, so this is naturally
* lenient near level (where a turn error barely matters) and strict on a steeper
* permitted surface (where it matters most). Sets that aren't the 4-point shape
* carry no cross-check and pass here; the magnitude guard still applies.
*/
fun isRotationConsistent(samples: List<CapturedSurfaceSample>): Boolean {
if (samples.size != 4) return true
val pitchA = (samples[0].pitchDegrees + samples[2].pitchDegrees) / 2.0
val rollA = (samples[0].rollDegrees + samples[2].rollDegrees) / 2.0
val pitchB = (samples[1].pitchDegrees + samples[3].pitchDegrees) / 2.0
val rollB = (samples[1].rollDegrees + samples[3].rollDegrees) / 2.0
return hypot(pitchA - pitchB, rollA - rollB) <= PAIR_AGREEMENT_TOLERANCE_DEGREES
}
}
@@ -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"
}
}
}
@@ -0,0 +1,58 @@
package com.onthelevel.core.sensors
/**
* Surface-only presentation state. Tilt magnitude remains authoritative from 0180°;
* Pitch and roll become ill-conditioned near vertical and are deliberately suppressed.
*/
enum class SurfacePresentation {
FACE_UP,
NEAR_VERTICAL,
SCREEN_DOWN,
}
/**
* Hysteretic presentation classifier driven by the same deadbanded stable magnitude
* the UI displays. This prevents Pitch/Roll panels and placement copy from flapping
* around the 80° and 90° boundaries due to sensor noise.
*/
class SurfacePresentationDetector {
private var presentation = SurfacePresentation.FACE_UP
fun update(displayedTiltDegrees: Double): SurfacePresentation {
presentation = when (presentation) {
SurfacePresentation.FACE_UP -> {
if (displayedTiltDegrees > SCREEN_DOWN_ENTER_DEGREES) {
SurfacePresentation.SCREEN_DOWN
} else if (displayedTiltDegrees >= AXIS_SUPPRESSION_ENTER_DEGREES) {
SurfacePresentation.NEAR_VERTICAL
} else {
SurfacePresentation.FACE_UP
}
}
SurfacePresentation.NEAR_VERTICAL -> when {
displayedTiltDegrees > SCREEN_DOWN_ENTER_DEGREES -> SurfacePresentation.SCREEN_DOWN
displayedTiltDegrees < AXIS_SUPPRESSION_EXIT_DEGREES -> SurfacePresentation.FACE_UP
else -> SurfacePresentation.NEAR_VERTICAL
}
SurfacePresentation.SCREEN_DOWN -> {
if (displayedTiltDegrees <= SCREEN_DOWN_EXIT_DEGREES) {
SurfacePresentation.NEAR_VERTICAL
} else {
SurfacePresentation.SCREEN_DOWN
}
}
}
return presentation
}
fun reset() {
presentation = SurfacePresentation.FACE_UP
}
companion object {
const val AXIS_SUPPRESSION_ENTER_DEGREES = 80.0
const val AXIS_SUPPRESSION_EXIT_DEGREES = 79.0
const val SCREEN_DOWN_ENTER_DEGREES = 90.0
const val SCREEN_DOWN_EXIT_DEGREES = 89.0
}
}
@@ -1,20 +1,34 @@
package com.onthelevel.core.sensors
import com.onthelevel.core.sensors.Vec3Math.Vec3
import kotlin.math.acos
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.tan
/**
* Two-sample (180° flip) calibration, per mode (BRIEF.md §Measurement modes and calibration).
*
* Math: the surface's true tilt is fixed in the world frame; the device's own bias is fixed
* in the device frame. After rotating the device 180° about the CONTACT-PLANE NORMAL — on the
* same, unmoved surface — the true tilt appears negated in device readings while the bias
* does not move:
* DERIVATION — the surface's true tilt is fixed in the world frame; the device's own bias
* is fixed in the device frame. After rotating the device 180° about the CONTACT-PLANE
* NORMAL — on the same, unmoved surface — the true tilt appears negated in device
* readings while the bias does not move:
*
* reading₁ = tilt + bias
* reading₂ = -tilt + bias
* ⇒ bias = (reading₁ + reading₂) / 2
*
* This holds per axis for the small near-level angles calibration is used at. It is only
* valid if (a) the rotation is about the contact-plane normal and (b) the surface does not
* move between samples — the calibration UI must instruct exactly that.
* Derivation happens in angle space near level, where the flip identity is exact to
* first order. Preconditions the calibration UI must enforce: (a) rotation about the
* contact-plane normal, (b) surface unmoved between samples, (c) surface within a few
* degrees of level.
*
* APPLICATION — the stored bias angles parameterize a per-mode REFERENCE ORIENTATION:
* the device-frame direction gravity reads when the device is truly level in that mode.
* Correction is applied in vector space — a fixed rotation of every gravity sample that
* maps the reference onto the mode's ideal axis — BEFORE any display angle is derived.
* This stays correct away from zero (see tests at 30°), unlike subtracting scalar
* offsets from derived angles.
*
* Biases are stored per mode and applied only to that mode's readings; a Surface
* calibration must never touch Edge readings (AUDIT.md finding 3).
@@ -33,19 +47,84 @@ object TwoSampleCalibration {
rollBiasDegrees = deriveBiasDegrees(roll1, roll2),
)
fun deriveEdge(level1: Double, level2: Double): EdgeCalibration =
EdgeCalibration(levelBiasDegrees = deriveBiasDegrees(level1, level2))
/**
* Generalized bias derivation for a SYMMETRIC set of rotations about the surface
* normal — 2-point (0°/180°) or 4-point (0°/90°/180°/270°). The surface's true tilt
* is a sinusoid in rotation angle, so it sums to zero over any such symmetric set;
* the mean of the readings is therefore the fixed device bias. The 4-point set also
* cancels each axis with both a 180° opposite and a 90° pair, so it tolerates an
* imperfect turn better and averages out more sensor noise (thorough mode).
*/
fun deriveSurfaceFromSamples(samples: List<CapturedSurfaceSample>): SurfaceCalibration {
require(samples.isNotEmpty()) { "Calibration needs at least one captured sample" }
return SurfaceCalibration(
pitchBiasDegrees = samples.sumOf { it.pitchDegrees } / samples.size,
rollBiasDegrees = samples.sumOf { it.rollDegrees } / samples.size,
)
}
fun deriveEdge(
level1: Double,
level2: Double,
calibratedOnPositiveXEdge: Boolean = true,
): EdgeCalibration = EdgeCalibration(
levelBiasDegrees = deriveBiasDegrees(level1, level2),
calibratedOnPositiveXEdge = calibratedOnPositiveXEdge,
)
}
/**
* Surface-mode reference orientation, parameterized by the bias angles measured when the
* device is truly flat. [apply] rotates each gravity sample by the fixed rotation that
* aligns the reference direction with the screen normal (+Z).
*/
data class SurfaceCalibration(val pitchBiasDegrees: Double, val rollBiasDegrees: Double) {
fun applyToPitch(rawPitchDegrees: Double): Double = rawPitchDegrees - pitchBiasDegrees
fun applyToRoll(rawRollDegrees: Double): Double = rawRollDegrees - rollBiasDegrees
fun apply(g: GravitySample): GravitySample {
if (pitchBiasDegrees == 0.0 && rollBiasDegrees == 0.0) return g
// Reference: the direction gravity reads on a truly level surface —
// atan2(y, z) = pitchBias and atan2(x, z) = rollBias by construction.
val reference = Vec3Math.normalize(
Vec3(
tan(Math.toRadians(rollBiasDegrees)),
tan(Math.toRadians(pitchBiasDegrees)),
1.0,
),
)
val axis = Vec3Math.cross(reference, Vec3Math.WORLD_UP_FLAT)
val axisNorm = Vec3Math.norm(axis)
if (axisNorm < 1e-12) return g
val unitAxis = Vec3(axis.x / axisNorm, axis.y / axisNorm, axis.z / axisNorm)
val angle = acos(Vec3Math.dot(reference, Vec3Math.WORLD_UP_FLAT).coerceIn(-1.0, 1.0))
val corrected = Vec3Math.rotate(Vec3(g.x, g.y, g.z), unitAxis, angle)
return g.copy(x = corrected.x, y = corrected.y, z = corrected.z)
}
companion object { val NONE = SurfaceCalibration(0.0, 0.0) }
}
data class EdgeCalibration(val levelBiasDegrees: Double) {
fun applyToLevel(rawLevelDegrees: Double): Double = rawLevelDegrees - levelBiasDegrees
/**
* Edge-mode reference orientation: a fixed rotation about the device Z axis that zeroes
* the level reading for the calibrated placement, leaving plumb lean untouched (lean is
* not part of "edge level" and must not be silently "calibrated" from a leaned placement).
*
* Valid for the long edge the calibration was performed on; [calibratedOnPositiveXEdge]
* records which (true = the device's right edge down, gravity along +X). The calibration
* flow must capture this from the placement it instructed.
*/
data class EdgeCalibration(
val levelBiasDegrees: Double,
val calibratedOnPositiveXEdge: Boolean = true,
) {
fun apply(g: GravitySample): GravitySample {
if (levelBiasDegrees == 0.0) return g
val gamma = Math.toRadians(
if (calibratedOnPositiveXEdge) -levelBiasDegrees else levelBiasDegrees,
)
val c = cos(gamma)
val s = sin(gamma)
return g.copy(x = g.x * c - g.y * s, y = g.x * s + g.y * c)
}
companion object { val NONE = EdgeCalibration(0.0) }
}
@@ -0,0 +1,41 @@
package com.onthelevel.core.sensors
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/** Minimal 3-vector helpers for calibration rotations. Pure Kotlin, module-internal. */
internal object Vec3Math {
data class Vec3(val x: Double, val y: Double, val z: Double)
val WORLD_UP_FLAT = Vec3(0.0, 0.0, 1.0)
fun norm(v: Vec3): Double = sqrt(v.x * v.x + v.y * v.y + v.z * v.z)
fun normalize(v: Vec3): Vec3 {
val n = norm(v)
return if (n == 0.0) v else Vec3(v.x / n, v.y / n, v.z / n)
}
fun dot(a: Vec3, b: Vec3): Double = a.x * b.x + a.y * b.y + a.z * b.z
fun cross(a: Vec3, b: Vec3): Vec3 = Vec3(
a.y * b.z - a.z * b.y,
a.z * b.x - a.x * b.z,
a.x * b.y - a.y * b.x,
)
/** Rodrigues rotation of [v] by [angleRadians] about the UNIT axis [axis]. */
fun rotate(v: Vec3, axis: Vec3, angleRadians: Double): Vec3 {
val c = cos(angleRadians)
val s = sin(angleRadians)
val kxv = cross(axis, v)
val kdv = dot(axis, v)
return Vec3(
v.x * c + kxv.x * s + axis.x * kdv * (1 - c),
v.y * c + kxv.y * s + axis.y * kdv * (1 - c),
v.z * c + kxv.z * s + axis.z * kdv * (1 - c),
)
}
}
@@ -0,0 +1,13 @@
package com.onthelevel.core.settings
/**
* How the hands-free Surface audio assist speaks. Mutually exclusive: tones and voice
* would talk over each other, so it's one choice, not two toggles.
*
* OFF — silent.
* TONES — continuous proximity ticking that speeds up as you near level, then `level.wav`
* once at lock.
* VOICE — spoken corrections ("raise the right") + "that's level", announced only on a
* settled change. No bed underneath — speech stays sparse.
*/
enum class AudioAssistMode { OFF, TONES, VOICE }
@@ -0,0 +1,11 @@
package com.onthelevel.core.settings
/**
* How thorough the guided Surface calibration is. Both use a symmetric rotation set
* about the surface normal so the mean of the readings is the device bias; THOROUGH
* adds the 90°/270° pair to better tolerate an imperfect turn and average more noise.
*/
enum class CalibrationMode(val captureCount: Int) {
SIMPLE(2),
THOROUGH(4),
}
@@ -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.doublePreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.onthelevel.core.sensors.EdgeCalibration
import com.onthelevel.core.sensors.SurfaceCalibration
@@ -30,15 +31,42 @@ class SettingsRepository(context: Context) {
}
val edgeCalibration: Flow<EdgeCalibration> = store.data.map { prefs ->
EdgeCalibration(levelBiasDegrees = prefs[Keys.EDGE_LEVEL_BIAS] ?: 0.0)
EdgeCalibration(
levelBiasDegrees = prefs[Keys.EDGE_LEVEL_BIAS] ?: 0.0,
calibratedOnPositiveXEdge = prefs[Keys.EDGE_CAL_POSITIVE_X] ?: true,
)
}
val hapticsEnabled: Flow<Boolean> = store.data.map { it[Keys.HAPTICS_ENABLED] ?: true }
val audioCueEnabled: Flow<Boolean> = store.data.map { it[Keys.AUDIO_CUE_ENABLED] ?: false }
/** Off / Tones / Voice, migrating from the legacy audio-cue boolean if unset. */
val audioAssistMode: Flow<AudioAssistMode> = store.data.map { prefs ->
when (prefs[Keys.AUDIO_ASSIST_MODE]) {
AudioAssistMode.TONES.name -> AudioAssistMode.TONES
AudioAssistMode.VOICE.name -> AudioAssistMode.VOICE
AudioAssistMode.OFF.name -> AudioAssistMode.OFF
else -> if (prefs[Keys.AUDIO_CUE_ENABLED] == true) AudioAssistMode.TONES else AudioAssistMode.OFF
}
}
/** 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 }
/** 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
}
}
val calibrationMode: Flow<CalibrationMode> = store.data.map { prefs ->
when (prefs[Keys.CALIBRATION_MODE]) {
CalibrationMode.THOROUGH.name -> CalibrationMode.THOROUGH
else -> CalibrationMode.SIMPLE
}
}
suspend fun setSurfaceCalibration(calibration: SurfaceCalibration) {
store.edit {
it[Keys.SURFACE_PITCH_BIAS] = calibration.pitchBiasDegrees
@@ -46,8 +74,13 @@ class SettingsRepository(context: Context) {
}
}
suspend fun clearSurfaceCalibration() = setSurfaceCalibration(SurfaceCalibration.NONE)
suspend fun setEdgeCalibration(calibration: EdgeCalibration) {
store.edit { it[Keys.EDGE_LEVEL_BIAS] = calibration.levelBiasDegrees }
store.edit {
it[Keys.EDGE_LEVEL_BIAS] = calibration.levelBiasDegrees
it[Keys.EDGE_CAL_POSITIVE_X] = calibration.calibratedOnPositiveXEdge
}
}
suspend fun setHapticsEnabled(enabled: Boolean) {
@@ -58,18 +91,34 @@ class SettingsRepository(context: Context) {
store.edit { it[Keys.AUDIO_CUE_ENABLED] = enabled }
}
suspend fun setAudioAssistMode(mode: AudioAssistMode) {
store.edit { it[Keys.AUDIO_ASSIST_MODE] = mode.name }
}
suspend fun setReducedMotion(enabled: Boolean) {
store.edit { it[Keys.REDUCED_MOTION] = enabled }
}
suspend fun setMeasurementUnits(units: MeasurementUnits) {
store.edit { it[Keys.MEASUREMENT_UNITS] = units.name }
}
suspend fun setCalibrationMode(mode: CalibrationMode) {
store.edit { it[Keys.CALIBRATION_MODE] = mode.name }
}
// TODO(ruler): screen-ruler scale keyed by display identity/characteristics (BRIEF.md).
private object Keys {
val SURFACE_PITCH_BIAS = doublePreferencesKey("surface_pitch_bias_deg")
val SURFACE_ROLL_BIAS = doublePreferencesKey("surface_roll_bias_deg")
val EDGE_LEVEL_BIAS = doublePreferencesKey("edge_level_bias_deg")
val EDGE_CAL_POSITIVE_X = booleanPreferencesKey("edge_cal_positive_x")
val HAPTICS_ENABLED = booleanPreferencesKey("haptics_enabled")
val AUDIO_CUE_ENABLED = booleanPreferencesKey("audio_cue_enabled")
val AUDIO_ASSIST_MODE = stringPreferencesKey("audio_assist_mode")
val REDUCED_MOTION = booleanPreferencesKey("reduced_motion")
val MEASUREMENT_UNITS = stringPreferencesKey("measurement_units")
val CALIBRATION_MODE = stringPreferencesKey("calibration_mode")
}
}
@@ -1,5 +1,6 @@
package com.onthelevel.feature.angle
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -21,12 +22,12 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.onthelevel.AppContainer
import com.onthelevel.core.design.KeepScreenOn
import com.onthelevel.core.design.LevelColors
import com.onthelevel.core.sensors.Ema
import com.onthelevel.core.sensors.GravitySample
import com.onthelevel.core.sensors.OrientationMath
import com.onthelevel.feature.level.formatDegrees
import com.onthelevel.feature.level.performConfirmHaptic
@@ -34,6 +35,13 @@ import kotlinx.coroutines.flow.map
/**
* Scaffold Angle screen: live absolute/relative angle with hold-to-zero (always free).
*
* Relative zero stores the full gravity DIRECTION at the moment of zeroing, and the
* relative reading is the angle between the current and stored directions. Subtracting
* tilt magnitudes would lose the axis: zeroed at 10° pitch and moved to 10° roll, the
* device has genuinely rotated ~14°, and that is what this reports (Codex review).
* The relative reading is therefore unsigned.
*
* TODO(pro): target-angle alerts and saved named references behind the entitlement.
*/
@Composable
@@ -48,29 +56,43 @@ fun AngleScreen(container: AppContainer) {
return
}
data class AngleReading(val tilt: Double, val pitch: Double, val roll: Double)
val readingFlow = remember {
val tiltEma = Ema(0.15)
val pitchEma = Ema(0.15)
val rollEma = Ema(0.15)
// Smooth the vector components, then derive angles — keeps the smoothed
// gravity direction available for the vector-based zero reference.
val xEma = Ema(0.15)
val yEma = Ema(0.15)
val zEma = Ema(0.15)
var lastNanos: Long? = null
sensorSource.gravity.map { g ->
val dt = lastNanos?.let { (g.timestampNanos - it) / 1e9 } ?: 0.0
lastNanos = g.timestampNanos
AngleReading(
tilt = tiltEma.update(OrientationMath.surfaceTiltMagnitudeDegrees(g), dt),
pitch = pitchEma.update(OrientationMath.surfacePitchDegrees(g), dt),
roll = rollEma.update(OrientationMath.surfaceRollDegrees(g), dt),
GravitySample(
x = xEma.update(g.x, dt),
y = yEma.update(g.y, dt),
z = zEma.update(g.z, dt),
timestampNanos = g.timestampNanos,
)
}
}
val reading by readingFlow.collectAsStateWithLifecycle(initialValue = null)
val smoothed by readingFlow.collectAsStateWithLifecycle(initialValue = null)
// Relative reference: "hold to zero" (BRIEF.md §Angle — always free).
var zeroReferenceDegrees by rememberSaveable { mutableStateOf(0.0) }
// Zero reference: the smoothed gravity direction captured on long-press.
// Empty array = no reference set. DoubleArray keeps rememberSaveable happy.
var zeroReference by rememberSaveable { mutableStateOf(doubleArrayOf()) }
val view = LocalView.current
val primaryDegrees = smoothed?.let { s ->
if (zeroReference.size == 3) {
OrientationMath.angleBetweenDegrees(
s,
GravitySample(zeroReference[0], zeroReference[1], zeroReference[2], 0),
)
} else {
OrientationMath.surfaceTiltMagnitudeDegrees(s)
}
}
val isRelative = zeroReference.size == 3
Column(
modifier = Modifier
.fillMaxSize()
@@ -82,7 +104,7 @@ fun AngleScreen(container: AppContainer) {
) {
Text("ANGLE", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
Text(
text = if (zeroReferenceDegrees != 0.0) "RELATIVE · HOLD TO RE-ZERO" else "HOLD TO ZERO",
text = if (isRelative) "RELATIVE · HOLD TO RE-ZERO" else "HOLD TO ZERO",
style = MaterialTheme.typography.labelSmall,
color = LevelColors.TextFaint,
)
@@ -92,27 +114,28 @@ fun AngleScreen(container: AppContainer) {
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.pointerInput(reading != null) {
.pointerInput(Unit) {
detectTapGestures(
onLongPress = {
reading?.let {
zeroReferenceDegrees = it.tilt
smoothed?.let {
zeroReference = doubleArrayOf(it.x, it.y, it.z)
view.performConfirmHaptic()
}
},
onDoubleTap = { zeroReference = doubleArrayOf() },
)
},
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = reading?.let { formatDegrees(it.tilt - zeroReferenceDegrees) } ?: "",
text = primaryDegrees?.let(::formatDegrees) ?: "",
style = MaterialTheme.typography.displayLarge,
color = LevelColors.TextPrimary,
)
if (zeroReferenceDegrees != 0.0) {
if (isRelative) {
Text(
text = "zeroed at " + formatDegrees(zeroReferenceDegrees),
text = "from saved orientation · double-tap to clear",
style = MaterialTheme.typography.labelSmall,
color = LevelColors.Amber,
textAlign = TextAlign.Center,
@@ -125,9 +148,18 @@ fun AngleScreen(container: AppContainer) {
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
SecondaryValue("PITCH", reading?.pitch?.let(::formatDegrees), Modifier.weight(1f))
SecondaryValue("ROLL", reading?.roll?.let(::formatDegrees), Modifier.weight(1f))
SecondaryValue("GRADE", reading?.pitch?.let { formatGrade(OrientationMath.percentGrade(it)) }, Modifier.weight(1f))
val pitch = smoothed?.let(OrientationMath::surfacePitchDegrees)
SecondaryValue("PITCH", pitch?.let(::formatDegrees), Modifier.weight(1f))
SecondaryValue(
"ROLL",
smoothed?.let(OrientationMath::surfaceRollDegrees)?.let(::formatDegrees),
Modifier.weight(1f),
)
SecondaryValue(
"GRADE",
pitch?.let { formatGrade(OrientationMath.percentGrade(it)) },
Modifier.weight(1f),
)
}
}
}
@@ -0,0 +1,244 @@
package com.onthelevel.feature.level
import android.content.Context
import android.media.AudioAttributes
import android.media.SoundPool
import android.os.SystemClock
import android.speech.tts.TextToSpeech
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.onthelevel.R
import com.onthelevel.core.audio.SurfaceTickPolicy
import com.onthelevel.core.audio.VoiceGuidancePolicy
import com.onthelevel.core.settings.AudioAssistMode
import java.util.Locale
import kotlin.math.hypot
/**
* Hands-free Surface audio assist (SOUND_DESIGN_HANDOFF.md).
*
* TONES — proximity ticks that speed up as you near level; the moment you're on the
* bullseye (aligned zone, spatial hysteresis) the ticks give way to the looping `level.wav`
* bullseye sound — immediately, even on a fast pass. One vocabulary: faster ticks = closer,
* bullseye sound = centered now. The persistent lime/label/haptic "held level" is separate
* (velocity-aware LockDetector); the sound does not wait on it.
*
* VOICE — deliberately separate: spoken corrections + "that's level", settled-change only.
*
* Everything plays on the media stream (so the volume slider works) and never takes audio
* focus. Players/speakers exist only while enabled and foregrounded; released on dispose.
*/
@Composable
fun AudioLevelAssist(reading: LevelReading?, mode: AudioAssistMode) {
val isForeground = rememberIsResumed()
val tickPolicy = remember { SurfaceTickPolicy() }
val context = LocalContext.current.applicationContext
val player = remember(mode, isForeground, context) {
if (mode != AudioAssistMode.OFF && isForeground) SonarSoundPool(context) else null
}
val speaker = remember(mode, isForeground, context) {
if (mode == AudioAssistMode.VOICE && isForeground) VoiceSpeaker(context) else null
}
// Fresh per speaker so each VOICE session re-evaluates from the current reading — and
// the policy is only consumed once the speaker is ready, so a phrase can't be queued
// and then go stale during TTS init (Codex).
val voicePolicy = remember(speaker) { VoiceGuidancePolicy() }
DisposableEffect(player) { onDispose { player?.release() } }
DisposableEffect(speaker) { onDispose { speaker?.shutdown() } }
LaunchedEffect(reading, mode, isForeground, player, speaker) {
val current = reading ?: return@LaunchedEffect
// TONES: proximity ticks outside the center zone; the bullseye sound the instant
// you're aligned. Alignment uses the SAME axes as the bubble (hypot of stable
// pitch/roll), so the sound can't lag the visual.
val alignError = hypot(
current.stableSurfacePitchDegrees ?: 0.0,
current.stableSurfaceRollDegrees ?: 0.0,
)
when (
tickPolicy.update(
SurfaceTickPolicy.Input(
isEnabled = mode == AudioAssistMode.TONES,
isAppForeground = isForeground,
placementOk = current.placementOk,
surfacePresentation = current.surfacePresentation,
errorDegrees = alignError,
nowMillis = SystemClock.elapsedRealtime(),
),
)
) {
// Aligned: hold the looping bullseye sound (idempotent — starts once).
SurfaceTickPolicy.Cue.ALIGNED -> player?.ensureLevelLooping()
// A tick means we're outside the zone: stop the bullseye first (order matters),
// then tick.
SurfaceTickPolicy.Cue.TICK -> {
player?.stopLevel()
player?.playTick()
}
// Outside the zone, no tick due: make sure the bullseye is stopped (covers exit).
null -> player?.stopLevel()
}
// Only run the voice policy once the speaker is ready — never consume its state
// for a phrase we can't speak yet. When ready, it evaluates the current reading,
// so nothing stale gets spoken after init finishes.
if (speaker?.isReady == true) {
val phrase = voicePolicy.update(
VoiceGuidancePolicy.Input(
isEnabled = true,
isAppForeground = isForeground,
placementOk = current.placementOk,
presentation = current.surfacePresentation,
isSettling = current.isSettling,
pitchDegrees = current.stableSurfacePitchDegrees ?: 0.0,
rollDegrees = current.stableSurfaceRollDegrees ?: 0.0,
isLocked = current.isLocked,
nowMillis = System.currentTimeMillis(),
),
)
if (phrase != null) speaker.speak(phrase)
}
}
}
@Composable
private fun rememberIsResumed(): Boolean {
val lifecycleOwner = LocalLifecycleOwner.current
var isResumed by remember(lifecycleOwner) {
mutableStateOf(lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED))
}
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, _ ->
isResumed = lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
return isResumed
}
private class SonarSoundPool(context: Context) {
private val loaded = mutableSetOf<Int>()
private val soundPool = SoundPool.Builder()
.setMaxStreams(4)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build(),
)
.build()
private val tickSound: Int
private val levelSound: Int
private var levelStreamId: Int = 0
private var levelPending = false // Level asked for before the sample finished loading
init {
soundPool.setOnLoadCompleteListener { _, sampleId, status ->
if (status == 0) {
loaded += sampleId
// If ALIGNED arrived before the Level sample finished loading, start the loop
// the moment it lands (ensureLevelLooping set levelPending instead of dropping it).
if (sampleId == levelSound && levelPending) {
levelPending = false
startLevelLoop()
}
}
}
tickSound = soundPool.load(context, R.raw.tick, 1)
levelSound = soundPool.load(context, R.raw.level, 1)
}
/** One proximity tick — constant volume (the rate is the signal, not loudness). */
fun playTick() {
if (tickSound in loaded) soundPool.play(tickSound, TICK_VOLUME, TICK_VOLUME, 1, 0, 1f)
}
/**
* The bullseye sound, looped while you're aligned. Idempotent — safe to call every frame;
* starts once and keeps going. Queues if the sample hasn't loaded yet.
*/
fun ensureLevelLooping() {
if (levelStreamId != 0) return // already looping
if (levelSound in loaded) startLevelLoop() else levelPending = true
}
private fun startLevelLoop() {
levelStreamId = soundPool.play(levelSound, LEVEL_VOLUME, LEVEL_VOLUME, 2, -1, 1f)
}
fun stopLevel() {
levelPending = false // cancel a queued start (we left the zone before it loaded)
if (levelStreamId != 0) {
soundPool.stop(levelStreamId)
levelStreamId = 0
}
}
fun release() = soundPool.release()
private companion object {
const val TICK_VOLUME = 0.55f
const val LEVEL_VOLUME = 0.85f
}
}
/**
* On-device text-to-speech, structured so premium pre-recorded clips can be swapped in
* later without touching callers. Mixes as sonification and does not take audio focus.
* TODO(voice): prefer bundled clips (res/raw) when present, fall back to TTS.
*/
private class VoiceSpeaker(context: Context) {
/** The caller waits on this before consuming the voice policy, so nothing goes stale. */
var isReady = false
private set
private lateinit var tts: TextToSpeech
init {
tts = TextToSpeech(context.applicationContext) { status ->
if (status == TextToSpeech.SUCCESS) {
tts.language = Locale.US
tts.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build(),
)
isReady = true
}
}
}
fun speak(phrase: VoiceGuidancePolicy.Phrase) {
if (isReady) tts.speak(textFor(phrase), TextToSpeech.QUEUE_FLUSH, null, "level-voice")
}
private fun textFor(phrase: VoiceGuidancePolicy.Phrase): String = when (phrase) {
VoiceGuidancePolicy.Phrase.Level -> "that's level"
is VoiceGuidancePolicy.Phrase.Raise -> {
val side = when (phrase.direction) {
VoiceGuidancePolicy.Direction.RAISE_LEFT -> "left"
VoiceGuidancePolicy.Direction.RAISE_RIGHT -> "right"
VoiceGuidancePolicy.Direction.RAISE_TOP -> "top"
VoiceGuidancePolicy.Direction.RAISE_BOTTOM -> "bottom"
}
if (phrase.fine) "raise the $side, just a little" else "raise the $side"
}
}
fun shutdown() {
tts.stop()
tts.shutdown()
}
}
@@ -8,7 +8,9 @@ import com.onthelevel.core.sensors.LevelMode
import com.onthelevel.core.sensors.LockDetector
import com.onthelevel.core.sensors.OrientationMath
import com.onthelevel.core.sensors.SurfaceCalibration
import kotlin.math.hypot
import com.onthelevel.core.sensors.SurfacePresentation
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).
@@ -19,18 +21,37 @@ data class LevelReading(
val mode: LevelMode,
/** Deadbanded stable magnitude for the big readout, in degrees. */
val displayPrimaryDegrees: Double,
/** Surface: pitch. Edge: signed level deviation. Deadbanded. */
val secondaryADegrees: Double,
/** Surface: roll. Edge: plumb lean. Deadbanded. */
val secondaryBDegrees: Double,
/** Surface: pitch when it is meaningful. Edge: signed level deviation. */
val secondaryADegrees: Double?,
/** Surface: roll when it is meaningful. Edge: plumb lean. */
val secondaryBDegrees: Double?,
/** Stable calibrated Surface axes for the visual instrument; null outside Surface mode. */
val stableSurfacePitchDegrees: Double? = null,
val stableSurfaceRollDegrees: Double? = null,
/** Stable (pre-deadband) Surface tilt magnitude; drives the audio tick rate. Null for Edge. */
val stableTiltDegrees: Double? = null,
/** Surface-only presentation state; null for Edge mode. */
val surfacePresentation: SurfacePresentation? = null,
/** False when the device is not physically in the selected mode's geometry. */
val placementOk: Boolean,
/** Surface-only shared motion gate; true while the phone is still settling. */
val isSettling: Boolean = false,
val isLocked: Boolean,
val fireFeedback: Boolean,
)
/**
* Stateful per-collection pipeline: calibration → EMA smoothing → lock detection →
* display deadband. Created fresh when mode or calibration changes; the LockDetector
* is shared across recreations so the haptic debounce survives mode switches.
* Stateful per-collection pipeline: vector-space calibration → angle derivation →
* EMA smoothing → lock detection → display deadband. Calibration is applied to the
* gravity VECTOR before any display angle is derived, so it stays a reference
* orientation rather than a scalar offset. Created fresh when mode or calibration
* changes; the LockDetector is shared across recreations so the haptic debounce
* survives mode switches.
*
* Invalid Edge placement (e.g. Edge selected but the phone lying flat) suppresses
* lock detection — asin(gy) reads near zero there too, and locking on it would be
* a lie. Surface lock is self-gating: only a near-zero surface tilt can acquire it,
* while its magnitude remains visible at every orientation.
*/
class LevelPipeline(
private val mode: LevelMode,
@@ -38,11 +59,14 @@ class LevelPipeline(
private val edgeCalibration: EdgeCalibration,
private val lockDetector: LockDetector,
) {
private val emaPrimary = Ema(SMOOTHING_TAU_SECONDS)
private val emaA = Ema(SMOOTHING_TAU_SECONDS)
private val emaB = Ema(SMOOTHING_TAU_SECONDS)
private val primaryDeadband = DisplayDeadband()
private val aDeadband = DisplayDeadband()
private val bDeadband = DisplayDeadband()
private val surfacePresentationDetector = SurfacePresentationDetector()
private val settlingDetector = SettlingDetector()
private var lastTimestampNanos: Long? = null
fun process(g: GravitySample): LevelReading {
@@ -51,42 +75,68 @@ class LevelPipeline(
// Sensor timestamps are monotonic; using them (not wall clock) keeps the
// lock state machine deterministic under recorded traces.
val nowMillis = g.timestampNanos / 1_000_000
val placementOk = OrientationMath.isPlacementValid(g, mode)
return when (mode) {
LevelMode.SURFACE -> {
val pitch = emaA.update(
surfaceCalibration.applyToPitch(OrientationMath.surfacePitchDegrees(g)),
val corrected = surfaceCalibration.apply(g)
val pitch = emaA.update(OrientationMath.surfacePitchDegrees(corrected), dtSeconds)
val roll = emaB.update(OrientationMath.surfaceRollDegrees(corrected), dtSeconds)
val magnitude = emaPrimary.update(
OrientationMath.surfaceTiltMagnitudeDegrees(corrected),
dtSeconds,
)
val roll = emaB.update(
surfaceCalibration.applyToRoll(OrientationMath.surfaceRollDegrees(g)),
dtSeconds,
)
// Near level, calibrated tilt magnitude ≈ hypot of the two calibrated
// axis angles — keeps lock detection consistent with the displayed axes.
val magnitude = hypot(pitch, roll)
val lock = lockDetector.update(magnitude, nowMillis)
val displayMagnitude = primaryDeadband.update(magnitude)
val presentation = surfacePresentationDetector.update(displayMagnitude)
val settling = settlingDetector.update(pitch, roll, g.timestampNanos)
// Velocity-aware lock: ease into center → quick confirm; fly across → nothing.
val lock = lockDetector.update(magnitude, settling.movementRateDegreesPerSecond, nowMillis)
LevelReading(
mode = mode,
displayPrimaryDegrees = primaryDeadband.update(magnitude),
secondaryADegrees = aDeadband.update(pitch),
secondaryBDegrees = bDeadband.update(roll),
displayPrimaryDegrees = displayMagnitude,
secondaryADegrees = if (presentation == SurfacePresentation.FACE_UP) {
aDeadband.update(pitch)
} else {
null
},
secondaryBDegrees = if (presentation == SurfacePresentation.FACE_UP) {
bDeadband.update(roll)
} else {
null
},
stableTiltDegrees = magnitude,
stableSurfacePitchDegrees = pitch,
stableSurfaceRollDegrees = roll,
surfacePresentation = presentation,
placementOk = placementOk,
isSettling = settling.isSettling,
isLocked = lock.isLocked,
fireFeedback = lock.fireFeedback,
)
}
LevelMode.EDGE -> {
val level = emaA.update(
edgeCalibration.applyToLevel(OrientationMath.edgeLevelDegrees(g)),
val corrected = edgeCalibration.apply(g)
val level = emaPrimary.update(
OrientationMath.edgeLevelDegrees(corrected),
dtSeconds,
)
val lean = emaB.update(OrientationMath.edgePlumbLeanDegrees(g), dtSeconds)
val lock = lockDetector.update(level, nowMillis)
val lean = emaB.update(
OrientationMath.edgePlumbLeanDegrees(corrected),
dtSeconds,
)
// Edge has no movement-rate signal yet: null → classic fixed-dwell lock.
val lock = lockDetector.update(
if (placementOk) level else Double.MAX_VALUE,
null,
nowMillis,
)
val displayLevel = primaryDeadband.update(level)
LevelReading(
mode = mode,
displayPrimaryDegrees = primaryDeadband.update(level),
secondaryADegrees = aDeadband.update(level),
displayPrimaryDegrees = displayLevel,
secondaryADegrees = displayLevel,
secondaryBDegrees = bDeadband.update(lean),
placementOk = placementOk,
isLocked = lock.isLocked,
fireFeedback = lock.fireFeedback,
)
@@ -7,8 +7,11 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Settings
@@ -20,6 +23,7 @@ import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -29,8 +33,15 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.onthelevel.AppContainer
@@ -39,10 +50,16 @@ import com.onthelevel.core.design.LevelColors
import com.onthelevel.core.sensors.EdgeCalibration
import com.onthelevel.core.sensors.LevelMode
import com.onthelevel.core.sensors.LockDetector
import com.onthelevel.core.sensors.RiseRun
import com.onthelevel.core.sensors.SensorSource
import com.onthelevel.core.sensors.SurfaceCalibration
import com.onthelevel.core.sensors.SurfaceGuidance
import com.onthelevel.core.sensors.SurfacePresentation
import com.onthelevel.core.settings.AudioAssistMode
import com.onthelevel.core.settings.MeasurementUnits
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import java.util.Locale
/**
@@ -53,7 +70,7 @@ import java.util.Locale
* while the portrait-locked device stands on its long edge.
*/
@Composable
fun LevelScreen(container: AppContainer) {
fun LevelScreen(container: AppContainer, onOpenSettings: () -> Unit) {
KeepScreenOn()
val sensorSource = container.sensorSource
@@ -69,6 +86,12 @@ fun LevelScreen(container: AppContainer) {
.collectAsStateWithLifecycle(initialValue = EdgeCalibration.NONE)
val hapticsEnabled by container.settings.hapticsEnabled
.collectAsStateWithLifecycle(initialValue = true)
val audioMode by container.settings.audioAssistMode
.collectAsStateWithLifecycle(initialValue = AudioAssistMode.OFF)
val reducedMotion by container.settings.reducedMotion
.collectAsStateWithLifecycle(initialValue = false)
val measurementUnits by container.settings.measurementUnits
.collectAsStateWithLifecycle(initialValue = MeasurementUnits.METRIC)
val view = LocalView.current
val lockDetector = remember { LockDetector() }
@@ -81,6 +104,7 @@ fun LevelScreen(container: AppContainer) {
.onEach { if (it.fireFeedback && hapticsEnabled) view.performConfirmHaptic() }
}
val reading by readingFlow.collectAsStateWithLifecycle(initialValue = null)
AudioLevelAssist(reading, audioMode)
val isLocked = reading?.isLocked == true
val isCalibrated = when (mode) {
@@ -106,12 +130,33 @@ fun LevelScreen(container: AppContainer) {
color = if (isCalibrated) LevelColors.LimeLock else LevelColors.TextFaint,
)
}
IconButton(onClick = { /* TODO(feature): calibration & settings entry */ }) {
Icon(
Icons.Outlined.Settings,
contentDescription = "Calibration and settings",
tint = LevelColors.TextDim,
)
Row(verticalAlignment = Alignment.CenterVertically) {
// Tap cycles Off -> Tones -> Voice. Lime when active (a confirmed on-state).
TextButton(onClick = {
val next = when (audioMode) {
AudioAssistMode.OFF -> AudioAssistMode.TONES
AudioAssistMode.TONES -> AudioAssistMode.VOICE
AudioAssistMode.VOICE -> AudioAssistMode.OFF
}
container.applicationScope.launch { container.settings.setAudioAssistMode(next) }
}) {
Text(
text = when (audioMode) {
AudioAssistMode.OFF -> "AUDIO OFF"
AudioAssistMode.TONES -> "TONES"
AudioAssistMode.VOICE -> "VOICE"
},
style = MaterialTheme.typography.labelSmall,
color = if (audioMode == AudioAssistMode.OFF) LevelColors.TextDim else LevelColors.LimeLockText,
)
}
IconButton(onClick = onOpenSettings) {
Icon(
Icons.Outlined.Settings,
contentDescription = "Settings",
tint = LevelColors.TextDim,
)
}
}
}
@@ -133,23 +178,69 @@ fun LevelScreen(container: AppContainer) {
.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
val placementOk = reading?.placementOk ?: true
val surfacePresentation = reading?.surfacePresentation
Column(
modifier = Modifier.offset(y = 16.dp),
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 = reading?.let { formatDegrees(it.displayPrimaryDegrees) } ?: "",
text = reading?.let { primaryReadout(formatDegrees(it.displayPrimaryDegrees)) }
?: AnnotatedString(""),
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).
// The label states the tolerance (the lock's exit threshold), so the
// rounded readout and the "level" claim can never contradict.
Text(
text = when {
isLocked && mode == LevelMode.SURFACE -> "Surface is flat"
isLocked -> "Edge is level"
mode == LevelMode.SURFACE && surfacePresentation == SurfacePresentation.SCREEN_DOWN ->
"Screen facing down · turn phone screen up"
mode == LevelMode.SURFACE && surfacePresentation == SurfacePresentation.NEAR_VERTICAL ->
"Near vertical · pitch and roll unavailable"
!placementOk && mode == LevelMode.SURFACE -> "Lay the phone flat, screen up"
!placementOk -> "Stand the phone upright on a long edge"
isLocked && mode == LevelMode.SURFACE -> "Flat within ${formatTolerance()}"
isLocked -> "Level within ${formatTolerance()}"
else -> ""
},
style = MaterialTheme.typography.headlineMedium,
color = LevelColors.LimeLockText,
color = when {
!placementOk -> LevelColors.TextDim
isLocked -> LevelColors.LimeLockText
else -> LevelColors.TextPrimary
},
textAlign = TextAlign.Center,
)
if (mode == LevelMode.SURFACE && surfacePresentation == SurfacePresentation.FACE_UP) {
Spacer(Modifier.height(8.dp))
// This slot stays the same height for Settling, guidance, and
// level states so a stable phone never makes the instrument
// jump as its correction copy changes.
Box(
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
contentAlignment = Alignment.TopCenter,
) {
// Guidance and the status label are mutually exclusive
// (guidance renders only while the label line is empty),
// so it rises to visually take the label's place.
SurfaceAdjustmentInfo(
reading,
measurementUnits,
Modifier.offset(y = (-36).dp),
)
}
}
}
}
@@ -171,19 +262,79 @@ fun LevelScreen(container: AppContainer) {
}
}
/**
* 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,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
if (reading == null) return@Column
// A locked surface is flat within tolerance: issuing correction guidance
// under the lock line would contradict it and crowd the status stack.
if (reading.isLocked) return@Column
if (reading.isSettling) {
Text(
text = "Settling",
style = MaterialTheme.typography.labelSmall,
color = LevelColors.TextDim,
)
return@Column
}
val pitch = reading.secondaryADegrees ?: return@Column
val roll = reading.secondaryBDegrees ?: return@Column
val highLabel = SurfaceGuidance.from(pitch, roll).highLabel ?: return@Column
// One rank below the lock/status line (28sp mono): guidance advises, it
// doesn't compete.
Text(
text = highLabel,
style = MaterialTheme.typography.titleLarge,
color = LevelColors.Amber,
textAlign = TextAlign.Center,
)
Text(
text = formatRiseRun(reading.displayPrimaryDegrees, units),
style = MaterialTheme.typography.labelSmall,
color = LevelColors.TextDim,
)
}
}
@Composable
private fun ValuePanel(label: String, value: Double?, modifier: Modifier = Modifier) {
Surface(
modifier = modifier,
color = LevelColors.Panel,
color = LevelColors.ReadoutPanel,
contentColor = LevelColors.TextPrimary,
shape = MaterialTheme.shapes.large,
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(label, style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
label,
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.labelSmall,
color = LevelColors.TextDim,
textAlign = TextAlign.Center,
)
Text(
text = value?.let { formatDegrees(it) } ?: "",
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
}
}
@@ -213,6 +364,41 @@ private fun statusLine(isCalibrated: Boolean, kind: SensorSource.Kind): String {
internal fun formatDegrees(value: Double): String = String.format(Locale.US, "%.1f°", value)
/**
* Primary readout typography: the degree symbol rendered small and faint keeps the
* number elegant without losing the instrument voice — and its invisible leading
* twin balances the trailing one, so the digits sit optically dead-center no
* matter the glyph widths.
*/
private fun primaryReadout(text: String): AnnotatedString = buildAnnotatedString {
// The shrunken symbol would otherwise sit on the digits' baseline at
// mid-height; the baseline shift floats it back up to the cap line where a
// degree mark belongs.
val degreeStyle = SpanStyle(
fontSize = DEGREE_SYMBOL_EM.em,
baselineShift = BaselineShift(DEGREE_SYMBOL_SHIFT),
)
withStyle(degreeStyle.copy(color = Color.Transparent)) { append("°") }
append(text.removeSuffix("°"))
withStyle(degreeStyle.copy(color = LevelColors.TextDim)) { append("°") }
}
private const val DEGREE_SYMBOL_EM = .55f
// No cap-height-alignment primitive exists in Compose; this value is derived from
// Roboto's metrics (digit cap height minus the degree glyph's top at the symbol
// size above — resize them together), verified against on-device captures.
private const val DEGREE_SYMBOL_SHIFT = .38f
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 =
String.format(Locale.US, "%.2f°", LockDetector.DEFAULT_EXIT_DEGREES)
internal fun View.performConfirmHaptic() {
val constant = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
HapticFeedbackConstants.CONFIRM
@@ -0,0 +1,302 @@
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.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.onthelevel.core.design.LevelColors
import com.onthelevel.core.design.ReadoutFontFamily
import com.onthelevel.core.sensors.SurfaceGuidance
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.sin
/**
* Glass bullseye whose target and rings are both driven by SurfaceGuidance.
*
* The bubble is a VOID in fluorescent fluid: a perfectly round, borderless
* translucent orb — one symmetric gradient whose edge fades out rather than
* outlining — grounded by a contact shadow and lit by a small fixed upper-left
* reflection. The calibration marks (and the red target dot) stay readable
* through it. Every layer serves depth or readability (SURFACE_LEVEL_PLAN.md:
* no decorative gloss); alphas are the tuning knobs if anything reads as chrome.
*/
@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) }
val textMeasurer = rememberTextMeasurer()
// These must run independently. animateTo suspends until a spring settles;
// sequencing axes here would starve Y under the sensor's ~50 Hz retargeting
// and make diagonal movement trace an unnatural L.
LaunchedEffect(targetX, reducedMotion, systemMotionDisabled) {
if (reducedMotion || systemMotionDisabled) {
animatedX.snapTo(targetX)
} else {
animatedX.animateTo(targetX, spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow))
}
}
LaunchedEffect(targetY, reducedMotion, systemMotionDisabled) {
if (reducedMotion || systemMotionDisabled) {
animatedY.snapTo(targetY)
} else {
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
val bubbleCenter = center + Offset(animatedX.value * vialRadius, animatedY.value * vialRadius)
// Nested inside the 1° target ring (0.148 × radius): the level-read is the
// classic "bubble inside the circle", never the bubble swallowing the mark.
val bubbleRadius = radius * BUBBLE_RADIUS_RATIO
// The target ring highlights on what the EYE sees: the drawn bubble sitting
// inside (or tangent to) the circle. Lock truth — label, pulse, haptic —
// stays with LockDetector; this accent must never outlive the visual.
val targetRingRadius = vialRadius * SurfaceGuidance.ringRadiusRatio(1.0).toFloat()
val bubbleInsideTarget =
hypot(bubbleCenter.x - center.x, bubbleCenter.y - center.y) + bubbleRadius <=
targetRingRadius + 1.dp.toPx()
drawVialGlass(center, radius, vialRadius)
drawCalibrationMarks(center, radius, vialRadius, bubbleInsideTarget, textMeasurer)
if (lockPulse.value > 0f) {
drawCircle(LevelColors.LimeLock.copy(alpha = .55f * lockPulse.value), vialRadius, center, style = Stroke(3.dp.toPx()))
}
drawBubbleBody(bubbleCenter, bubbleRadius)
drawBubbleLight(bubbleCenter, bubbleRadius)
}
}
private fun DrawScope.drawVialGlass(center: Offset, radius: Float, vialRadius: Float) {
drawCircle(Brush.radialGradient(listOf(Color(0xFF22272E), Color(0xFF090A0C)), center, radius), radius, center)
// Faint sheen from the fixed upper-left light — a depth cue, kept far below gloss.
drawCircle(
Brush.radialGradient(
listOf(Color.White.copy(alpha = .05f), Color.Transparent),
center + Offset(-radius * .38f, -radius * .42f),
radius * .9f,
),
radius,
center,
)
// Fluid vignette: the dye reads darker where it meets the vial wall.
drawCircle(
Brush.radialGradient(
colorStops = arrayOf(
0f to Color.Transparent,
.78f to Color.Transparent,
1f to Color.Black.copy(alpha = .32f),
),
center = center,
radius = vialRadius,
),
vialRadius,
center,
)
// Machined bezel: a shallow metallic annulus, lit from above, instead of a
// flat outline — housing depth without adding information noise.
val bezelWidth = 5.dp.toPx()
drawCircle(
Brush.linearGradient(
listOf(Color.White.copy(alpha = .20f), Color.White.copy(alpha = .05f)),
start = Offset(center.x, center.y - radius),
end = Offset(center.x, center.y + radius),
),
radius - bezelWidth / 2f,
center,
style = Stroke(bezelWidth),
)
drawCircle(Color.White.copy(alpha = .14f), radius - bezelWidth, center, style = Stroke(1.dp.toPx()))
// Etched bezel ticks: cardinals strong, 45° minors faint — orientation
// vocabulary borrowed from real instrument bezels.
for (degrees in 0 until 360 step 45) {
val isCardinal = degrees % 90 == 0
val angle = Math.toRadians(degrees.toDouble())
val direction = Offset(cos(angle).toFloat(), sin(angle).toFloat())
val outer = radius - 1.dp.toPx()
val inner = outer - if (isCardinal) 8.dp.toPx() else 5.dp.toPx()
drawLine(
Color.White.copy(alpha = if (isCardinal) .45f else .22f),
center + direction * inner,
center + direction * outer,
if (isCardinal) 1.5.dp.toPx() else 1.dp.toPx(),
)
}
}
private fun DrawScope.drawCalibrationMarks(
center: Offset,
radius: Float,
vialRadius: Float,
bubbleInsideTarget: Boolean,
textMeasurer: TextMeasurer,
) {
val labelStyle = TextStyle(
fontSize = 9.sp,
fontFamily = ReadoutFontFamily,
color = Color.White.copy(alpha = .38f),
)
SurfaceGuidance.RING_DEGREES.forEach { mark ->
val isTargetRing = mark == 1.0
val ringRadius = vialRadius * SurfaceGuidance.ringRadiusRatio(mark).toFloat()
val color = if (isTargetRing && bubbleInsideTarget) {
// Restrained lime accent, driven by visual containment of the drawn
// bubble. The label still carries the lock state in words.
LevelColors.LimeLock.copy(alpha = .9f)
} else {
val alpha = when (mark) {
1.0 -> .46f // The target zone needs to be the clearest calibration mark.
2.0 -> .36f
else -> .27f
}
Color.White.copy(alpha = alpha)
}
drawCircle(
color,
ringRadius,
center,
style = Stroke(if (isTargetRing && bubbleInsideTarget) 2.dp.toPx() else 1.5.dp.toPx()),
)
// Each ring says what it means: its degree value, etched small on the
// lower-right diagonal just outside the ring.
val layout = textMeasurer.measure(AnnotatedString("${mark.toInt()}°"), labelStyle)
val diagonal = (ringRadius + 5.dp.toPx()) * DIAGONAL_COMPONENT
val position = center + Offset(diagonal, diagonal)
drawText(
layout,
topLeft = position - Offset(layout.size.width / 2f, layout.size.height / 2f),
)
}
val crosshairStroke = 1.dp.toPx()
drawLine(Color.White.copy(alpha = .32f), Offset(center.x - vialRadius, center.y), Offset(center.x + vialRadius, center.y), crosshairStroke)
drawLine(Color.White.copy(alpha = .32f), Offset(center.x, center.y - vialRadius), Offset(center.x, center.y + vialRadius), crosshairStroke)
// The exact-center target mark stays beneath the translucent bubble, seen
// through the trapped air just as it would be in a real vial.
drawCircle(Color.White.copy(alpha = .85f), radius * .030f, center)
drawCircle(LevelColors.VialTarget, radius * .020f, center)
}
/** A borderless, perfectly round translucent orb: one symmetric gradient, edge fading to nothing. */
private fun DrawScope.drawBubbleBody(c: Offset, b: Float) {
// Fluorescent halo: the dye glows brightest where the lens bends light past the rim.
drawCircle(
Brush.radialGradient(
listOf(LevelColors.VialLime.copy(alpha = .16f), Color.Transparent),
c,
b * 1.9f,
),
b * 1.9f,
c,
)
// Contact shadow: a soft dark ring just outside the rim seats the bubble IN the
// fluid instead of floating over the graphics.
drawCircle(
Brush.radialGradient(
colorStops = arrayOf(
0f to Color.Transparent,
.68f to Color.Transparent,
.80f to Color.Black.copy(alpha = .30f),
1f to Color.Transparent,
),
center = c,
radius = b * 1.3f,
),
b * 1.3f,
c,
)
// The orb itself. Centered gradient = perfectly round; the soft bright band
// sits evenly inside the rim, and the thin dark contact edge fades out rather
// than drawing a border.
drawCircle(
Brush.radialGradient(
colorStops = arrayOf(
0f to LevelColors.VialHighlight.copy(alpha = .24f),
.52f to LevelColors.VialLime.copy(alpha = .18f),
.80f to LevelColors.VialLime.copy(alpha = .36f),
.91f to LevelColors.VialHighlight.copy(alpha = .66f),
.965f to LevelColors.VialDeep.copy(alpha = .32f),
1f to Color.Transparent,
),
center = c,
radius = b * 1.06f,
),
b * 1.06f,
c,
)
}
/**
* Reflection layer, world-oriented: a soft window-style reflection patch at the
* upper-left (how glass and soap bubbles actually mirror a light source), a sharp
* glint at its heart, and a faint pass-through shimmer on the far rim.
*/
private fun DrawScope.drawBubbleLight(c: Offset, b: Float) {
withTransform({
translate(c.x - b * .30f, c.y - b * .32f)
rotate(-38f, Offset.Zero)
scale(1f, .55f, Offset.Zero)
}) {
drawCircle(
Brush.radialGradient(
listOf(Color.White.copy(alpha = .50f), Color.Transparent),
Offset.Zero,
b * .46f,
),
b * .46f,
Offset.Zero,
)
}
drawCircle(Color.White.copy(alpha = .85f), b * .07f, c + Offset(-b * .30f, -b * .34f))
val shimmerCenter = c + Offset(b * .42f, b * .44f)
drawCircle(
Brush.radialGradient(
listOf(Color.White.copy(alpha = .12f), Color.Transparent),
shimmerCenter,
b * .30f,
),
b * .30f,
shimmerCenter,
)
}
private const val BUBBLE_RADIUS_RATIO = .12f
private const val DIAGONAL_COMPONENT = .7071f
@@ -0,0 +1,386 @@
package com.onthelevel.feature.level
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.onthelevel.AppContainer
import com.onthelevel.core.design.KeepScreenOn
import com.onthelevel.core.design.LevelColors
import com.onthelevel.core.sensors.CapturedSurfaceSample
import com.onthelevel.core.sensors.Ema
import com.onthelevel.core.sensors.OrientationMath
import com.onthelevel.core.sensors.SettlingDetector
import com.onthelevel.core.sensors.StableSurfaceCapture
import com.onthelevel.core.sensors.SurfaceCalibration
import com.onthelevel.core.sensors.SurfaceCalibrationSample
import com.onthelevel.core.sensors.SurfaceCalibrationValidation
import com.onthelevel.core.sensors.TwoSampleCalibration
import com.onthelevel.core.settings.CalibrationMode
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.util.Locale
private enum class Stage { OVERVIEW, CAPTURING }
private enum class CapturePhase { READY, RUNNING, ERROR }
/**
* Surface calibration. OVERVIEW shows the current correction and lets the user start,
* reset, or leave. CAPTURING runs the guided flow and shows ONLY calibration controls;
* Cancel returns to OVERVIEW without touching the saved calibration, so exploring or
* backing out never changes the current setting.
*
* Captures read SensorSource.gravity directly and never apply the stored calibration.
*/
@Composable
fun SurfaceCalibrationScreen(container: AppContainer, onBack: () -> Unit) {
KeepScreenOn()
val sensorSource = container.sensorSource
val mode by container.settings.calibrationMode
.collectAsStateWithLifecycle(initialValue = CalibrationMode.SIMPLE)
val totalSteps = mode.captureCount
val existingCalibration by container.settings.surfaceCalibration
.collectAsStateWithLifecycle(initialValue = SurfaceCalibration.NONE)
val isCalibrated = existingCalibration != SurfaceCalibration.NONE
var stage by remember { mutableStateOf(Stage.OVERVIEW) }
var phase by remember { mutableStateOf(CapturePhase.READY) }
var captures by remember { mutableStateOf<List<CapturedSurfaceSample>>(emptyList()) }
var capture by remember { mutableStateOf<StableSurfaceCapture?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var justSaved by remember { mutableStateOf(false) }
val rawReadingFlow = remember(sensorSource) {
val pitchEma = Ema(0.15)
val rollEma = Ema(0.15)
val tiltEma = Ema(0.15)
val settling = SettlingDetector()
var lastNanos: Long? = null
sensorSource.gravity.map { raw ->
val dtSeconds = lastNanos?.let { (raw.timestampNanos - it) / 1e9 } ?: 0.0
lastNanos = raw.timestampNanos
val pitch = pitchEma.update(OrientationMath.surfacePitchDegrees(raw), dtSeconds)
val roll = rollEma.update(OrientationMath.surfaceRollDegrees(raw), dtSeconds)
val tilt = tiltEma.update(OrientationMath.surfaceTiltMagnitudeDegrees(raw), dtSeconds)
val settlingResult = settling.update(pitch, roll, raw.timestampNanos)
SurfaceCalibrationSample(pitch, roll, tilt, raw.timestampNanos, settlingResult.isSettling)
}
}
val reading by rawReadingFlow.collectAsStateWithLifecycle(initialValue = null)
// Fully synchronous body: persistence goes to `scope`, never awaited here. Writing a
// key (capture/phase/stage) and then suspending inside would let Compose cancel the
// effect mid-write and strand the transition.
LaunchedEffect(reading, stage, phase, capture) {
if (stage != Stage.CAPTURING || phase != CapturePhase.RUNNING) return@LaunchedEffect
val activeCapture = capture ?: return@LaunchedEffect
val current = reading ?: return@LaunchedEffect
val captured = activeCapture.add(current) ?: return@LaunchedEffect
val newCaptures = captures + captured
capture = null
if (newCaptures.size < totalSteps) {
captures = newCaptures
phase = CapturePhase.READY
return@LaunchedEffect
}
val calibration = TwoSampleCalibration.deriveSurfaceFromSamples(newCaptures)
val consistent = SurfaceCalibrationValidation.isRotationConsistent(newCaptures)
if (!SurfaceCalibrationValidation.isAcceptable(calibration) || !consistent) {
captures = emptyList()
error = "Those positions didn't agree. Keep the phone on the same spot, turn it in place, and don't flip it over."
phase = CapturePhase.ERROR
} else {
captures = emptyList()
phase = CapturePhase.READY
stage = Stage.OVERVIEW
justSaved = true
// App-owned scope: the write must survive an immediate Done/back.
container.applicationScope.launch { container.settings.setSurfaceCalibration(calibration) }
}
}
fun startCalibrating() {
captures = emptyList()
error = null
justSaved = false
phase = CapturePhase.READY
stage = Stage.CAPTURING
}
fun cancelCalibrating() {
// Saved calibration is never touched mid-flow, so this simply restores it.
captures = emptyList()
capture = null
error = null
phase = CapturePhase.READY
stage = Stage.OVERVIEW
}
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("SURFACE CALIBRATION", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
if (stage == Stage.CAPTURING) {
Text(
text = if (mode == CalibrationMode.THOROUGH) "DETAILED" else "STANDARD",
style = MaterialTheme.typography.labelSmall,
color = LevelColors.Amber,
)
}
}
if (stage == Stage.OVERVIEW) {
OverviewContent(
modifier = Modifier.weight(1f).fillMaxWidth(),
isCalibrated = isCalibrated,
calibration = existingCalibration,
justSaved = justSaved,
mode = mode,
onModeChange = { container.applicationScope.launch { container.settings.setCalibrationMode(it) } },
)
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = ::startCalibrating, modifier = Modifier.fillMaxWidth()) {
Text(if (isCalibrated) "Recalibrate" else "Calibrate now")
}
if (isCalibrated) {
CalibrationOutlinedButton(
text = "Reset to phone defaults",
onClick = {
justSaved = false
container.applicationScope.launch { container.settings.clearSurfaceCalibration() }
},
modifier = Modifier.fillMaxWidth(),
)
}
CalibrationOutlinedButton(text = "Done", onClick = onBack, modifier = Modifier.fillMaxWidth())
}
} else {
CapturingContent(
modifier = Modifier.weight(1f).fillMaxWidth(),
phase = phase,
stepIndex = captures.size,
totalSteps = totalSteps,
mode = mode,
error = error,
tiltText = reading?.let { formatDegrees(it.tiltDegrees) } ?: "",
status = captureStatus(reading?.isSettling, reading?.tiltDegrees, reading != null),
)
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
when (phase) {
CapturePhase.READY -> {
val canStart = reading?.let {
!it.isSettling && it.tiltDegrees <= StableSurfaceCapture.MAXIMUM_CALIBRATION_TILT_DEGREES
} ?: false
Button(
onClick = {
capture = StableSurfaceCapture()
phase = CapturePhase.RUNNING
},
enabled = canStart,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (captures.isEmpty()) "Capture first position" else "Capture position ${captures.size + 1}")
}
}
CapturePhase.RUNNING -> Text(
text = "Hold still — capturing 1.5 seconds…",
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
color = LevelColors.TextDim,
)
CapturePhase.ERROR -> Button(
onClick = {
captures = emptyList()
error = null
phase = CapturePhase.READY
},
modifier = Modifier.fillMaxWidth(),
) { Text("Start over") }
}
CalibrationOutlinedButton(text = "Cancel", onClick = ::cancelCalibrating, modifier = Modifier.fillMaxWidth())
}
}
}
}
@Composable
private fun OverviewContent(
modifier: Modifier,
isCalibrated: Boolean,
calibration: SurfaceCalibration,
justSaved: Boolean,
mode: CalibrationMode,
onModeChange: (CalibrationMode) -> Unit,
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = if (justSaved) "CALIBRATION SAVED" else "CURRENT CORRECTION",
style = MaterialTheme.typography.labelSmall,
// Lime marks the confirmed/active state: just-saved, or an active calibration.
color = when {
justSaved -> LevelColors.LimeLockText
isCalibrated -> LevelColors.LimeLock
else -> LevelColors.TextDim
},
)
Spacer(Modifier.height(10.dp))
if (isCalibrated) {
Text(
"Pitch ${signedDegrees(calibration.pitchBiasDegrees)}",
style = MaterialTheme.typography.headlineMedium,
color = LevelColors.TextPrimary,
)
Text(
"Roll ${signedDegrees(calibration.rollBiasDegrees)}",
style = MaterialTheme.typography.headlineMedium,
color = LevelColors.TextPrimary,
)
} else {
Text(
"Using phone defaults",
style = MaterialTheme.typography.headlineMedium,
color = LevelColors.TextPrimary,
textAlign = TextAlign.Center,
)
}
Spacer(Modifier.height(10.dp))
Text(
text = "Calibration corrects your phone-and-case setup. It does not make a surface level.",
style = MaterialTheme.typography.bodyMedium,
color = LevelColors.TextDim,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(28.dp))
Text("METHOD", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
Spacer(Modifier.height(8.dp))
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
CalibrationMode.entries.forEachIndexed { index, entry ->
SegmentedButton(
selected = mode == entry,
onClick = { onModeChange(entry) },
shape = SegmentedButtonDefaults.itemShape(index, CalibrationMode.entries.size),
) {
Text(if (entry == CalibrationMode.SIMPLE) "Standard" else "Detailed")
}
}
}
Text(
text = if (mode == CalibrationMode.THOROUGH) {
"Four positions, a quarter-turn apart. Averages more sensor noise and cross-checks the turn."
} else {
"Two positions, a half-turn apart. Quick and dependable."
},
style = MaterialTheme.typography.labelSmall,
color = LevelColors.TextDim,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 8.dp),
)
}
}
@Composable
private fun CapturingContent(
modifier: Modifier,
phase: CapturePhase,
stepIndex: Int,
totalSteps: Int,
mode: CalibrationMode,
error: String?,
tiltText: String,
status: String,
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
if (phase != CapturePhase.ERROR) {
Text(
"POSITION ${stepIndex + 1} OF $totalSteps",
style = MaterialTheme.typography.labelSmall,
color = LevelColors.TextDim,
)
Spacer(Modifier.height(14.dp))
}
Text(
text = when {
phase == CapturePhase.ERROR -> error ?: "Let's try that again"
stepIndex == 0 -> "Lay the phone screen-up on a firm, roughly level surface. Keep it still."
mode == CalibrationMode.SIMPLE ->
"Spin the phone a half-turn (180°) in the same spot. Don't lift or flip it over."
else ->
"Spin the phone a quarter-turn (90°) the same direction, same spot. Don't lift or flip it over."
},
style = MaterialTheme.typography.headlineMedium,
color = if (phase == CapturePhase.ERROR) LevelColors.Amber else LevelColors.TextPrimary,
textAlign = TextAlign.Center,
)
if (phase != CapturePhase.ERROR) {
Spacer(Modifier.height(32.dp))
Text(tiltText, style = MaterialTheme.typography.displaySmall, color = LevelColors.TextPrimary)
Text(status, style = MaterialTheme.typography.bodyLarge, color = LevelColors.Amber, textAlign = TextAlign.Center)
}
}
}
private fun captureStatus(isSettling: Boolean?, tiltDegrees: Double?, hasReading: Boolean): String = when {
!hasReading -> "Waiting for a sensor reading"
isSettling == true -> "Settling — keep the phone still"
tiltDegrees != null && tiltDegrees > StableSurfaceCapture.MAXIMUM_CALIBRATION_TILT_DEGREES ->
"Find a flatter surface (under 5°)"
else -> "Ready to capture"
}
/** Outlined buttons default to a near-invisible outline on graphite; give them a real border. */
@Composable
private fun CalibrationOutlinedButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
OutlinedButton(
onClick = onClick,
modifier = modifier,
border = BorderStroke(1.5.dp, LevelColors.Amber.copy(alpha = .65f)),
colors = ButtonDefaults.outlinedButtonColors(contentColor = LevelColors.Amber),
) { Text(text) }
}
private fun signedDegrees(value: Double): String {
val rounded = Math.round(value * 10.0) / 10.0
return when {
rounded == 0.0 -> "0.0°" // never render a signed negative zero
rounded > 0.0 -> String.format(Locale.US, "+%.1f°", rounded)
else -> String.format(Locale.US, "%.1f°", rounded)
}
}
@@ -0,0 +1,152 @@
package com.onthelevel.feature.tools
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.onthelevel.AppContainer
import com.onthelevel.core.design.LevelColors
import com.onthelevel.core.settings.MeasurementUnits
import kotlinx.coroutines.launch
/**
* App settings, reached from the Level header gear. Calibration is a plain doorway
* here (its own screen owns status and controls); the measurement preferences live
* directly on this list. Done is pinned to the bottom so it stays put as the list grows.
*/
@Composable
fun SettingsScreen(
container: AppContainer,
onOpenSurfaceCalibration: () -> Unit,
onBack: () -> Unit,
) {
// A preferences list should time out normally — no KeepScreenOn here (calibration
// and the live tool screens keep the screen awake themselves).
val units by container.settings.measurementUnits
.collectAsStateWithLifecycle(initialValue = MeasurementUnits.METRIC)
val reducedMotion by container.settings.reducedMotion
.collectAsStateWithLifecycle(initialValue = false)
val hapticsEnabled by container.settings.hapticsEnabled
.collectAsStateWithLifecycle(initialValue = true)
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp)) {
Column(
modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Text("SETTINGS", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
SettingsSection("Calibration") {
Button(
onClick = onOpenSurfaceCalibration,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = LevelColors.ReadoutPanel,
contentColor = LevelColors.Amber,
),
) { Text("Calibration Utility") }
}
SettingsSection("Units") {
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
MeasurementUnits.entries.forEachIndexed { index, entry ->
SegmentedButton(
selected = units == entry,
onClick = { container.applicationScope.launch { container.settings.setMeasurementUnits(entry) } },
shape = SegmentedButtonDefaults.itemShape(index, MeasurementUnits.entries.size),
) {
Text(if (entry == MeasurementUnits.METRIC) "Metric" else "Imperial")
}
}
}
}
SettingsSection("Feedback & motion") {
ToggleRow(
label = "Haptic feedback",
description = "A gentle buzz when the level locks.",
checked = hapticsEnabled,
onCheckedChange = { container.applicationScope.launch { container.settings.setHapticsEnabled(it) } },
)
Spacer(Modifier.height(12.dp))
ToggleRow(
label = "Reduce motion",
description = "Snap the bubble instead of springing it.",
checked = reducedMotion,
onCheckedChange = { container.applicationScope.launch { container.settings.setReducedMotion(it) } },
)
}
}
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = onBack,
modifier = Modifier.fillMaxWidth(),
border = BorderStroke(1.5.dp, LevelColors.Amber.copy(alpha = .65f)),
colors = ButtonDefaults.outlinedButtonColors(contentColor = LevelColors.Amber),
) { Text("Done") }
}
}
@Composable
private fun SettingsSection(title: String, content: @Composable () -> Unit) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
title,
style = MaterialTheme.typography.titleMedium,
color = LevelColors.TextPrimary,
modifier = Modifier.padding(bottom = 10.dp),
)
content()
}
}
@Composable
private fun ToggleRow(
label: String,
description: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(label, style = MaterialTheme.typography.bodyLarge, color = LevelColors.TextPrimary)
Text(description, style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
}
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
colors = SwitchDefaults.colors(
checkedThumbColor = LevelColors.Graphite,
checkedTrackColor = LevelColors.Amber,
),
)
}
}
@@ -17,7 +17,7 @@ import androidx.compose.ui.unit.dp
import com.onthelevel.core.design.LevelColors
@Composable
fun ToolsScreen(onOpenRuler: () -> Unit) {
fun ToolsScreen(onOpenRuler: () -> Unit, onOpenSettings: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
@@ -33,14 +33,9 @@ fun ToolsScreen(onOpenRuler: () -> Unit) {
onClick = onOpenRuler,
)
ToolCard(
title = "Calibration",
subtitle = "Two-sample level calibration — coming with the feature build",
onClick = { /* TODO(feature): guided calibration flow */ },
)
ToolCard(
title = "Units & Feedback",
subtitle = "Haptics, audio cue, reduced motion — coming with the feature build",
onClick = { /* TODO(feature): preferences UI over SettingsRepository */ },
title = "Settings",
subtitle = "Calibration, units, feedback, and motion",
onClick = onOpenSettings,
)
}
}
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,110 @@
package com.onthelevel.core.audio
import com.onthelevel.core.sensors.SurfacePresentation
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SurfaceTickPolicyTest {
private fun input(
error: Double = 2.0,
now: Long = 0,
enabled: Boolean = true,
foreground: Boolean = true,
placementOk: Boolean = true,
presentation: SurfacePresentation? = SurfacePresentation.FACE_UP,
) = SurfaceTickPolicy.Input(
isEnabled = enabled,
isAppForeground = foreground,
placementOk = placementOk,
surfacePresentation = presentation,
errorDegrees = error,
nowMillis = now,
)
// --- Proximity ticking (outside the center zone) ---
@Test
fun `rate hits both anchors and clamps beyond them`() {
val p = SurfaceTickPolicy()
assertEquals(8.0, p.tickRatePerSecond(0.4), 1e-9)
assertEquals(1.5, p.tickRatePerSecond(5.0), 1e-9)
assertEquals(8.0, p.tickRatePerSecond(0.1), 1e-9)
assertEquals(1.5, p.tickRatePerSecond(12.0), 1e-9)
}
@Test
fun `rate is monotonic - closer is always faster`() {
val p = SurfaceTickPolicy()
val rates = listOf(0.4, 1.0, 2.0, 3.0, 4.0, 5.0).map { p.tickRatePerSecond(it) }
for (i in 1 until rates.size) assertTrue(rates[i] < rates[i - 1])
}
@Test
fun `silent and reset while disabled, backgrounded, or off-surface`() {
val p = SurfaceTickPolicy()
assertNull(p.update(input(enabled = false)))
assertNull(p.update(input(foreground = false)))
assertNull(p.update(input(placementOk = false)))
assertNull(p.update(input(presentation = SurfacePresentation.NEAR_VERTICAL)))
}
@Test
fun `ticks fire on the current interval and react immediately to changing tilt`() {
val p = SurfaceTickPolicy()
assertEquals(SurfaceTickPolicy.Cue.TICK, p.update(input(error = 5.0, now = 0))) // first tick, anchor 0
// Drop toward center (still outside the 0.2 zone): next tick uses the FAST interval.
assertNull(p.update(input(error = 0.4, now = 124)))
assertEquals(SurfaceTickPolicy.Cue.TICK, p.update(input(error = 0.4, now = 125)))
}
@Test
fun `a long gap yields one tick, not a catch-up burst`() {
val p = SurfaceTickPolicy()
p.update(input(error = 2.0, now = 0))
assertEquals(SurfaceTickPolicy.Cue.TICK, p.update(input(error = 2.0, now = 10_000)))
assertNull(p.update(input(error = 2.0, now = 10_001)))
}
// --- Bullseye alignment (inside the center zone) ---
@Test
fun `entering the zone announces immediately - even on a fast pass, no dwell`() {
val p = SurfaceTickPolicy()
p.update(input(error = 3.0, now = 0)) // ticking, outside
// One frame later it's already inside (a fast sweep): aligned right away.
assertEquals(SurfaceTickPolicy.Cue.ALIGNED, p.update(input(error = 0.1, now = 20)))
}
@Test
fun `stays aligned every frame while inside, ticks suppressed`() {
val p = SurfaceTickPolicy()
assertEquals(SurfaceTickPolicy.Cue.ALIGNED, p.update(input(error = 0.15, now = 0)))
for (t in 20..2_000 step 20) {
assertEquals(SurfaceTickPolicy.Cue.ALIGNED, p.update(input(error = 0.15, now = t.toLong())))
}
}
@Test
fun `spatial hysteresis - holds aligned to 0_35, resumes ticks past it`() {
val p = SurfaceTickPolicy()
assertEquals(SurfaceTickPolicy.Cue.ALIGNED, p.update(input(error = 0.1, now = 0)))
// 0.3 is past the 0.2 enter but below the 0.35 exit: still aligned.
assertEquals(SurfaceTickPolicy.Cue.ALIGNED, p.update(input(error = 0.3, now = 20)))
// Beyond 0.35: exit, and ticks resume immediately (anchor cleared on exit).
assertEquals(SurfaceTickPolicy.Cue.TICK, p.update(input(error = 0.4, now = 40)))
}
@Test
fun `re-crossing rearms only after leaving the exit band, then announces again`() {
val p = SurfaceTickPolicy()
p.update(input(error = 0.1, now = 0)) // aligned
p.update(input(error = 0.25, now = 20)) // 0.2 < 0.25 < 0.35: still aligned (not rearmed)
assertEquals(SurfaceTickPolicy.Cue.ALIGNED, p.update(input(error = 0.25, now = 40)))
// Swing out past 0.35, then back to center: announces again.
p.update(input(error = 0.5, now = 60)) // exits
assertEquals(SurfaceTickPolicy.Cue.ALIGNED, p.update(input(error = 0.1, now = 80)))
}
}
@@ -0,0 +1,96 @@
package com.onthelevel.core.audio
import com.onthelevel.core.audio.VoiceGuidancePolicy.Direction
import com.onthelevel.core.audio.VoiceGuidancePolicy.Phrase
import com.onthelevel.core.sensors.SurfacePresentation
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class VoiceGuidancePolicyTest {
private fun input(
pitch: Double = 0.0,
roll: Double = 0.0,
locked: Boolean = false,
settling: Boolean = false,
enabled: Boolean = true,
foreground: Boolean = true,
placementOk: Boolean = true,
presentation: SurfacePresentation? = SurfacePresentation.FACE_UP,
) = VoiceGuidancePolicy.Input(
isEnabled = enabled,
isAppForeground = foreground,
placementOk = placementOk,
presentation = presentation,
isSettling = settling,
pitchDegrees = pitch,
rollDegrees = roll,
isLocked = locked,
nowMillis = 0,
)
/** Move (silent), then come to rest — the frame the board settles on. */
private fun VoiceGuidancePolicy.moveThenSettle(pitch: Double, roll: Double): Phrase? {
update(input(pitch = pitch, roll = roll, settling = true))
return update(input(pitch = pitch, roll = roll, settling = false))
}
@Test
fun `first settled reading gives one instruction, low side of the dominant axis`() {
val p = VoiceGuidancePolicy()
assertEquals(Phrase.Raise(Direction.RAISE_LEFT, fine = false), p.update(input(roll = 3.0)))
}
@Test
fun `same direction after another settle stays silent`() {
val p = VoiceGuidancePolicy()
assertEquals(Phrase.Raise(Direction.RAISE_LEFT, fine = false), p.update(input(roll = 3.0)))
// Adjusted, still needs the same coarse "raise the left": say nothing.
assertNull(p.moveThenSettle(pitch = 0.0, roll = 2.8))
}
@Test
fun `direction change speaks once`() {
val p = VoiceGuidancePolicy()
p.update(input(roll = 3.0)) // raise left
// Overcorrected past level (left now high): speak the new direction, once.
assertEquals(Phrase.Raise(Direction.RAISE_RIGHT, fine = false), p.moveThenSettle(pitch = 0.0, roll = -3.0))
// Settling again with the same direction: silent.
assertNull(p.moveThenSettle(pitch = 0.0, roll = -2.9))
}
@Test
fun `crossing coarse to fine speaks once`() {
val p = VoiceGuidancePolicy()
p.update(input(roll = 3.0)) // coarse "raise the left"
// Now close, same direction: announce the finer correction, once.
assertEquals(Phrase.Raise(Direction.RAISE_LEFT, fine = true), p.moveThenSettle(pitch = 0.0, roll = 0.6))
// Still fine, same direction: silent.
assertNull(p.moveThenSettle(pitch = 0.0, roll = 0.5))
}
@Test
fun `lock speaks once`() {
val p = VoiceGuidancePolicy()
p.update(input(roll = 3.0))
assertEquals(Phrase.Level, p.update(input(locked = true)))
assertNull(p.update(input(locked = true)))
}
@Test
fun `no periodic repeat while sitting settled and off-level`() {
val p = VoiceGuidancePolicy()
assertEquals(Phrase.Raise(Direction.RAISE_LEFT, fine = false), p.update(input(roll = 3.0)))
// Many more settled frames, unchanged: silence, no timer-driven repeat.
repeat(50) { assertNull(p.update(input(roll = 3.0))) }
}
@Test
fun `silent when disabled, backgrounded, or off-surface`() {
assertNull(VoiceGuidancePolicy().update(input(roll = 3.0, enabled = false)))
assertNull(VoiceGuidancePolicy().update(input(roll = 3.0, foreground = false)))
assertNull(VoiceGuidancePolicy().update(input(roll = 3.0, placementOk = false)))
assertNull(VoiceGuidancePolicy().update(input(roll = 3.0, presentation = SurfacePresentation.NEAR_VERTICAL)))
}
}
@@ -9,65 +9,105 @@ class LockDetectorTest {
private fun detector() = LockDetector(
enterThresholdDegrees = 0.2,
exitThresholdDegrees = 0.35,
dwellMillis = 400,
dwellMillis = 175,
settledDwellMillis = 400,
slowRateThresholdDegPerSec = 0.3,
feedbackDebounceMillis = 3_000,
)
private val slow = 0.1 // °/s, below the slow-rate threshold
private val fast = 2.0 // °/s, a real center crossing
@Test
fun `lock requires dwell time inside the enter threshold`() {
fun `easing slowly into center locks after the short dwell`() {
val d = detector()
assertFalse(d.update(0.1, 0).isLocked)
assertFalse(d.update(0.1, 200).isLocked)
val result = d.update(0.1, 450)
assertTrue(result.isLocked)
assertTrue(result.fireFeedback)
assertFalse(d.update(0.1, slow, 0).isLocked)
assertFalse(d.update(0.1, slow, 174).isLocked)
val r = d.update(0.1, slow, 175)
assertTrue(r.isLocked)
assertTrue(r.fireFeedback)
}
@Test
fun `leaving the threshold before dwell completes resets the timer`() {
fun `flying across center never locks, however long it stays in the zone`() {
val d = detector()
d.update(0.1, 0)
d.update(0.5, 200) // bounced out
d.update(0.1, 300) // back in — dwell restarts
assertFalse(d.update(0.1, 600).isLocked) // only 300ms since re-entry
assertTrue(d.update(0.1, 750).isLocked)
// In the zone the whole time, but moving fast: the dwell never accumulates.
for (t in 0..2_000 step 20) assertFalse(d.update(0.05, fast, t.toLong()).isLocked)
}
@Test
fun `intermediate speed does not accumulate lock time`() {
val d = detector()
val intermediate = 0.5 // between slow (0.3) and a crossing
for (t in 0..2_000 step 20) assertFalse(d.update(0.1, intermediate, t.toLong()).isLocked)
}
@Test
fun `slowing to a stop inside the zone starts a fresh confirmation dwell`() {
val d = detector()
// In the zone but moving fast until t=300: no accumulation yet.
d.update(0.1, fast, 0)
d.update(0.1, fast, 300)
// Now slow down: dwell starts fresh here, so it must NOT be locked at +100ms...
assertFalse(d.update(0.1, slow, 320).isLocked)
assertFalse(d.update(0.1, slow, 420).isLocked)
// ...but locks 175ms after slowing.
assertTrue(d.update(0.1, slow, 495).isLocked)
}
@Test
fun `leaving the zone before dwell completes resets the timer`() {
val d = detector()
d.update(0.1, slow, 0)
d.update(0.5, slow, 100) // bounced out of the zone
d.update(0.1, slow, 150) // back in — dwell restarts
assertFalse(d.update(0.1, slow, 300).isLocked) // only 150ms since re-entry
assertTrue(d.update(0.1, slow, 325).isLocked)
}
@Test
fun `hysteresis holds the lock between enter and exit thresholds`() {
val d = detector()
d.update(0.1, 0)
assertTrue(d.update(0.1, 500).isLocked)
// 0.3° is above enter (0.2) but below exit (0.35): still locked.
assertTrue(d.update(0.3, 600).isLocked)
d.update(0.1, slow, 0)
assertTrue(d.update(0.1, slow, 200).isLocked)
// 0.3° is above enter (0.2) but below exit (0.35): still locked (velocity irrelevant once locked).
assertTrue(d.update(0.3, fast, 300).isLocked)
// 0.4° exceeds the exit threshold: unlocked.
assertFalse(d.update(0.4, 700).isLocked)
assertFalse(d.update(0.4, slow, 400).isLocked)
}
@Test
fun `feedback fires once per lock and respects the debounce window`() {
val d = detector()
d.update(0.1, 0)
assertTrue(d.update(0.1, 500).fireFeedback)
assertFalse(d.update(0.1, 600).fireFeedback) // still locked, no re-fire
d.update(0.1, slow, 0)
assertTrue(d.update(0.1, slow, 200).fireFeedback)
assertFalse(d.update(0.1, slow, 300).fireFeedback) // still locked, no re-fire
// Rock out and back in quickly: re-lock at ~1500ms is inside the 3s debounce.
d.update(0.5, 900)
d.update(0.1, 1000)
val relock = d.update(0.1, 1500)
// Rock out and back quickly: re-lock at ~1500ms is inside the 3s debounce.
d.update(0.5, fast, 900)
d.update(0.1, slow, 1000)
val relock = d.update(0.1, slow, 1500)
assertTrue(relock.isLocked)
assertFalse(relock.fireFeedback)
// A re-lock after the debounce window fires again.
d.update(0.5, 2000)
d.update(0.1, 4000)
assertTrue(d.update(0.1, 4500).fireFeedback)
d.update(0.5, fast, 4000)
d.update(0.1, slow, 4100)
assertTrue(d.update(0.1, slow, 4400).fireFeedback)
}
@Test
fun `without a velocity signal it uses the fixed settled dwell (Edge)`() {
val d = detector()
assertFalse(d.update(0.1, null, 0).isLocked)
assertFalse(d.update(0.1, null, 399).isLocked) // 175 has passed, but the no-rate path needs 400
assertTrue(d.update(0.1, null, 400).isLocked)
}
@Test
fun `negative readings lock on magnitude`() {
val d = detector()
d.update(-0.1, 0)
assertTrue(d.update(-0.1, 500).isLocked)
d.update(-0.1, slow, 0)
assertTrue(d.update(-0.1, slow, 200).isLocked)
}
}
@@ -56,6 +56,11 @@ class OrientationMathTest {
assertEquals(4.0, OrientationMath.surfaceTiltMagnitudeDegrees(rolled(4.0)), 1e-9)
}
@Test
fun `surface magnitude remains authoritative through screen down`() {
assertEquals(120.0, OrientationMath.surfaceTiltMagnitudeDegrees(pitched(120.0)), 1e-9)
}
@Test
fun `edge mode reads zero when the long edge is horizontal`() {
assertEquals(0.0, OrientationMath.edgeLevelDegrees(onEdge(0.0)), 1e-9)
@@ -76,6 +81,35 @@ class OrientationMathTest {
assertTrue(OrientationMath.percentGrade(-89.6) == Double.NEGATIVE_INFINITY)
}
@Test
fun `angle between identical directions is zero`() {
assertEquals(0.0, OrientationMath.angleBetweenDegrees(pitched(10.0), pitched(10.0)), 1e-9)
}
@Test
fun `angle between same-axis tilts is their difference`() {
assertEquals(15.0, OrientationMath.angleBetweenDegrees(pitched(10.0), pitched(25.0)), 1e-9)
}
@Test
fun `angle between cross-axis tilts is directional, not a magnitude difference`() {
// Zeroed at 10° pitch, moved to 10° roll: magnitudes are equal (difference 0),
// but the device genuinely rotated acos(cos²10°) ≈ 14.1°.
val expected = Math.toDegrees(
kotlin.math.acos(cos(Math.toRadians(10.0)) * cos(Math.toRadians(10.0))),
)
assertEquals(expected, OrientationMath.angleBetweenDegrees(pitched(10.0), rolled(10.0)), 1e-9)
assertTrue(expected > 14.0)
}
@Test
fun `placement validity matches the selected mode's geometry`() {
assertTrue(OrientationMath.isPlacementValid(flat(), LevelMode.SURFACE))
assertTrue(!OrientationMath.isPlacementValid(flat(), LevelMode.EDGE))
assertTrue(OrientationMath.isPlacementValid(onEdge(0.0), LevelMode.EDGE))
assertTrue(!OrientationMath.isPlacementValid(onEdge(0.0), LevelMode.SURFACE))
}
@Test
fun `zero vector does not produce NaN`() {
val zero = GravitySample(0.0, 0.0, 0.0, 0)
@@ -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)
}
}
@@ -0,0 +1,64 @@
package com.onthelevel.core.sensors
import org.junit.Assert.assertEquals
import org.junit.Test
import kotlin.math.cos
import kotlin.math.sin
/**
* Pins the GravitySample contract: every sensor path yields device-frame WORLD-UP,
* with flat-screen-up = (0, 0, +g).
*
* Per the Android sensor docs (TYPE_ACCELEROMETER), a device stationary flat on a
* table reads +9.81 on Z: "the acceleration of the device (0 m/s²) minus the force
* of gravity (9.81 m/s²)". TYPE_GRAVITY shares that convention (it is the isolated
* gravity component of the same signal), so both pass-through paths already match.
* These tests prove the rotation-vector conversion produces the identical vector,
* so NO sign adjustment is applied to any path.
*/
class SensorContractTest {
private val g = RotationVectorMath.STANDARD_GRAVITY
/** Device→world rotation matrix for a device pitched up by [degrees] about X. */
private fun deviceToWorldPitch(degrees: Double): FloatArray {
val r = Math.toRadians(degrees)
return floatArrayOf(
1f, 0f, 0f,
0f, cos(r).toFloat(), (-sin(r)).toFloat(),
0f, sin(r).toFloat(), cos(r).toFloat(),
)
}
@Test
fun `flat device - rotation path matches the documented gravity sensor output`() {
val identity = floatArrayOf(1f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 1f)
val (x, y, z) = RotationVectorMath.worldUpDeviceFrame(identity)
// Documented TYPE_GRAVITY / TYPE_ACCELEROMETER flat output: (0, 0, +9.81).
assertEquals(0.0, x * g, 1e-6)
assertEquals(0.0, y * g, 1e-6)
assertEquals(g, z * g, 1e-6)
}
@Test
fun `pitched device - rotation path matches the analytic gravity vector`() {
val theta = 10.0
val (x, y, z) = RotationVectorMath.worldUpDeviceFrame(deviceToWorldPitch(theta))
val fromRotation = GravitySample(x * g, y * g, z * g, 0)
// The gravity-sensor path for the same physical orientation:
val r = Math.toRadians(theta)
val fromGravitySensor = GravitySample(0.0, g * sin(r), g * cos(r), 0)
assertEquals(fromGravitySensor.x, fromRotation.x, 1e-6)
assertEquals(fromGravitySensor.y, fromRotation.y, 1e-6)
assertEquals(fromGravitySensor.z, fromRotation.z, 1e-6)
// And both derive the same pitch through the measurement math:
assertEquals(
OrientationMath.surfacePitchDegrees(fromGravitySensor),
OrientationMath.surfacePitchDegrees(fromRotation),
1e-6,
)
}
}
@@ -0,0 +1,43 @@
package com.onthelevel.core.sensors
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SettlingDetectorTest {
@Test
fun `settles only after low movement has held for its dwell time`() {
val detector = SettlingDetector()
assertTrue(detector.update(0.0, 0.0, 0).isSettling)
assertTrue(detector.update(0.2, 0.0, 100_000_000).isSettling) // 2°/s: moving
assertTrue(detector.update(0.2, 0.0, 200_000_000).isSettling)
assertTrue(detector.update(0.2, 0.0, 600_000_000).isSettling)
assertFalse(detector.update(0.2, 0.0, 700_000_000).isSettling)
}
@Test
fun `movement re-enters settling and resets the dwell`() {
val detector = SettlingDetector()
detector.update(0.0, 0.0, 0)
detector.update(0.0, 0.0, 100_000_000)
detector.update(0.0, 0.0, 600_000_000)
assertFalse(detector.update(0.0, 0.0, 700_000_000).isSettling)
assertTrue(detector.update(0.2, 0.0, 800_000_000).isSettling)
assertTrue(detector.update(0.2, 0.0, 1_200_000_000).isSettling)
}
@Test
fun `mid band movement resets the quiet dwell without leaving settling`() {
val detector = SettlingDetector()
detector.update(0.0, 0.0, 0)
detector.update(0.0, 0.0, 100_000_000) // quiet dwell starts
detector.update(0.08, 0.0, 300_000_000) // 0.4°/s: between exit and enter
assertTrue(detector.update(0.08, 0.0, 600_000_000).isSettling)
assertTrue(detector.update(0.08, 0.0, 1_000_000_000).isSettling)
assertFalse(detector.update(0.08, 0.0, 1_100_000_000).isSettling)
}
}
@@ -0,0 +1,42 @@
package com.onthelevel.core.sensors
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class StableSurfaceCaptureTest {
private fun sample(
pitch: Double,
roll: Double,
timestampMillis: Long,
tilt: Double = 1.0,
settling: Boolean = false,
) = SurfaceCalibrationSample(pitch, roll, tilt, timestampMillis * 1_000_000, settling)
@Test
fun `captures the mean of a continuous settled one point five second window`() {
val capture = StableSurfaceCapture()
assertNull(capture.add(sample(0.2, -0.1, 0)))
assertNull(capture.add(sample(0.4, -0.3, 750)))
val result = capture.add(sample(0.6, -0.5, 1_500))
requireNotNull(result)
assertEquals(0.4, result.pitchDegrees, 1e-9)
assertEquals(-0.3, result.rollDegrees, 1e-9)
}
@Test
fun `motion or excessive tilt resets the capture window`() {
val capture = StableSurfaceCapture()
assertNull(capture.add(sample(0.2, 0.0, 0)))
assertNull(capture.add(sample(0.2, 0.0, 750, settling = true)))
assertNull(capture.add(sample(0.2, 0.0, 1_000)))
assertNull(capture.add(sample(0.2, 0.0, 1_500, tilt = 5.1)))
assertNull(capture.add(sample(0.2, 0.0, 1_750)))
assertNull(capture.add(sample(0.2, 0.0, 2_500)))
requireNotNull(capture.add(sample(0.2, 0.0, 3_250)))
}
}
@@ -0,0 +1,55 @@
package com.onthelevel.core.sensors
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.cos
import kotlin.math.sin
class SurfaceCalibrationValidationTest {
@Test
fun `accepts a combined bias at or below three degrees`() {
assertTrue(SurfaceCalibrationValidation.isAcceptable(SurfaceCalibration(3.0, 0.0)))
assertTrue(SurfaceCalibrationValidation.isAcceptable(SurfaceCalibration(1.8, 2.4)))
}
@Test
fun `rejects a combined bias above three degrees`() {
assertFalse(SurfaceCalibrationValidation.isAcceptable(SurfaceCalibration(3.01, 0.0)))
assertFalse(SurfaceCalibrationValidation.isAcceptable(SurfaceCalibration(2.5, 2.5)))
}
// A 5° surface is the worst permitted case (bias magnitude ≤ 5° is allowed to reach
// the mean). biasPitch/Roll are the fixed device bias; the true tilt rotates with
// the phone about the surface normal.
private val biasPitch = 0.3
private val biasRoll = -0.2
private val tilt = 5.0
private fun sampleAt(rotationDegrees: Double) = CapturedSurfaceSample(
pitchDegrees = biasPitch + tilt * cos(Math.toRadians(rotationDegrees)),
rollDegrees = biasRoll + tilt * sin(Math.toRadians(rotationDegrees)),
)
@Test
fun `clean four-point turn is consistent`() {
val clean = listOf(sampleAt(0.0), sampleAt(90.0), sampleAt(180.0), sampleAt(270.0))
assertTrue(SurfaceCalibrationValidation.isRotationConsistent(clean))
}
@Test
fun `sloppy four-point turn is rejected`() {
// Second position over-rotated 10° (100° instead of 90°). The (90,270) pair no
// longer cancels, so the two independent bias estimates diverge past tolerance —
// a malformed rotation the 3° magnitude guard alone cannot catch.
val sloppy = listOf(sampleAt(0.0), sampleAt(100.0), sampleAt(180.0), sampleAt(270.0))
assertFalse(SurfaceCalibrationValidation.isRotationConsistent(sloppy))
}
@Test
fun `two-point set has no cross-check and is always consistent`() {
val twoPoint = listOf(CapturedSurfaceSample(0.8, -0.1), CapturedSurfaceSample(-0.2, 0.5))
assertTrue(SurfaceCalibrationValidation.isRotationConsistent(twoPoint))
}
}
@@ -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)
}
}
@@ -0,0 +1,28 @@
package com.onthelevel.core.sensors
import org.junit.Assert.assertEquals
import org.junit.Test
class SurfacePresentationDetectorTest {
@Test
fun `axis suppression boundary is hysteretic`() {
val detector = SurfacePresentationDetector()
assertEquals(SurfacePresentation.FACE_UP, detector.update(79.9))
assertEquals(SurfacePresentation.NEAR_VERTICAL, detector.update(80.0))
assertEquals(SurfacePresentation.NEAR_VERTICAL, detector.update(79.2))
assertEquals(SurfacePresentation.FACE_UP, detector.update(78.9))
}
@Test
fun `screen down boundary is hysteretic`() {
val detector = SurfacePresentationDetector()
detector.update(80.0)
assertEquals(SurfacePresentation.NEAR_VERTICAL, detector.update(90.0))
assertEquals(SurfacePresentation.SCREEN_DOWN, detector.update(90.1))
assertEquals(SurfacePresentation.SCREEN_DOWN, detector.update(89.2))
assertEquals(SurfacePresentation.NEAR_VERTICAL, detector.update(89.0))
}
}
@@ -1,10 +1,24 @@
package com.onthelevel.core.sensors
import com.onthelevel.core.sensors.Vec3Math.Vec3
import org.junit.Assert.assertEquals
import org.junit.Test
import kotlin.math.acos
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.tan
class TwoSampleCalibrationTest {
private val g = 9.80665
private fun sample(v: Vec3) = GravitySample(v.x, v.y, v.z, 0)
private fun pitched(degrees: Double): GravitySample {
val r = Math.toRadians(degrees)
return GravitySample(0.0, g * sin(r), g * cos(r), 0)
}
@Test
fun `flip cancels true tilt and isolates device bias`() {
// Surface truly tilted 0.5°, device bias +0.3°:
@@ -12,8 +26,6 @@ class TwoSampleCalibrationTest {
val reading2 = -0.5 + 0.3 // after 180° rotation about the surface normal
val bias = TwoSampleCalibration.deriveBiasDegrees(reading1, reading2)
assertEquals(0.3, bias, 1e-9)
// Applying the bias recovers the true tilt from the original reading:
assertEquals(0.5, reading1 - bias, 1e-9)
}
@Test
@@ -24,24 +36,142 @@ class TwoSampleCalibrationTest {
)
assertEquals(0.3, cal.pitchBiasDegrees, 1e-9)
assertEquals(0.2, cal.rollBiasDegrees, 1e-9)
assertEquals(0.5, cal.applyToPitch(0.8), 1e-9)
assertEquals(-0.3, cal.applyToRoll(-0.1), 1e-9)
}
@Test
fun `unbiased device on a level surface derives zero bias`() {
val cal = TwoSampleCalibration.deriveEdge(0.0, 0.0)
assertEquals(0.0, cal.levelBiasDegrees, 1e-9)
assertEquals(1.2, cal.applyToLevel(1.2), 1e-9)
fun `surface correction zeroes a biased level placement - vector space`() {
val cal = SurfaceCalibration(pitchBiasDegrees = 0.4, rollBiasDegrees = -0.3)
// The reference direction: what a biased device measures on a TRULY level surface.
val measuredAtLevel = sample(
Vec3Math.normalize(
Vec3(
tan(Math.toRadians(cal.rollBiasDegrees)),
tan(Math.toRadians(cal.pitchBiasDegrees)),
1.0,
),
).let { Vec3(it.x * g, it.y * g, it.z * g) },
)
val corrected = cal.apply(measuredAtLevel)
assertEquals(0.0, OrientationMath.surfacePitchDegrees(corrected), 1e-9)
assertEquals(0.0, OrientationMath.surfaceRollDegrees(corrected), 1e-9)
assertEquals(0.0, OrientationMath.surfaceTiltMagnitudeDegrees(corrected), 1e-6)
}
@Test
fun `surface and edge calibrations are independent types applied per mode`() {
fun `surface correction is exact away from zero - same axis`() {
// Misalignment purely about X by 0.5°: a true pitch of 30° measures as 30.5°.
val cal = SurfaceCalibration(pitchBiasDegrees = 0.5, rollBiasDegrees = 0.0)
val measured = pitched(30.5)
val corrected = cal.apply(measured)
assertEquals(30.0, OrientationMath.surfacePitchDegrees(corrected), 1e-9)
assertEquals(30.0, OrientationMath.surfaceTiltMagnitudeDegrees(corrected), 1e-9)
}
@Test
fun `surface correction is exact away from zero - cross axis`() {
// General misalignment: pitch bias 0.4°, roll bias 0.3°. Build the measured
// sample by applying the INVERSE of the correction rotation to the true
// 30°-pitched gravity, then verify the correction recovers it exactly.
val cal = SurfaceCalibration(pitchBiasDegrees = 0.4, rollBiasDegrees = 0.3)
val reference = Vec3Math.normalize(
Vec3(
tan(Math.toRadians(cal.rollBiasDegrees)),
tan(Math.toRadians(cal.pitchBiasDegrees)),
1.0,
),
)
val axis = Vec3Math.normalize(Vec3Math.cross(reference, Vec3Math.WORLD_UP_FLAT))
val angle = acos(Vec3Math.dot(reference, Vec3Math.WORLD_UP_FLAT).coerceIn(-1.0, 1.0))
val true30 = pitched(30.0)
val measured = sample(Vec3Math.rotate(Vec3(true30.x, true30.y, true30.z), axis, -angle))
val corrected = cal.apply(measured)
assertEquals(30.0, OrientationMath.surfacePitchDegrees(corrected), 1e-9)
assertEquals(0.0, OrientationMath.surfaceRollDegrees(corrected), 1e-9)
assertEquals(30.0, OrientationMath.surfaceTiltMagnitudeDegrees(corrected), 1e-9)
}
@Test
fun `edge correction is exact away from zero and leaves lean untouched`() {
// Misalignment about Z by 0.3° on the positive-X edge; true in-plane tip 10°.
val bias = 0.3
val cal = EdgeCalibration(levelBiasDegrees = bias, calibratedOnPositiveXEdge = true)
fun onEdgeMeasured(tipDegrees: Double): GravitySample {
val r = Math.toRadians(tipDegrees + bias) // Rz misalignment adds directly in-plane
return GravitySample(g * cos(r), g * sin(r), 0.0, 0)
}
assertEquals(
0.0,
OrientationMath.edgeLevelDegrees(cal.apply(onEdgeMeasured(0.0))),
1e-9,
)
assertEquals(
10.0,
OrientationMath.edgeLevelDegrees(cal.apply(onEdgeMeasured(10.0))),
1e-9,
)
// Lean (device Z component) is untouched by the Z-rotation correction.
val leaned = GravitySample(g * 0.99, 0.05, g * 0.1, 0)
assertEquals(
OrientationMath.edgePlumbLeanDegrees(leaned),
OrientationMath.edgePlumbLeanDegrees(cal.apply(leaned)),
1e-9,
)
}
@Test
fun `edge polarity flips the correction direction`() {
val positive = EdgeCalibration(0.3, calibratedOnPositiveXEdge = true)
val negative = EdgeCalibration(0.3, calibratedOnPositiveXEdge = false)
val s = GravitySample(g, 0.1, 0.0, 0)
val correctedPositive = positive.apply(s)
val correctedNegative = negative.apply(s)
// Opposite rotation directions about Z:
assertEquals(
OrientationMath.edgeLevelDegrees(correctedPositive) - OrientationMath.edgeLevelDegrees(s),
-(OrientationMath.edgeLevelDegrees(correctedNegative) - OrientationMath.edgeLevelDegrees(s)),
1e-6,
)
}
@Test
fun `surface and edge calibrations are independent and applied per mode`() {
val surface = TwoSampleCalibration.deriveSurface(1.0, 1.0, 0.0, 0.0)
val edge = EdgeCalibration.NONE
// An edge reading passed through the untouched edge calibration is unchanged,
// An edge sample passed through the untouched edge calibration is unchanged,
// regardless of surface calibration state (AUDIT.md finding 3).
assertEquals(0.7, edge.applyToLevel(0.7), 1e-9)
val edgeSample = GravitySample(g, 0.12, 0.0, 0)
assertEquals(edgeSample, edge.apply(edgeSample))
assertEquals(0.5, surface.pitchBiasDegrees, 1e-9)
}
@Test
fun `two-sample averaging matches the pairwise derivation`() {
val fromSamples = TwoSampleCalibration.deriveSurfaceFromSamples(
listOf(CapturedSurfaceSample(0.8, -0.1), CapturedSurfaceSample(-0.2, 0.5)),
)
val pairwise = TwoSampleCalibration.deriveSurface(0.8, -0.1, -0.2, 0.5)
assertEquals(pairwise.pitchBiasDegrees, fromSamples.pitchBiasDegrees, 1e-9)
assertEquals(pairwise.rollBiasDegrees, fromSamples.rollBiasDegrees, 1e-9)
}
@Test
fun `four-point averaging recovers bias from a symmetric rotation set`() {
// Bias is fixed in the device frame; the true surface tilt appears as a
// symmetric set summing to zero over 0/90/180/270 (pitch: +t,0,-t,0 ;
// roll: 0,+t,0,-t). The mean must therefore be exactly the bias.
val biasPitch = 0.4
val biasRoll = -0.3
val t = 1.7
val samples = listOf(
CapturedSurfaceSample(biasPitch + t, biasRoll),
CapturedSurfaceSample(biasPitch, biasRoll + t),
CapturedSurfaceSample(biasPitch - t, biasRoll),
CapturedSurfaceSample(biasPitch, biasRoll - t),
)
val cal = TwoSampleCalibration.deriveSurfaceFromSamples(samples)
assertEquals(biasPitch, cal.pitchBiasDegrees, 1e-9)
assertEquals(biasRoll, cal.rollBiasDegrees, 1e-9)
}
}
@@ -0,0 +1,153 @@
package com.onthelevel.feature.level
import com.onthelevel.core.sensors.EdgeCalibration
import com.onthelevel.core.sensors.GravitySample
import com.onthelevel.core.sensors.LevelMode
import com.onthelevel.core.sensors.LockDetector
import com.onthelevel.core.sensors.SurfaceCalibration
import com.onthelevel.core.sensors.SurfacePresentation
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.cos
import kotlin.math.sin
/**
* Acceptance tests for lock/readout coherence (Codex review): whenever the pipeline
* reports a lock, the deadbanded readout must stay within the tolerance the locked
* label states (the lock's exit threshold). Also pins placement gating.
*/
class LevelPipelineTest {
private val g = 9.80665
private fun pitched(degrees: Double, tMillis: Long): GravitySample {
val r = Math.toRadians(degrees)
return GravitySample(0.0, g * sin(r), g * cos(r), tMillis * 1_000_000)
}
private fun flat(tMillis: Long) = pitched(0.0, tMillis)
private fun surfacePipeline() = LevelPipeline(
mode = LevelMode.SURFACE,
surfaceCalibration = SurfaceCalibration.NONE,
edgeCalibration = EdgeCalibration.NONE,
lockDetector = LockDetector(),
)
/** Feed [degrees] steadily from t=[fromMillis] to t=[untilMillis] at 20 ms. */
private fun run(
pipeline: LevelPipeline,
degrees: Double,
fromMillis: Long,
untilMillis: Long,
): List<LevelReading> =
(fromMillis..untilMillis step 20).map { pipeline.process(pitched(degrees, it)) }
@Test
fun `steady near-level tilt locks and readout stays within stated tolerance`() {
val pipeline = surfacePipeline()
val readings = run(pipeline, degrees = 0.18, fromMillis = 0, untilMillis = 2_000)
assertTrue("expected a lock after dwell", readings.last().isLocked)
assertEquals(1, readings.count { it.fireFeedback })
readings.filter { it.isLocked }.forEach {
assertTrue(
"locked reading displayed ${it.displayPrimaryDegrees}° above tolerance",
it.displayPrimaryDegrees <= LockDetector.DEFAULT_EXIT_DEGREES,
)
}
}
@Test
fun `coherence invariant holds while drifting inside hysteresis, then unlocks`() {
val pipeline = surfacePipeline()
run(pipeline, degrees = 0.1, fromMillis = 0, untilMillis = 1_000) // acquire lock
// Drift to 0.30° — inside hysteresis (exit is 0.35°), so the lock holds and
// the displayed value (0.3°) must still be within the stated tolerance.
val drifted = run(pipeline, degrees = 0.30, fromMillis = 1_020, untilMillis = 3_000)
assertTrue(drifted.last().isLocked)
drifted.filter { it.isLocked }.forEach {
assertTrue(it.displayPrimaryDegrees <= LockDetector.DEFAULT_EXIT_DEGREES)
}
// Past the exit threshold the lock must release.
val tilted = run(pipeline, degrees = 0.6, fromMillis = 3_020, untilMillis = 5_000)
assertFalse(tilted.last().isLocked)
}
@Test
fun `surface magnitude remains available through near vertical and screen down`() {
fun readingAt(degrees: Double): LevelReading =
surfacePipeline().process(pitched(degrees, 0))
val atThirty = readingAt(30.0)
assertEquals(30.0, atThirty.displayPrimaryDegrees, 1e-9)
assertTrue(atThirty.secondaryADegrees != null)
assertTrue(atThirty.secondaryBDegrees != null)
assertEquals(SurfacePresentation.FACE_UP, atThirty.surfacePresentation)
assertFalse(atThirty.isLocked)
val atEighty = readingAt(80.0)
assertEquals(80.0, atEighty.displayPrimaryDegrees, 1e-9)
assertEquals(null, atEighty.secondaryADegrees)
assertEquals(null, atEighty.secondaryBDegrees)
assertEquals(SurfacePresentation.NEAR_VERTICAL, atEighty.surfacePresentation)
assertFalse(atEighty.isLocked)
val screenDown = readingAt(120.0)
assertEquals(120.0, screenDown.displayPrimaryDegrees, 1e-9)
assertEquals(null, screenDown.secondaryADegrees)
assertEquals(null, screenDown.secondaryBDegrees)
assertEquals(SurfacePresentation.SCREEN_DOWN, screenDown.surfacePresentation)
assertFalse(screenDown.isLocked)
}
@Test
fun `sustained steep Surface reading never locks or fires feedback`() {
val readings = run(surfacePipeline(), degrees = 30.0, fromMillis = 0, untilMillis = 2_000)
assertTrue(readings.all { !it.isLocked })
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
fun `edge mode never locks while the phone lies flat on a table`() {
val pipeline = LevelPipeline(
mode = LevelMode.EDGE,
surfaceCalibration = SurfaceCalibration.NONE,
edgeCalibration = EdgeCalibration.NONE,
lockDetector = LockDetector(),
)
// Flat on a table, Edge mode selected: edgeLevelDegrees(g) is ~0 — without
// placement gating this would lock on a meaningless reading.
val readings = (0L..2_000L step 20).map { pipeline.process(flat(it)) }
readings.forEach {
assertFalse(it.placementOk)
assertFalse(it.isLocked)
}
}
@Test
fun `edge mode locks on a genuinely level edge placement`() {
val pipeline = LevelPipeline(
mode = LevelMode.EDGE,
surfaceCalibration = SurfaceCalibration.NONE,
edgeCalibration = EdgeCalibration.NONE,
lockDetector = LockDetector(),
)
val readings = (0L..2_000L step 20).map {
pipeline.process(GravitySample(g, 0.0, 0.0, it * 1_000_000))
}
assertTrue(readings.last().placementOk)
assertTrue(readings.last().isLocked)
}
}
+12
View File
@@ -0,0 +1,12 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
toolchainVersion=21
+252
View File
@@ -0,0 +1,252 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@500;600;700&family=JetBrains+Mono:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
body{margin:0;background:radial-gradient(1200px 800px at 50% 20%, #1a1c20 0%, #101114 60%, #0b0c0e 100%)}
a{color:#ffb454;text-decoration:none} a:hover{color:#ffc97e}
@keyframes ping{0%{transform:scale(0.08);opacity:0.55}75%{transform:scale(1);opacity:0}100%{transform:scale(1);opacity:0}}
@keyframes chipring{0%{transform:scale(0.6);opacity:0.9}100%{transform:scale(2.4);opacity:0}}
@keyframes breathe{0%,100%{opacity:1}50%{opacity:0.25}}
@keyframes bob{0%,100%{transform:translate(0px,0px)}30%{transform:translate(1.4px,-1.1px)}65%{transform:translate(-1.2px,0.9px)}}
</style>
</helmet>
<div style="min-height:100vh;display:grid;place-items:center;padding:36px 24px">
<x-import component-from-global-scope="AndroidDevice" from="./android-frame.jsx" dark="{{ true }}" height="{{ 920 }}" hint-size="428px,936px">
<div data-screen-label="Level" style="position:relative;height:100%;display:flex;flex-direction:column;gap:14px;padding:14px 20px 18px;box-sizing:border-box;overflow:hidden;background:radial-gradient(140% 90% at 50% 0%, #121419 0%, #0a0b0d 55%, #060708 100%);font-family:'Archivo',system-ui,sans-serif;color:#e8eaed">
<div style="position:absolute;left:-120px;bottom:-120px;width:340px;height:340px;border-radius:50%;background:radial-gradient(circle, rgba(255,164,60,0.14) 0%, rgba(255,164,60,0) 70%);pointer-events:none;opacity:{{ glowOpacity }};transition:opacity 0.6s"></div>
<div style="display:flex;align-items:center;justify-content:space-between">
<div style="display:flex;flex-direction:column;gap:3px">
<div style="font-size:14px;font-weight:700;letter-spacing:4px;color:#e8eaed">LEVEL</div>
<div style="display:flex;align-items:center;gap:6px">
<div style="width:5px;height:5px;border-radius:50%;background:#7ed9a0;animation:breathe 2.6s ease-in-out infinite"></div>
<div style="font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:2px;color:#6f767e">LIVE · FUSED</div>
</div>
</div>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#6f767e" stroke-width="1.6" style="cursor:pointer">
<circle cx="12" cy="12" r="3.2"></circle>
<path d="M12 2.8v3M12 18.2v3M2.8 12h3M18.2 12h3M5.5 5.5l2.1 2.1M16.4 16.4l2.1 2.1M18.5 5.5l-2.1 2.1M7.6 16.4l-2.1 2.1"></path>
</svg>
</div>
<div style="display:flex;align-items:center;gap:10px;padding:9px 14px;border:1px solid rgba(255,255,255,0.07);border-radius:12px;background:linear-gradient(180deg, rgba(255,255,255,0.025), rgba(255,255,255,0))">
<div style="position:relative;width:8px;height:8px;flex:none">
<div style="position:absolute;inset:0;border-radius:50%;background:{{ sonarDotColor }}"></div>
<sc-if value="{{ sonarOn }}" hint-placeholder-val="{{ true }}">
<div style="position:absolute;inset:-3px;border-radius:50%;border:1px solid #5fd4c4;animation:chipring 2.2s ease-out infinite"></div>
</sc-if>
</div>
<div style="font-size:10px;font-weight:600;letter-spacing:2.5px;color:{{ sonarLabelColor }}">SONAR</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:12px;color:{{ sonarValueColor }}">{{ sonarText }}</div>
<div style="flex:1"></div>
<sc-if value="{{ sonarOn }}" hint-placeholder-val="{{ true }}">
<div style="display:flex;align-items:center;gap:3px">
<div style="width:2px;height:6px;background:#5fd4c4;opacity:0.35;border-radius:1px"></div>
<div style="width:2px;height:10px;background:#5fd4c4;opacity:0.6;border-radius:1px"></div>
<div style="width:2px;height:14px;background:#5fd4c4;opacity:0.9;border-radius:1px"></div>
<div style="width:2px;height:9px;background:#5fd4c4;opacity:0.5;border-radius:1px"></div>
<div style="width:2px;height:5px;background:#5fd4c4;opacity:0.3;border-radius:1px"></div>
</div>
</sc-if>
</div>
<div style="position:relative;display:flex;background:#15171b;border:1px solid rgba(255,255,255,0.06);border-radius:999px;padding:3px">
<div style="position:absolute;top:3px;bottom:3px;width:calc(50% - 3px);left:{{ thumbLeft }};border-radius:999px;background:linear-gradient(180deg,#2a2d33,#1f2126);border:1px solid rgba(255,255,255,0.1);box-shadow:0 2px 8px rgba(0,0,0,0.5);transition:left 0.25s cubic-bezier(0.3,0.9,0.3,1)"></div>
<button onClick="{{ setSurface }}" style="position:relative;flex:1;padding:9px 0;background:none;border:none;border-radius:999px;font-family:'Archivo',sans-serif;font-size:12px;font-weight:600;letter-spacing:1.5px;color:{{ surfTabColor }};cursor:pointer;transition:color 0.25s">SURFACE</button>
<button onClick="{{ setEdge }}" style="position:relative;flex:1;padding:9px 0;background:none;border:none;border-radius:999px;font-family:'Archivo',sans-serif;font-size:12px;font-weight:600;letter-spacing:1.5px;color:{{ edgeTabColor }};cursor:pointer;transition:color 0.25s">EDGE</button>
</div>
<sc-if value="{{ isSurface }}" hint-placeholder-val="{{ true }}">
<div data-screen-label="Surface mode" style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:space-evenly;min-height:0">
<svg width="308" height="308" viewBox="0 0 360 360" style="flex:none">
<defs>
<radialGradient id="dialFace" cx="42%" cy="34%" r="80%">
<stop offset="0%" stop-color="#1b1e23"></stop>
<stop offset="60%" stop-color="#111318"></stop>
<stop offset="100%" stop-color="#0a0b0e"></stop>
</radialGradient>
<radialGradient id="bubbleG" cx="38%" cy="30%" r="75%">
<stop offset="0%" stop-color="#eaffb0"></stop>
<stop offset="35%" stop-color="#c3e85e"></stop>
<stop offset="75%" stop-color="#8ab825"></stop>
<stop offset="100%" stop-color="#5f8510"></stop>
</radialGradient>
<radialGradient id="bubbleHalo" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#b7e050" stop-opacity="0.35"></stop>
<stop offset="100%" stop-color="#b7e050" stop-opacity="0"></stop>
</radialGradient>
</defs>
<circle cx="180" cy="180" r="172" fill="url(#dialFace)" stroke="rgba(255,255,255,0.1)" stroke-width="1"></circle>
<circle cx="180" cy="180" r="164" fill="none" stroke="rgba(255,255,255,0.28)" stroke-width="7" stroke-dasharray="1.4 41.55"></circle>
<circle cx="180" cy="180" r="164" fill="none" stroke="rgba(255,255,255,0.12)" stroke-width="5" stroke-dasharray="1 13.35"></circle>
<sc-if value="{{ sonarOn }}" hint-placeholder-val="{{ true }}">
<g style="transform-origin:180px 180px;animation:ping 4.5s linear infinite">
<circle cx="180" cy="180" r="150" fill="none" stroke="#5fd4c4" stroke-width="1" opacity="0.5"></circle>
</g>
<g style="transform-origin:180px 180px;animation:ping 4.5s linear 2.25s infinite">
<circle cx="180" cy="180" r="150" fill="none" stroke="#5fd4c4" stroke-width="1" opacity="0.5"></circle>
</g>
</sc-if>
<circle cx="180" cy="180" r="150" fill="none" stroke="rgba(255,255,255,0.08)" stroke-width="1"></circle>
<circle cx="180" cy="180" r="100" fill="none" stroke="rgba(255,255,255,0.1)" stroke-width="1"></circle>
<circle cx="180" cy="180" r="50" fill="none" stroke="rgba(255,255,255,0.14)" stroke-width="1"></circle>
<text x="186" y="44" font-family="JetBrains Mono" font-size="10" fill="#5a616a">1.5°</text>
<text x="186" y="94" font-family="JetBrains Mono" font-size="10" fill="#5a616a"></text>
<text x="186" y="144" font-family="JetBrains Mono" font-size="10" fill="#5a616a">0.5°</text>
<path d="M180 30 V150 M180 210 V330 M30 180 H150 M210 180 H330" stroke="rgba(255,255,255,0.16)" stroke-width="1"></path>
<path d="M78 249 A122 122 0 0 1 60.5 202" fill="none" stroke="#ffb454" stroke-width="2.5" stroke-linecap="round" opacity="{{ dirOpacity }}" style="transition:opacity 0.5s"></path>
<g opacity="{{ dirOpacity }}" style="transition:opacity 0.5s">
<path d="M96 262 l-9 5 3-10" fill="none" stroke="#ffb454" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</g>
<circle cx="180" cy="180" r="2.5" fill="#ffb454"></circle>
<g transform="translate({{ bubbleX }} {{ bubbleY }})" style="transition:transform 0.7s cubic-bezier(0.3,0.9,0.35,1)">
<g style="animation:bob 5.5s ease-in-out infinite">
<circle cx="180" cy="180" r="46" fill="url(#bubbleHalo)"></circle>
<circle cx="163" cy="196" r="3" fill="#b7e050" opacity="0.12"></circle>
<circle cx="171" cy="189" r="4.5" fill="#b7e050" opacity="0.22"></circle>
<circle cx="180" cy="180" r="21" fill="url(#bubbleG)"></circle>
<circle cx="180" cy="180" r="21" fill="none" stroke="rgba(255,255,255,0.25)" stroke-width="0.8"></circle>
<ellipse cx="173" cy="172" rx="7" ry="4.5" fill="#ffffff" opacity="0.55" transform="rotate(-32 173 172)"></ellipse>
</g>
</g>
</svg>
<div style="display:flex;flex-direction:column;align-items:center;gap:4px">
<div style="font-family:'JetBrains Mono',monospace;font-weight:300;font-size:84px;line-height:1;letter-spacing:-3px;color:#f2f4f6">{{ mainVal }}<span style="font-size:40px;font-weight:400;color:#8a919b;vertical-align:26px;letter-spacing:0">{{ mainUnit }}</span></div>
<svg width="240" height="22" viewBox="0 0 240 22">
<line x1="10" y1="14" x2="230" y2="14" stroke="rgba(255,255,255,0.1)" stroke-width="4" stroke-dasharray="1 10"></line>
<line x1="120" y1="6" x2="120" y2="20" stroke="rgba(255,255,255,0.3)" stroke-width="1.5"></line>
<rect x="{{ devX }}" y="4" width="3" height="16" rx="1.5" fill="{{ stateColor }}" style="transition:x 0.7s, fill 0.5s"></rect>
</svg>
<div style="display:flex;align-items:center;gap:8px">
<div style="width:5px;height:5px;border-radius:50%;background:{{ stateColor }};transition:background 0.5s"></div>
<div style="font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:2px;color:#6f767e">{{ subReadout }}</div>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ isEdge }}" hint-placeholder-val="{{ false }}">
<div data-screen-label="Edge mode" style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:space-evenly;min-height:0">
<svg width="340" height="150" viewBox="0 0 340 150" style="flex:none">
<defs>
<linearGradient id="tubeG" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#07080a"></stop>
<stop offset="45%" stop-color="#15181d"></stop>
<stop offset="100%" stop-color="#0b0c0f"></stop>
</linearGradient>
</defs>
<rect x="8" y="34" width="324" height="62" rx="31" fill="url(#tubeG)" stroke="rgba(255,255,255,0.1)" stroke-width="1"></rect>
<rect x="10" y="36" width="320" height="14" rx="7" fill="rgba(255,255,255,0.03)"></rect>
<line x1="146" y1="30" x2="146" y2="100" stroke="rgba(255,255,255,0.35)" stroke-width="1.5"></line>
<line x1="194" y1="30" x2="194" y2="100" stroke="rgba(255,255,255,0.35)" stroke-width="1.5"></line>
<g transform="translate({{ edgeBx }} 0)" style="transition:transform 0.7s cubic-bezier(0.3,0.9,0.35,1)">
<ellipse cx="170" cy="65" rx="24" ry="19" fill="url(#bubbleG2)"></ellipse>
<ellipse cx="170" cy="65" rx="24" ry="19" fill="none" stroke="rgba(255,255,255,0.25)" stroke-width="0.8"></ellipse>
<ellipse cx="162" cy="57" rx="8" ry="4" fill="#ffffff" opacity="0.5" transform="rotate(-18 162 57)"></ellipse>
</g>
<defs>
<radialGradient id="bubbleG2" cx="38%" cy="30%" r="75%">
<stop offset="0%" stop-color="#eaffb0"></stop>
<stop offset="35%" stop-color="#c3e85e"></stop>
<stop offset="75%" stop-color="#8ab825"></stop>
<stop offset="100%" stop-color="#5f8510"></stop>
</radialGradient>
</defs>
<line x1="20" y1="118" x2="320" y2="118" stroke="rgba(255,255,255,0.12)" stroke-width="6" stroke-dasharray="1 14"></line>
<line x1="170" y1="110" x2="170" y2="126" stroke="rgba(255,255,255,0.3)" stroke-width="1.5"></line>
<text x="14" y="142" font-family="JetBrains Mono" font-size="10" fill="#5a616a">-2°</text>
<text x="166" y="142" font-family="JetBrains Mono" font-size="10" fill="#5a616a">0</text>
<text x="312" y="142" font-family="JetBrains Mono" font-size="10" fill="#5a616a">+2°</text>
</svg>
<div style="display:flex;flex-direction:column;align-items:center;gap:6px">
<div style="font-family:'JetBrains Mono',monospace;font-weight:300;font-size:84px;line-height:1;letter-spacing:-3px;color:#f2f4f6">{{ edgeVal }}<span style="font-size:40px;font-weight:400;color:#8a919b;vertical-align:26px;letter-spacing:0">°</span></div>
<div style="display:flex;align-items:center;gap:8px">
<div style="width:5px;height:5px;border-radius:50%;background:{{ stateColor }};transition:background 0.5s"></div>
<div style="font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:2px;color:#6f767e">{{ edgeSubReadout }}</div>
</div>
</div>
</div>
</sc-if>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div style="background:linear-gradient(180deg,#14161b,#101216);border:1px solid rgba(255,255,255,0.06);border-radius:16px;padding:13px 16px 11px;display:flex;flex-direction:column;gap:6px">
<div style="font-size:9.5px;font-weight:600;letter-spacing:2.5px;color:#6f767e">PITCH</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:26px;font-weight:400;color:#e8eaed">{{ pitchText }}</div>
<svg width="100%" height="20" viewBox="0 0 130 20" preserveAspectRatio="none">
<line x1="0" y1="10" x2="130" y2="10" stroke="rgba(255,255,255,0.08)" stroke-width="1"></line>
<g style="transform-origin:65px 10px;transform:rotate({{ pitchRot }}deg);transition:transform 0.7s">
<line x1="20" y1="10" x2="110" y2="10" stroke="{{ stateColor }}" stroke-width="1.5"></line>
</g>
<circle cx="65" cy="10" r="2" fill="{{ stateColor }}"></circle>
</svg>
</div>
<div style="background:linear-gradient(180deg,#14161b,#101216);border:1px solid rgba(255,255,255,0.06);border-radius:16px;padding:13px 16px 11px;display:flex;flex-direction:column;gap:6px">
<div style="font-size:9.5px;font-weight:600;letter-spacing:2.5px;color:#6f767e">ROLL</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:26px;font-weight:400;color:#e8eaed">{{ rollText }}</div>
<svg width="100%" height="20" viewBox="0 0 130 20" preserveAspectRatio="none">
<line x1="0" y1="10" x2="130" y2="10" stroke="rgba(255,255,255,0.08)" stroke-width="1"></line>
<g style="transform-origin:65px 10px;transform:rotate({{ rollRot }}deg);transition:transform 0.7s">
<line x1="20" y1="10" x2="110" y2="10" stroke="{{ stateColor }}" stroke-width="1.5"></line>
</g>
<circle cx="65" cy="10" r="2" fill="{{ stateColor }}"></circle>
</svg>
</div>
</div>
</div>
</x-import>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:520,&quot;height&quot;:1000},&quot;leveled&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:false,&quot;tsType&quot;:&quot;boolean&quot;,&quot;section&quot;:&quot;State&quot;},&quot;sonarOn&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:true,&quot;tsType&quot;:&quot;boolean&quot;,&quot;section&quot;:&quot;Sonar&quot;},&quot;units&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;degrees&quot;,&quot;mm/m&quot;,&quot;percent&quot;],&quot;default&quot;:&quot;degrees&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Readout&quot;}}">
class Component extends DCLogic {
state = { mode: 'surface' };
renderVals() {
const leveled = this.props.leveled ?? false;
const sonarOn = this.props.sonarOn ?? true;
const units = this.props.units ?? 'degrees';
const mode = this.state.mode;
const pitch = leveled ? 0 : -0.6, roll = leveled ? 0 : -0.3;
const stateColor = leveled ? '#7ed9a0' : '#ffb454';
const fmt = (v) => (v > 0 ? '+' : v < 0 ? '-' : '') + Math.abs(v).toFixed(1) + '\u00b0';
let mainVal, mainUnit;
if (units === 'mm/m') { mainVal = leveled ? '0.0' : '10.5'; mainUnit = ' mm/m'; }
else if (units === 'percent') { mainVal = leveled ? '0.0' : '1.2'; mainUnit = '%'; }
else { mainVal = leveled ? '0.0' : '0.7'; mainUnit = '\u00b0'; }
return {
isSurface: mode === 'surface', isEdge: mode === 'edge',
setSurface: () => this.setState({ mode: 'surface' }),
setEdge: () => this.setState({ mode: 'edge' }),
thumbLeft: mode === 'surface' ? '3px' : 'calc(50%)',
surfTabColor: mode === 'surface' ? '#e8eaed' : '#6f767e',
edgeTabColor: mode === 'edge' ? '#e8eaed' : '#6f767e',
sonarOn,
sonarDotColor: sonarOn ? '#5fd4c4' : '#3a3f45',
sonarLabelColor: sonarOn ? '#9ba3ac' : '#4d535a',
sonarValueColor: sonarOn ? '#5fd4c4' : '#4d535a',
sonarText: sonarOn ? '0.42 m' : 'OFF',
glowOpacity: leveled ? 0 : 1,
dirOpacity: leveled ? 0 : 0.9,
bubbleX: roll * 100, bubbleY: -pitch * 100,
devX: 118.5 + pitch * 60,
stateColor,
mainVal, mainUnit,
subReadout: leveled ? 'LEVEL \u00b7 HOLD' : '10.5 MM/M \u00b7 HIGH \u2199',
edgeVal: leveled ? '0.0' : '0.3',
edgeSubReadout: leveled ? 'LEVEL \u00b7 HOLD' : '5.2 MM/M \u00b7 HIGH LEFT',
edgeBx: roll * 120,
pitchText: fmt(pitch), rollText: fmt(roll),
pitchRot: pitch * 12, rollRot: roll * 12,
};
}
}
</script>
</body>
</html>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.