feat(sim+player): scale-ring navigation (endless encoder) + cosmos/abyss scales
Add scale-ring navigation to the simulator per the scales-library design §3: an endless rotary-encoder control (relative, vs the absolute 0-4 experience knobs) walks a closed ring of neutral "scales of nature" clips, with placeholder AI zoom/warp transitions between adjacent scales and a small->large wrap (the infinite-zoom payoff). - player/ring.py (new, canonical pure logic): ScaleRing + advance_ring; one transition clip per edge, played forward zooming inward / reversed outward; modulo wrap both ways; chained steps for a multi-detent spin. Mirrors player/content.py conventions (frozen dataclasses, pure functions). - simulator: load_ring + ring_to_dict/ring_move_to_dict; GET /api/ring and POST /api/ring/advance (Python owns step/wrap/transition math; the browser only plays the returned transition(s) then settles on the target scale). - frontend: endless-encoder control (zoom in/out buttons + stage scroll), current-scale readout, multi-clip active-scale selection, transition playback. - media: two cheap true-PD scales so the ring is demonstrable -- cosmos (NASA/Hubble) + abyss (NOAA Ocean Exploration), provenance recorded in the manifest, bytes generated as labelled placeholders by setup_scales_media.py. The expensive multi-strength flow-stabilized Right re-bake is DEFERRED (new scales carry a raw base only) until the ring is liked; Pi renderer + serial/firmware remain deferred (simulator-first). - docs: ROADMAP slice-3 done; USER_GUIDE scale-ring gesture; plan doc. Verified: pytest 215 passed / 2 skipped (+22 new); sim boots, /api/ring + /api/ring/advance correct incl. wrap, media served, and a headless-Chrome CDP drive walked cosmos -> forest -> abyss -> (wrap) -> cosmos with the active clip reloading correctly. Spec: docs/superpowers/specs/2026-06-07-scales-library-and-right-axis-pipeline-design.md (§3) Plan: docs/superpowers/plans/2026-06-07-scale-ring-navigation.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+152
@@ -0,0 +1,152 @@
|
||||
"""Scale-ring navigation: the endless-encoder closed loop of nature scales (§3).
|
||||
|
||||
Design: docs/superpowers/specs/2026-06-07-scales-library-and-right-axis-pipeline-
|
||||
design.md §3 (scale navigation & zoom transitions).
|
||||
|
||||
The scales-of-nature library is navigated as a CLOSED RING, not a line. A
|
||||
dedicated endless rotary encoder — relative, vs the absolute 0..4 experience
|
||||
knobs — walks the ring one step per detent; diving past the smallest scale wraps
|
||||
around to the largest (the infinite-zoom payoff). Between each adjacent pair of
|
||||
scales a short pre-baked AI zoom/warp transition plays.
|
||||
|
||||
This is the canonical navigation engine (Python-canonical, shared with the Pi
|
||||
player/firmware); the simulator browser only renders what `advance_ring` returns.
|
||||
|
||||
Ordering convention:
|
||||
- index 0 = the LARGEST scale (cosmos); increasing index zooms INWARD toward
|
||||
the smallest.
|
||||
- +1 detent zooms in, -1 zooms out; BOTH wrap (past the smallest wraps to the
|
||||
largest, and vice versa).
|
||||
- edge i connects scales[i] -> scales[(i+1) % N]; the last edge is the
|
||||
small->large wrap seam. One transition clip per edge plays FORWARD when
|
||||
zooming inward and REVERSED when zooming outward, so N scales need only N
|
||||
transition clips.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class RingError(ValueError):
|
||||
"""Raised when a ScaleRing is structurally invalid."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Scale:
|
||||
"""A node on the ring: a scale of nature and the base clip it plays."""
|
||||
|
||||
id: str
|
||||
clip_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Transition:
|
||||
"""A pre-baked zoom/warp morph along ONE ring edge.
|
||||
|
||||
`file` plays FORWARD when zooming inward (scales[i] -> scales[i+1] in ring
|
||||
order); the renderer reverses it for the outward direction, so one clip
|
||||
covers both directions of an edge.
|
||||
"""
|
||||
|
||||
file: str
|
||||
model: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScaleRing:
|
||||
"""A closed ring of scales joined by per-edge transitions.
|
||||
|
||||
Invariant: a ring of N>=2 scales has exactly N transitions (one per edge,
|
||||
including the small->large wrap seam). A degenerate single-scale ring has no
|
||||
edges. An empty ring is rejected.
|
||||
"""
|
||||
|
||||
scales: tuple[Scale, ...]
|
||||
transitions: tuple[Transition, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
n = len(self.scales)
|
||||
if n == 0:
|
||||
raise RingError("a ScaleRing needs at least one scale")
|
||||
expected = 0 if n == 1 else n
|
||||
if len(self.transitions) != expected:
|
||||
raise RingError(
|
||||
f"a {n}-scale ring needs {expected} transitions, "
|
||||
f"got {len(self.transitions)}"
|
||||
)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.scales)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TransitionStep:
|
||||
"""One transition to play during a move: which edge clip, in which direction,
|
||||
and the scale index it lands on."""
|
||||
|
||||
edge: int
|
||||
reversed: bool
|
||||
file: str
|
||||
to_index: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RingMove:
|
||||
"""The result of advancing the encoder: where you end up and the ordered
|
||||
transitions to play getting there (chained for a multi-detent spin)."""
|
||||
|
||||
from_index: int
|
||||
to_index: int
|
||||
steps: tuple[TransitionStep, ...]
|
||||
wrapped: bool
|
||||
|
||||
|
||||
def scale_at(ring: ScaleRing, index: int) -> Scale:
|
||||
"""The scale at `index`, taken modulo the ring length (wraps both ways)."""
|
||||
return ring.scales[index % len(ring)]
|
||||
|
||||
|
||||
def advance_ring(ring: ScaleRing, from_index: int, delta: int) -> RingMove:
|
||||
"""Walk the endless encoder `delta` detents from `from_index` (signed).
|
||||
|
||||
Returns a `RingMove` with the landing index and the ordered `TransitionStep`s
|
||||
to play. Inward steps (+) play edge i forward; outward steps (-) play the
|
||||
crossed edge reversed. `wrapped` is True if any step crossed the small<->large
|
||||
seam. A degenerate single-scale ring (or delta 0) is a no-op.
|
||||
"""
|
||||
n = len(ring)
|
||||
start = from_index % n
|
||||
if n == 1 or delta == 0:
|
||||
return RingMove(from_index=start, to_index=start, steps=(), wrapped=False)
|
||||
|
||||
direction = 1 if delta > 0 else -1
|
||||
steps: list[TransitionStep] = []
|
||||
wrapped = False
|
||||
index = start
|
||||
for _ in range(abs(delta)):
|
||||
if direction > 0:
|
||||
# zoom inward: edge `index` forward, land at index+1 (mod n)
|
||||
edge = index
|
||||
nxt = (index + 1) % n
|
||||
reversed_ = False
|
||||
else:
|
||||
# zoom outward: cross edge `index-1` reversed, land at index-1 (mod n)
|
||||
edge = (index - 1) % n
|
||||
nxt = (index - 1) % n
|
||||
reversed_ = True
|
||||
if edge == n - 1:
|
||||
wrapped = True
|
||||
steps.append(
|
||||
TransitionStep(
|
||||
edge=edge,
|
||||
reversed=reversed_,
|
||||
file=ring.transitions[edge].file,
|
||||
to_index=nxt,
|
||||
)
|
||||
)
|
||||
index = nxt
|
||||
|
||||
return RingMove(
|
||||
from_index=start, to_index=index, steps=tuple(steps), wrapped=wrapped
|
||||
)
|
||||
Reference in New Issue
Block a user