Compare commits
17 Commits
2a48d79c77
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f8aa27e6cb | |||
| 0cb315fdcc | |||
| 604d61ebb2 | |||
| 3ebb36f265 | |||
| 36c0cfa5ab | |||
| e5d15c920f | |||
| daf19c34b3 | |||
| 85a8ce1136 | |||
| 721fccd5ce | |||
| 30417a5f98 | |||
| 027b9b96b3 | |||
| 422854de07 | |||
| d3854ac402 | |||
| 8d942a88b4 | |||
| 0c197894ff | |||
| abff397364 | |||
| 99c2b12192 |
@@ -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.
|
||||||
@@ -23,12 +23,18 @@ Implemented and tested:
|
|||||||
|
|
||||||
- `core/sensors` — sensor selection (game rotation vector → gravity → low-passed
|
- `core/sensors` — sensor selection (game rotation vector → gravity → low-passed
|
||||||
accelerometer), device-frame gravity stream, and the pure measurement math:
|
accelerometer), device-frame gravity stream, and the pure measurement math:
|
||||||
orientation mapping, two-sample per-mode calibration, EMA smoothing,
|
orientation mapping, two-sample per-mode calibration (derived in angle space,
|
||||||
lock hysteresis/dwell/debounce, display deadband. All unit-tested.
|
**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/settings` — DataStore preferences and per-mode calibration persistence.
|
||||||
- `core/billing` — entitlement interface with a stub (free-tier) implementation.
|
- `core/billing` — entitlement interface with a stub (free-tier) implementation.
|
||||||
- `feature/level`, `feature/angle` — live numeric scaffolds wired to the real
|
- `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.
|
- `feature/ruler`, `feature/tools` — static placeholders.
|
||||||
|
|
||||||
Not yet implemented (deliberately — see TODO markers):
|
Not yet implemented (deliberately — see TODO markers):
|
||||||
|
|||||||
@@ -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 515–680 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.5–2.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.
|
||||||
@@ -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.core.design.OnTheLevelTheme
|
||||||
import com.onthelevel.feature.angle.AngleScreen
|
import com.onthelevel.feature.angle.AngleScreen
|
||||||
import com.onthelevel.feature.level.LevelScreen
|
import com.onthelevel.feature.level.LevelScreen
|
||||||
|
import com.onthelevel.feature.level.SurfaceCalibrationScreen
|
||||||
import com.onthelevel.feature.ruler.RulerScreen
|
import com.onthelevel.feature.ruler.RulerScreen
|
||||||
|
import com.onthelevel.feature.tools.SettingsScreen
|
||||||
import com.onthelevel.feature.tools.ToolsScreen
|
import com.onthelevel.feature.tools.ToolsScreen
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@@ -87,12 +89,29 @@ private fun AppRoot(container: AppContainer) {
|
|||||||
startDestination = "level",
|
startDestination = "level",
|
||||||
modifier = Modifier.padding(padding),
|
modifier = Modifier.padding(padding),
|
||||||
) {
|
) {
|
||||||
composable("level") { LevelScreen(container) }
|
composable("level") {
|
||||||
|
LevelScreen(container, onOpenSettings = {
|
||||||
|
navController.navigate("settings")
|
||||||
|
})
|
||||||
|
}
|
||||||
composable("angle") { AngleScreen(container) }
|
composable("angle") { AngleScreen(container) }
|
||||||
composable("tools") {
|
composable("tools") {
|
||||||
ToolsScreen(onOpenRuler = { navController.navigate("ruler") })
|
ToolsScreen(
|
||||||
|
onOpenRuler = { navController.navigate("ruler") },
|
||||||
|
onOpenSettings = { navController.navigate("settings") },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
composable("ruler") { RulerScreen(container) }
|
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.AndroidSensorSource
|
||||||
import com.onthelevel.core.sensors.SensorSource
|
import com.onthelevel.core.sensors.SensorSource
|
||||||
import com.onthelevel.core.settings.SettingsRepository
|
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
|
* 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) {
|
class AppContainer(context: Context) {
|
||||||
private val appContext = context.applicationContext
|
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 sensorSource: SensorSource by lazy { AndroidSensorSource(appContext) }
|
||||||
val settings: SettingsRepository by lazy { SettingsRepository(appContext) }
|
val settings: SettingsRepository by lazy { SettingsRepository(appContext) }
|
||||||
val entitlement: ProEntitlementRepository by lazy { StubProEntitlementRepository() }
|
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):
|
* 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.
|
* 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.
|
* 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 AmberHighlight = Color(0xFFFFEEB0)
|
||||||
val AmberDeep = Color(0xFFE0961E)
|
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 LimeLock = Color(0xFFCBEF5C)
|
||||||
val LimeLockText = Color(0xFFEAFFC2)
|
val LimeLockText = Color(0xFFEAFFC2)
|
||||||
|
|
||||||
|
|||||||
@@ -49,12 +49,13 @@ class AndroidSensorSource(context: Context) : SensorSource {
|
|||||||
val sample = when (sensorKind) {
|
val sample = when (sensorKind) {
|
||||||
SensorSource.Kind.GAME_ROTATION_VECTOR -> {
|
SensorSource.Kind.GAME_ROTATION_VECTOR -> {
|
||||||
SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
|
SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
|
||||||
// R maps device → world; world-up expressed in the device frame
|
// Converted to the GravitySample contract (device-frame
|
||||||
// is R's third row. Scale to standard gravity.
|
// world-up); see RotationVectorMath and SensorContractTest.
|
||||||
|
val (x, y, z) = RotationVectorMath.worldUpDeviceFrame(rotationMatrix)
|
||||||
GravitySample(
|
GravitySample(
|
||||||
x = rotationMatrix[6] * STANDARD_GRAVITY,
|
x = x * STANDARD_GRAVITY,
|
||||||
y = rotationMatrix[7] * STANDARD_GRAVITY,
|
y = y * STANDARD_GRAVITY,
|
||||||
z = rotationMatrix[8] * STANDARD_GRAVITY,
|
z = z * STANDARD_GRAVITY,
|
||||||
timestampNanos = event.timestamp,
|
timestampNanos = event.timestamp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
package com.onthelevel.core.sensors
|
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.
|
* 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(
|
data class GravitySample(
|
||||||
val x: Double,
|
val x: Double,
|
||||||
@@ -12,5 +19,20 @@ data class GravitySample(
|
|||||||
val timestampNanos: Long,
|
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 }
|
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
|
* Level-lock state machine with hysteresis, dwell, and haptic debounce (BRIEF.md
|
||||||
* §Sensor and measurement architecture). Operates ONLY on the stable calibrated
|
* §Sensor and measurement architecture). Operates on the stable calibrated measurement —
|
||||||
* measurement — the same value the numeric readout shows. The lock must never
|
* the same value the numeric readout shows, so the lock never disagrees with the number.
|
||||||
* disagree with the number on screen.
|
|
||||||
*
|
*
|
||||||
* 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(
|
class LockDetector(
|
||||||
private val enterThresholdDegrees: Double = 0.2,
|
private val enterThresholdDegrees: Double = DEFAULT_ENTER_DEGREES,
|
||||||
private val exitThresholdDegrees: Double = 0.35,
|
private val exitThresholdDegrees: Double = DEFAULT_EXIT_DEGREES,
|
||||||
private val dwellMillis: Long = 400,
|
private val dwellMillis: Long = DEFAULT_DWELL_MILLIS,
|
||||||
private val feedbackDebounceMillis: Long = 3_000,
|
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 {
|
init {
|
||||||
require(exitThresholdDegrees > enterThresholdDegrees) {
|
require(exitThresholdDegrees > enterThresholdDegrees) {
|
||||||
"Hysteresis requires exit > enter threshold"
|
"Hysteresis requires exit > enter threshold"
|
||||||
@@ -32,14 +55,19 @@ class LockDetector(
|
|||||||
private var withinEnterSinceMillis: Long? = null
|
private var withinEnterSinceMillis: Long? = null
|
||||||
private var lastFeedbackAtMillis: 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)
|
val magnitude = abs(stableTiltDegrees)
|
||||||
var fire = false
|
var fire = false
|
||||||
|
|
||||||
if (!locked) {
|
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 }
|
val since = withinEnterSinceMillis ?: nowMillis.also { withinEnterSinceMillis = it }
|
||||||
if (nowMillis - since >= dwellMillis) {
|
if (nowMillis - since >= requiredDwell) {
|
||||||
locked = true
|
locked = true
|
||||||
val last = lastFeedbackAtMillis
|
val last = lastFeedbackAtMillis
|
||||||
if (last == null || nowMillis - last >= feedbackDebounceMillis) {
|
if (last == null || nowMillis - last >= feedbackDebounceMillis) {
|
||||||
@@ -48,6 +76,8 @@ class LockDetector(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Out of zone or moving too fast: reset, so a fresh slow-and-centered spell
|
||||||
|
// starts the confirmation dwell over.
|
||||||
withinEnterSinceMillis = null
|
withinEnterSinceMillis = null
|
||||||
}
|
}
|
||||||
} else if (magnitude >= exitThresholdDegrees) {
|
} else if (magnitude >= exitThresholdDegrees) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import kotlin.math.abs
|
|||||||
import kotlin.math.acos
|
import kotlin.math.acos
|
||||||
import kotlin.math.asin
|
import kotlin.math.asin
|
||||||
import kotlin.math.atan2
|
import kotlin.math.atan2
|
||||||
|
import kotlin.math.cos
|
||||||
import kotlin.math.sqrt
|
import kotlin.math.sqrt
|
||||||
import kotlin.math.tan
|
import kotlin.math.tan
|
||||||
|
|
||||||
@@ -61,6 +62,37 @@ object OrientationMath {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const val VERTICAL_GRADE_CUTOFF_DEGREES = 89.5
|
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)
|
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 0–180°;
|
||||||
|
* 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
|
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).
|
* 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
|
* DERIVATION — the surface's true tilt is fixed in the world frame; the device's own bias
|
||||||
* in the device frame. After rotating the device 180° about the CONTACT-PLANE NORMAL — on the
|
* is fixed in the device frame. After rotating the device 180° about the CONTACT-PLANE
|
||||||
* same, unmoved surface — the true tilt appears negated in device readings while the bias
|
* NORMAL — on the same, unmoved surface — the true tilt appears negated in device
|
||||||
* does not move:
|
* readings while the bias does not move:
|
||||||
*
|
*
|
||||||
* reading₁ = tilt + bias
|
* reading₁ = tilt + bias
|
||||||
* reading₂ = -tilt + bias
|
* reading₂ = -tilt + bias
|
||||||
* ⇒ bias = (reading₁ + reading₂) / 2
|
* ⇒ bias = (reading₁ + reading₂) / 2
|
||||||
*
|
*
|
||||||
* This holds per axis for the small near-level angles calibration is used at. It is only
|
* Derivation happens in angle space near level, where the flip identity is exact to
|
||||||
* valid if (a) the rotation is about the contact-plane normal and (b) the surface does not
|
* first order. Preconditions the calibration UI must enforce: (a) rotation about the
|
||||||
* move between samples — the calibration UI must instruct exactly that.
|
* 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
|
* 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).
|
* calibration must never touch Edge readings (AUDIT.md finding 3).
|
||||||
@@ -33,19 +47,84 @@ object TwoSampleCalibration {
|
|||||||
rollBiasDegrees = deriveBiasDegrees(roll1, roll2),
|
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) {
|
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) }
|
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) }
|
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.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.doublePreferencesKey
|
import androidx.datastore.preferences.core.doublePreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import com.onthelevel.core.sensors.EdgeCalibration
|
import com.onthelevel.core.sensors.EdgeCalibration
|
||||||
import com.onthelevel.core.sensors.SurfaceCalibration
|
import com.onthelevel.core.sensors.SurfaceCalibration
|
||||||
@@ -30,15 +31,42 @@ class SettingsRepository(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val edgeCalibration: Flow<EdgeCalibration> = store.data.map { prefs ->
|
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 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. */
|
/** In-app reduced-motion preference; the system animator-scale signal is respected separately. */
|
||||||
val reducedMotion: Flow<Boolean> = store.data.map { it[Keys.REDUCED_MOTION] ?: false }
|
val reducedMotion: Flow<Boolean> = store.data.map { it[Keys.REDUCED_MOTION] ?: false }
|
||||||
|
|
||||||
|
/** Kept in core so every future measurement tool formats slope consistently. */
|
||||||
|
val measurementUnits: Flow<MeasurementUnits> = store.data.map { prefs ->
|
||||||
|
when (prefs[Keys.MEASUREMENT_UNITS]) {
|
||||||
|
MeasurementUnits.IMPERIAL.name -> MeasurementUnits.IMPERIAL
|
||||||
|
else -> MeasurementUnits.METRIC
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
suspend fun setSurfaceCalibration(calibration: SurfaceCalibration) {
|
||||||
store.edit {
|
store.edit {
|
||||||
it[Keys.SURFACE_PITCH_BIAS] = calibration.pitchBiasDegrees
|
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) {
|
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) {
|
suspend fun setHapticsEnabled(enabled: Boolean) {
|
||||||
@@ -58,18 +91,34 @@ class SettingsRepository(context: Context) {
|
|||||||
store.edit { it[Keys.AUDIO_CUE_ENABLED] = enabled }
|
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) {
|
suspend fun setReducedMotion(enabled: Boolean) {
|
||||||
store.edit { it[Keys.REDUCED_MOTION] = enabled }
|
store.edit { it[Keys.REDUCED_MOTION] = enabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun setMeasurementUnits(units: MeasurementUnits) {
|
||||||
|
store.edit { it[Keys.MEASUREMENT_UNITS] = units.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
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).
|
// TODO(ruler): screen-ruler scale keyed by display identity/characteristics (BRIEF.md).
|
||||||
|
|
||||||
private object Keys {
|
private object Keys {
|
||||||
val SURFACE_PITCH_BIAS = doublePreferencesKey("surface_pitch_bias_deg")
|
val SURFACE_PITCH_BIAS = doublePreferencesKey("surface_pitch_bias_deg")
|
||||||
val SURFACE_ROLL_BIAS = doublePreferencesKey("surface_roll_bias_deg")
|
val SURFACE_ROLL_BIAS = doublePreferencesKey("surface_roll_bias_deg")
|
||||||
val EDGE_LEVEL_BIAS = doublePreferencesKey("edge_level_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 HAPTICS_ENABLED = booleanPreferencesKey("haptics_enabled")
|
||||||
val AUDIO_CUE_ENABLED = booleanPreferencesKey("audio_cue_enabled")
|
val AUDIO_CUE_ENABLED = booleanPreferencesKey("audio_cue_enabled")
|
||||||
|
val AUDIO_ASSIST_MODE = stringPreferencesKey("audio_assist_mode")
|
||||||
val REDUCED_MOTION = booleanPreferencesKey("reduced_motion")
|
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
|
package com.onthelevel.feature.angle
|
||||||
|
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -21,12 +22,12 @@ import androidx.compose.ui.input.pointer.pointerInput
|
|||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.onthelevel.AppContainer
|
import com.onthelevel.AppContainer
|
||||||
import com.onthelevel.core.design.KeepScreenOn
|
import com.onthelevel.core.design.KeepScreenOn
|
||||||
import com.onthelevel.core.design.LevelColors
|
import com.onthelevel.core.design.LevelColors
|
||||||
import com.onthelevel.core.sensors.Ema
|
import com.onthelevel.core.sensors.Ema
|
||||||
|
import com.onthelevel.core.sensors.GravitySample
|
||||||
import com.onthelevel.core.sensors.OrientationMath
|
import com.onthelevel.core.sensors.OrientationMath
|
||||||
import com.onthelevel.feature.level.formatDegrees
|
import com.onthelevel.feature.level.formatDegrees
|
||||||
import com.onthelevel.feature.level.performConfirmHaptic
|
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).
|
* 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.
|
* TODO(pro): target-angle alerts and saved named references behind the entitlement.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
@@ -48,29 +56,43 @@ fun AngleScreen(container: AppContainer) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data class AngleReading(val tilt: Double, val pitch: Double, val roll: Double)
|
|
||||||
|
|
||||||
val readingFlow = remember {
|
val readingFlow = remember {
|
||||||
val tiltEma = Ema(0.15)
|
// Smooth the vector components, then derive angles — keeps the smoothed
|
||||||
val pitchEma = Ema(0.15)
|
// gravity direction available for the vector-based zero reference.
|
||||||
val rollEma = Ema(0.15)
|
val xEma = Ema(0.15)
|
||||||
|
val yEma = Ema(0.15)
|
||||||
|
val zEma = Ema(0.15)
|
||||||
var lastNanos: Long? = null
|
var lastNanos: Long? = null
|
||||||
sensorSource.gravity.map { g ->
|
sensorSource.gravity.map { g ->
|
||||||
val dt = lastNanos?.let { (g.timestampNanos - it) / 1e9 } ?: 0.0
|
val dt = lastNanos?.let { (g.timestampNanos - it) / 1e9 } ?: 0.0
|
||||||
lastNanos = g.timestampNanos
|
lastNanos = g.timestampNanos
|
||||||
AngleReading(
|
GravitySample(
|
||||||
tilt = tiltEma.update(OrientationMath.surfaceTiltMagnitudeDegrees(g), dt),
|
x = xEma.update(g.x, dt),
|
||||||
pitch = pitchEma.update(OrientationMath.surfacePitchDegrees(g), dt),
|
y = yEma.update(g.y, dt),
|
||||||
roll = rollEma.update(OrientationMath.surfaceRollDegrees(g), 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).
|
// Zero reference: the smoothed gravity direction captured on long-press.
|
||||||
var zeroReferenceDegrees by rememberSaveable { mutableStateOf(0.0) }
|
// Empty array = no reference set. DoubleArray keeps rememberSaveable happy.
|
||||||
|
var zeroReference by rememberSaveable { mutableStateOf(doubleArrayOf()) }
|
||||||
val view = LocalView.current
|
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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -82,7 +104,7 @@ fun AngleScreen(container: AppContainer) {
|
|||||||
) {
|
) {
|
||||||
Text("ANGLE", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
|
Text("ANGLE", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
|
||||||
Text(
|
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,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = LevelColors.TextFaint,
|
color = LevelColors.TextFaint,
|
||||||
)
|
)
|
||||||
@@ -92,27 +114,28 @@ fun AngleScreen(container: AppContainer) {
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.pointerInput(reading != null) {
|
.pointerInput(Unit) {
|
||||||
detectTapGestures(
|
detectTapGestures(
|
||||||
onLongPress = {
|
onLongPress = {
|
||||||
reading?.let {
|
smoothed?.let {
|
||||||
zeroReferenceDegrees = it.tilt
|
zeroReference = doubleArrayOf(it.x, it.y, it.z)
|
||||||
view.performConfirmHaptic()
|
view.performConfirmHaptic()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onDoubleTap = { zeroReference = doubleArrayOf() },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text(
|
Text(
|
||||||
text = reading?.let { formatDegrees(it.tilt - zeroReferenceDegrees) } ?: "—",
|
text = primaryDegrees?.let(::formatDegrees) ?: "—",
|
||||||
style = MaterialTheme.typography.displayLarge,
|
style = MaterialTheme.typography.displayLarge,
|
||||||
color = LevelColors.TextPrimary,
|
color = LevelColors.TextPrimary,
|
||||||
)
|
)
|
||||||
if (zeroReferenceDegrees != 0.0) {
|
if (isRelative) {
|
||||||
Text(
|
Text(
|
||||||
text = "zeroed at " + formatDegrees(zeroReferenceDegrees),
|
text = "from saved orientation · double-tap to clear",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = LevelColors.Amber,
|
color = LevelColors.Amber,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
@@ -125,9 +148,18 @@ fun AngleScreen(container: AppContainer) {
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
) {
|
) {
|
||||||
SecondaryValue("PITCH", reading?.pitch?.let(::formatDegrees), Modifier.weight(1f))
|
val pitch = smoothed?.let(OrientationMath::surfacePitchDegrees)
|
||||||
SecondaryValue("ROLL", reading?.roll?.let(::formatDegrees), Modifier.weight(1f))
|
SecondaryValue("PITCH", pitch?.let(::formatDegrees), Modifier.weight(1f))
|
||||||
SecondaryValue("GRADE", reading?.pitch?.let { formatGrade(OrientationMath.percentGrade(it)) }, 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.LockDetector
|
||||||
import com.onthelevel.core.sensors.OrientationMath
|
import com.onthelevel.core.sensors.OrientationMath
|
||||||
import com.onthelevel.core.sensors.SurfaceCalibration
|
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).
|
* 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,
|
val mode: LevelMode,
|
||||||
/** Deadbanded stable magnitude for the big readout, in degrees. */
|
/** Deadbanded stable magnitude for the big readout, in degrees. */
|
||||||
val displayPrimaryDegrees: Double,
|
val displayPrimaryDegrees: Double,
|
||||||
/** Surface: pitch. Edge: signed level deviation. Deadbanded. */
|
/** Surface: pitch when it is meaningful. Edge: signed level deviation. */
|
||||||
val secondaryADegrees: Double,
|
val secondaryADegrees: Double?,
|
||||||
/** Surface: roll. Edge: plumb lean. Deadbanded. */
|
/** Surface: roll when it is meaningful. Edge: plumb lean. */
|
||||||
val secondaryBDegrees: Double,
|
val secondaryBDegrees: Double?,
|
||||||
|
/** Stable calibrated Surface axes for the visual instrument; null outside Surface mode. */
|
||||||
|
val stableSurfacePitchDegrees: Double? = null,
|
||||||
|
val stableSurfaceRollDegrees: Double? = null,
|
||||||
|
/** 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 isLocked: Boolean,
|
||||||
val fireFeedback: Boolean,
|
val fireFeedback: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stateful per-collection pipeline: calibration → EMA smoothing → lock detection →
|
* Stateful per-collection pipeline: vector-space calibration → angle derivation →
|
||||||
* display deadband. Created fresh when mode or calibration changes; the LockDetector
|
* EMA smoothing → lock detection → display deadband. Calibration is applied to the
|
||||||
* is shared across recreations so the haptic debounce survives mode switches.
|
* 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(
|
class LevelPipeline(
|
||||||
private val mode: LevelMode,
|
private val mode: LevelMode,
|
||||||
@@ -38,11 +59,14 @@ class LevelPipeline(
|
|||||||
private val edgeCalibration: EdgeCalibration,
|
private val edgeCalibration: EdgeCalibration,
|
||||||
private val lockDetector: LockDetector,
|
private val lockDetector: LockDetector,
|
||||||
) {
|
) {
|
||||||
|
private val emaPrimary = Ema(SMOOTHING_TAU_SECONDS)
|
||||||
private val emaA = Ema(SMOOTHING_TAU_SECONDS)
|
private val emaA = Ema(SMOOTHING_TAU_SECONDS)
|
||||||
private val emaB = Ema(SMOOTHING_TAU_SECONDS)
|
private val emaB = Ema(SMOOTHING_TAU_SECONDS)
|
||||||
private val primaryDeadband = DisplayDeadband()
|
private val primaryDeadband = DisplayDeadband()
|
||||||
private val aDeadband = DisplayDeadband()
|
private val aDeadband = DisplayDeadband()
|
||||||
private val bDeadband = DisplayDeadband()
|
private val bDeadband = DisplayDeadband()
|
||||||
|
private val surfacePresentationDetector = SurfacePresentationDetector()
|
||||||
|
private val settlingDetector = SettlingDetector()
|
||||||
private var lastTimestampNanos: Long? = null
|
private var lastTimestampNanos: Long? = null
|
||||||
|
|
||||||
fun process(g: GravitySample): LevelReading {
|
fun process(g: GravitySample): LevelReading {
|
||||||
@@ -51,42 +75,68 @@ class LevelPipeline(
|
|||||||
// Sensor timestamps are monotonic; using them (not wall clock) keeps the
|
// Sensor timestamps are monotonic; using them (not wall clock) keeps the
|
||||||
// lock state machine deterministic under recorded traces.
|
// lock state machine deterministic under recorded traces.
|
||||||
val nowMillis = g.timestampNanos / 1_000_000
|
val nowMillis = g.timestampNanos / 1_000_000
|
||||||
|
val placementOk = OrientationMath.isPlacementValid(g, mode)
|
||||||
|
|
||||||
return when (mode) {
|
return when (mode) {
|
||||||
LevelMode.SURFACE -> {
|
LevelMode.SURFACE -> {
|
||||||
val pitch = emaA.update(
|
val corrected = surfaceCalibration.apply(g)
|
||||||
surfaceCalibration.applyToPitch(OrientationMath.surfacePitchDegrees(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,
|
dtSeconds,
|
||||||
)
|
)
|
||||||
val roll = emaB.update(
|
val displayMagnitude = primaryDeadband.update(magnitude)
|
||||||
surfaceCalibration.applyToRoll(OrientationMath.surfaceRollDegrees(g)),
|
val presentation = surfacePresentationDetector.update(displayMagnitude)
|
||||||
dtSeconds,
|
val settling = settlingDetector.update(pitch, roll, g.timestampNanos)
|
||||||
)
|
// Velocity-aware lock: ease into center → quick confirm; fly across → nothing.
|
||||||
// Near level, calibrated tilt magnitude ≈ hypot of the two calibrated
|
val lock = lockDetector.update(magnitude, settling.movementRateDegreesPerSecond, nowMillis)
|
||||||
// axis angles — keeps lock detection consistent with the displayed axes.
|
|
||||||
val magnitude = hypot(pitch, roll)
|
|
||||||
val lock = lockDetector.update(magnitude, nowMillis)
|
|
||||||
LevelReading(
|
LevelReading(
|
||||||
mode = mode,
|
mode = mode,
|
||||||
displayPrimaryDegrees = primaryDeadband.update(magnitude),
|
displayPrimaryDegrees = displayMagnitude,
|
||||||
secondaryADegrees = aDeadband.update(pitch),
|
secondaryADegrees = if (presentation == SurfacePresentation.FACE_UP) {
|
||||||
secondaryBDegrees = bDeadband.update(roll),
|
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,
|
isLocked = lock.isLocked,
|
||||||
fireFeedback = lock.fireFeedback,
|
fireFeedback = lock.fireFeedback,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
LevelMode.EDGE -> {
|
LevelMode.EDGE -> {
|
||||||
val level = emaA.update(
|
val corrected = edgeCalibration.apply(g)
|
||||||
edgeCalibration.applyToLevel(OrientationMath.edgeLevelDegrees(g)),
|
val level = emaPrimary.update(
|
||||||
|
OrientationMath.edgeLevelDegrees(corrected),
|
||||||
dtSeconds,
|
dtSeconds,
|
||||||
)
|
)
|
||||||
val lean = emaB.update(OrientationMath.edgePlumbLeanDegrees(g), dtSeconds)
|
val lean = emaB.update(
|
||||||
val lock = lockDetector.update(level, nowMillis)
|
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(
|
LevelReading(
|
||||||
mode = mode,
|
mode = mode,
|
||||||
displayPrimaryDegrees = primaryDeadband.update(level),
|
displayPrimaryDegrees = displayLevel,
|
||||||
secondaryADegrees = aDeadband.update(level),
|
secondaryADegrees = displayLevel,
|
||||||
secondaryBDegrees = bDeadband.update(lean),
|
secondaryBDegrees = bDeadband.update(lean),
|
||||||
|
placementOk = placementOk,
|
||||||
isLocked = lock.isLocked,
|
isLocked = lock.isLocked,
|
||||||
fireFeedback = lock.fireFeedback,
|
fireFeedback = lock.fireFeedback,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.Settings
|
import androidx.compose.material.icons.outlined.Settings
|
||||||
@@ -20,6 +23,7 @@ import androidx.compose.material3.SegmentedButtonDefaults
|
|||||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -29,8 +33,15 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalView
|
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.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.em
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.onthelevel.AppContainer
|
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.EdgeCalibration
|
||||||
import com.onthelevel.core.sensors.LevelMode
|
import com.onthelevel.core.sensors.LevelMode
|
||||||
import com.onthelevel.core.sensors.LockDetector
|
import com.onthelevel.core.sensors.LockDetector
|
||||||
|
import com.onthelevel.core.sensors.RiseRun
|
||||||
import com.onthelevel.core.sensors.SensorSource
|
import com.onthelevel.core.sensors.SensorSource
|
||||||
import com.onthelevel.core.sensors.SurfaceCalibration
|
import com.onthelevel.core.sensors.SurfaceCalibration
|
||||||
|
import com.onthelevel.core.sensors.SurfaceGuidance
|
||||||
|
import com.onthelevel.core.sensors.SurfacePresentation
|
||||||
|
import com.onthelevel.core.settings.AudioAssistMode
|
||||||
|
import com.onthelevel.core.settings.MeasurementUnits
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,7 +70,7 @@ import java.util.Locale
|
|||||||
* while the portrait-locked device stands on its long edge.
|
* while the portrait-locked device stands on its long edge.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun LevelScreen(container: AppContainer) {
|
fun LevelScreen(container: AppContainer, onOpenSettings: () -> Unit) {
|
||||||
KeepScreenOn()
|
KeepScreenOn()
|
||||||
|
|
||||||
val sensorSource = container.sensorSource
|
val sensorSource = container.sensorSource
|
||||||
@@ -69,6 +86,12 @@ fun LevelScreen(container: AppContainer) {
|
|||||||
.collectAsStateWithLifecycle(initialValue = EdgeCalibration.NONE)
|
.collectAsStateWithLifecycle(initialValue = EdgeCalibration.NONE)
|
||||||
val hapticsEnabled by container.settings.hapticsEnabled
|
val hapticsEnabled by container.settings.hapticsEnabled
|
||||||
.collectAsStateWithLifecycle(initialValue = true)
|
.collectAsStateWithLifecycle(initialValue = true)
|
||||||
|
val 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 view = LocalView.current
|
||||||
val lockDetector = remember { LockDetector() }
|
val lockDetector = remember { LockDetector() }
|
||||||
@@ -81,6 +104,7 @@ fun LevelScreen(container: AppContainer) {
|
|||||||
.onEach { if (it.fireFeedback && hapticsEnabled) view.performConfirmHaptic() }
|
.onEach { if (it.fireFeedback && hapticsEnabled) view.performConfirmHaptic() }
|
||||||
}
|
}
|
||||||
val reading by readingFlow.collectAsStateWithLifecycle(initialValue = null)
|
val reading by readingFlow.collectAsStateWithLifecycle(initialValue = null)
|
||||||
|
AudioLevelAssist(reading, audioMode)
|
||||||
|
|
||||||
val isLocked = reading?.isLocked == true
|
val isLocked = reading?.isLocked == true
|
||||||
val isCalibrated = when (mode) {
|
val isCalibrated = when (mode) {
|
||||||
@@ -106,14 +130,35 @@ fun LevelScreen(container: AppContainer) {
|
|||||||
color = if (isCalibrated) LevelColors.LimeLock else LevelColors.TextFaint,
|
color = if (isCalibrated) LevelColors.LimeLock else LevelColors.TextFaint,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = { /* TODO(feature): calibration & settings entry */ }) {
|
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(
|
Icon(
|
||||||
Icons.Outlined.Settings,
|
Icons.Outlined.Settings,
|
||||||
contentDescription = "Calibration and settings",
|
contentDescription = "Settings",
|
||||||
tint = LevelColors.TextDim,
|
tint = LevelColors.TextDim,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||||
LevelMode.entries.forEachIndexed { index, entry ->
|
LevelMode.entries.forEachIndexed { index, entry ->
|
||||||
@@ -133,23 +178,69 @@ fun LevelScreen(container: AppContainer) {
|
|||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
contentAlignment = Alignment.Center,
|
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(
|
||||||
text = reading?.let { formatDegrees(it.displayPrimaryDegrees) } ?: "—",
|
text = reading?.let { primaryReadout(formatDegrees(it.displayPrimaryDegrees)) }
|
||||||
|
?: AnnotatedString("—"),
|
||||||
style = MaterialTheme.typography.displayLarge,
|
style = MaterialTheme.typography.displayLarge,
|
||||||
color = if (isLocked) LevelColors.LimeLock else LevelColors.TextPrimary,
|
color = LevelColors.TextPrimary,
|
||||||
)
|
)
|
||||||
// Lock state is announced with a label, never color alone (BRIEF.md).
|
// Lock state is announced with a label, never color alone (BRIEF.md).
|
||||||
|
// The label states the tolerance (the lock's exit threshold), so the
|
||||||
|
// rounded readout and the "level" claim can never contradict.
|
||||||
Text(
|
Text(
|
||||||
text = when {
|
text = when {
|
||||||
isLocked && mode == LevelMode.SURFACE -> "Surface is flat"
|
mode == LevelMode.SURFACE && surfacePresentation == SurfacePresentation.SCREEN_DOWN ->
|
||||||
isLocked -> "Edge is level"
|
"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 -> ""
|
else -> ""
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
color = LevelColors.LimeLockText,
|
color = when {
|
||||||
|
!placementOk -> LevelColors.TextDim
|
||||||
|
isLocked -> LevelColors.LimeLockText
|
||||||
|
else -> LevelColors.TextPrimary
|
||||||
|
},
|
||||||
textAlign = TextAlign.Center,
|
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
|
@Composable
|
||||||
private fun ValuePanel(label: String, value: Double?, modifier: Modifier = Modifier) {
|
private fun ValuePanel(label: String, value: Double?, modifier: Modifier = Modifier) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
color = LevelColors.Panel,
|
color = LevelColors.ReadoutPanel,
|
||||||
contentColor = LevelColors.TextPrimary,
|
contentColor = LevelColors.TextPrimary,
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(
|
||||||
Text(label, style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
|
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(
|
||||||
text = value?.let { formatDegrees(it) } ?: "—",
|
text = value?.let { formatDegrees(it) } ?: "—",
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
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)
|
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() {
|
internal fun View.performConfirmHaptic() {
|
||||||
val constant = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
val constant = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
HapticFeedbackConstants.CONFIRM
|
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
|
import com.onthelevel.core.design.LevelColors
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ToolsScreen(onOpenRuler: () -> Unit) {
|
fun ToolsScreen(onOpenRuler: () -> Unit, onOpenSettings: () -> Unit) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -33,14 +33,9 @@ fun ToolsScreen(onOpenRuler: () -> Unit) {
|
|||||||
onClick = onOpenRuler,
|
onClick = onOpenRuler,
|
||||||
)
|
)
|
||||||
ToolCard(
|
ToolCard(
|
||||||
title = "Calibration",
|
title = "Settings",
|
||||||
subtitle = "Two-sample level calibration — coming with the feature build",
|
subtitle = "Calibration, units, feedback, and motion",
|
||||||
onClick = { /* TODO(feature): guided calibration flow */ },
|
onClick = onOpenSettings,
|
||||||
)
|
|
||||||
ToolCard(
|
|
||||||
title = "Units & Feedback",
|
|
||||||
subtitle = "Haptics, audio cue, reduced motion — coming with the feature build",
|
|
||||||
onClick = { /* TODO(feature): preferences UI over SettingsRepository */ },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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(
|
private fun detector() = LockDetector(
|
||||||
enterThresholdDegrees = 0.2,
|
enterThresholdDegrees = 0.2,
|
||||||
exitThresholdDegrees = 0.35,
|
exitThresholdDegrees = 0.35,
|
||||||
dwellMillis = 400,
|
dwellMillis = 175,
|
||||||
|
settledDwellMillis = 400,
|
||||||
|
slowRateThresholdDegPerSec = 0.3,
|
||||||
feedbackDebounceMillis = 3_000,
|
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
|
@Test
|
||||||
fun `lock requires dwell time inside the enter threshold`() {
|
fun `easing slowly into center locks after the short dwell`() {
|
||||||
val d = detector()
|
val d = detector()
|
||||||
assertFalse(d.update(0.1, 0).isLocked)
|
assertFalse(d.update(0.1, slow, 0).isLocked)
|
||||||
assertFalse(d.update(0.1, 200).isLocked)
|
assertFalse(d.update(0.1, slow, 174).isLocked)
|
||||||
val result = d.update(0.1, 450)
|
val r = d.update(0.1, slow, 175)
|
||||||
assertTrue(result.isLocked)
|
assertTrue(r.isLocked)
|
||||||
assertTrue(result.fireFeedback)
|
assertTrue(r.fireFeedback)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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()
|
val d = detector()
|
||||||
d.update(0.1, 0)
|
// In the zone the whole time, but moving fast: the dwell never accumulates.
|
||||||
d.update(0.5, 200) // bounced out
|
for (t in 0..2_000 step 20) assertFalse(d.update(0.05, fast, t.toLong()).isLocked)
|
||||||
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)
|
@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
|
@Test
|
||||||
fun `hysteresis holds the lock between enter and exit thresholds`() {
|
fun `hysteresis holds the lock between enter and exit thresholds`() {
|
||||||
val d = detector()
|
val d = detector()
|
||||||
d.update(0.1, 0)
|
d.update(0.1, slow, 0)
|
||||||
assertTrue(d.update(0.1, 500).isLocked)
|
assertTrue(d.update(0.1, slow, 200).isLocked)
|
||||||
// 0.3° is above enter (0.2) but below exit (0.35): still locked.
|
// 0.3° is above enter (0.2) but below exit (0.35): still locked (velocity irrelevant once locked).
|
||||||
assertTrue(d.update(0.3, 600).isLocked)
|
assertTrue(d.update(0.3, fast, 300).isLocked)
|
||||||
// 0.4° exceeds the exit threshold: unlocked.
|
// 0.4° exceeds the exit threshold: unlocked.
|
||||||
assertFalse(d.update(0.4, 700).isLocked)
|
assertFalse(d.update(0.4, slow, 400).isLocked)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `feedback fires once per lock and respects the debounce window`() {
|
fun `feedback fires once per lock and respects the debounce window`() {
|
||||||
val d = detector()
|
val d = detector()
|
||||||
d.update(0.1, 0)
|
d.update(0.1, slow, 0)
|
||||||
assertTrue(d.update(0.1, 500).fireFeedback)
|
assertTrue(d.update(0.1, slow, 200).fireFeedback)
|
||||||
assertFalse(d.update(0.1, 600).fireFeedback) // still locked, no re-fire
|
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.
|
// Rock out and back quickly: re-lock at ~1500ms is inside the 3s debounce.
|
||||||
d.update(0.5, 900)
|
d.update(0.5, fast, 900)
|
||||||
d.update(0.1, 1000)
|
d.update(0.1, slow, 1000)
|
||||||
val relock = d.update(0.1, 1500)
|
val relock = d.update(0.1, slow, 1500)
|
||||||
assertTrue(relock.isLocked)
|
assertTrue(relock.isLocked)
|
||||||
assertFalse(relock.fireFeedback)
|
assertFalse(relock.fireFeedback)
|
||||||
|
|
||||||
// A re-lock after the debounce window fires again.
|
// A re-lock after the debounce window fires again.
|
||||||
d.update(0.5, 2000)
|
d.update(0.5, fast, 4000)
|
||||||
d.update(0.1, 4000)
|
d.update(0.1, slow, 4100)
|
||||||
assertTrue(d.update(0.1, 4500).fireFeedback)
|
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
|
@Test
|
||||||
fun `negative readings lock on magnitude`() {
|
fun `negative readings lock on magnitude`() {
|
||||||
val d = detector()
|
val d = detector()
|
||||||
d.update(-0.1, 0)
|
d.update(-0.1, slow, 0)
|
||||||
assertTrue(d.update(-0.1, 500).isLocked)
|
assertTrue(d.update(-0.1, slow, 200).isLocked)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ class OrientationMathTest {
|
|||||||
assertEquals(4.0, OrientationMath.surfaceTiltMagnitudeDegrees(rolled(4.0)), 1e-9)
|
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
|
@Test
|
||||||
fun `edge mode reads zero when the long edge is horizontal`() {
|
fun `edge mode reads zero when the long edge is horizontal`() {
|
||||||
assertEquals(0.0, OrientationMath.edgeLevelDegrees(onEdge(0.0)), 1e-9)
|
assertEquals(0.0, OrientationMath.edgeLevelDegrees(onEdge(0.0)), 1e-9)
|
||||||
@@ -76,6 +81,35 @@ class OrientationMathTest {
|
|||||||
assertTrue(OrientationMath.percentGrade(-89.6) == Double.NEGATIVE_INFINITY)
|
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
|
@Test
|
||||||
fun `zero vector does not produce NaN`() {
|
fun `zero vector does not produce NaN`() {
|
||||||
val zero = GravitySample(0.0, 0.0, 0.0, 0)
|
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
|
package com.onthelevel.core.sensors
|
||||||
|
|
||||||
|
import com.onthelevel.core.sensors.Vec3Math.Vec3
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import kotlin.math.acos
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.tan
|
||||||
|
|
||||||
class TwoSampleCalibrationTest {
|
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
|
@Test
|
||||||
fun `flip cancels true tilt and isolates device bias`() {
|
fun `flip cancels true tilt and isolates device bias`() {
|
||||||
// Surface truly tilted 0.5°, device bias +0.3°:
|
// 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 reading2 = -0.5 + 0.3 // after 180° rotation about the surface normal
|
||||||
val bias = TwoSampleCalibration.deriveBiasDegrees(reading1, reading2)
|
val bias = TwoSampleCalibration.deriveBiasDegrees(reading1, reading2)
|
||||||
assertEquals(0.3, bias, 1e-9)
|
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
|
@Test
|
||||||
@@ -24,24 +36,142 @@ class TwoSampleCalibrationTest {
|
|||||||
)
|
)
|
||||||
assertEquals(0.3, cal.pitchBiasDegrees, 1e-9)
|
assertEquals(0.3, cal.pitchBiasDegrees, 1e-9)
|
||||||
assertEquals(0.2, cal.rollBiasDegrees, 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
|
@Test
|
||||||
fun `unbiased device on a level surface derives zero bias`() {
|
fun `surface correction zeroes a biased level placement - vector space`() {
|
||||||
val cal = TwoSampleCalibration.deriveEdge(0.0, 0.0)
|
val cal = SurfaceCalibration(pitchBiasDegrees = 0.4, rollBiasDegrees = -0.3)
|
||||||
assertEquals(0.0, cal.levelBiasDegrees, 1e-9)
|
// The reference direction: what a biased device measures on a TRULY level surface.
|
||||||
assertEquals(1.2, cal.applyToLevel(1.2), 1e-9)
|
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
|
@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 surface = TwoSampleCalibration.deriveSurface(1.0, 1.0, 0.0, 0.0)
|
||||||
val edge = EdgeCalibration.NONE
|
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).
|
// 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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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">1°</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="{"$preview":{"width":520,"height":1000},"leveled":{"editor":"boolean","default":false,"tsType":"boolean","section":"State"},"sonarOn":{"editor":"boolean","default":true,"tsType":"boolean","section":"Sonar"},"units":{"editor":"enum","options":["degrees","mm/m","percent"],"default":"degrees","tsType":"string","section":"Readout"}}">
|
||||||
|
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.
Reference in New Issue
Block a user