Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,8 @@ __pycache__/
.matplotlib/
.venv/
.DS_Store
*.blend
*.blend1
# Local debug / probe frames (never products)
output/**/*_smoke_*
output/**/overlay_probe.png
34 changes: 28 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ SOLSYS/
│ ├── solar_system_animator.py # SolarSystemAnimator, renderAllAnimations
│ ├── camera_controller.py # CameraController
│ ├── animation_styles.py # Light/dark styles and timing constants
│ ├── blender_body_sprites.py # Blender spin-loop frames for cinematic Earth/Moon
│ └── scenes/
│ ├── inner_system.py # Fixed 2D inner-system scene
│ ├── zoom_tour.py # Staged 3D Oort → inner zoom
Expand Down Expand Up @@ -84,6 +85,7 @@ SOLSYS/
├── tests/ # Unit tests (stdlib unittest)
│ ├── test_frame_transform.py # Sol ↔ α Cen frame transform
│ ├── test_sol_centauri_cinematic.py # Sol → α Cen cinematic helpers
│ ├── test_blender_body_sprites.py # Blender sprites + cinematic billboards
│ └── test_blender_pipeline.py # Blender export / ingest scaffold
├── solsys/ # Shared libraries (no plotting)
Expand Down Expand Up @@ -111,7 +113,8 @@ SOLSYS/
│ ├── 2d/ # inner_solar_system_{light,dark}.gif
│ ├── 3d/ # solar_system_{light,dark}.gif
│ ├── alpha_centauri/ # ab / system_wide / proxima_planets GIFs
│ ├── sol_centauri/ # sol_centauri_cinematic_{light,dark}.gif
│ ├── sol_centauri/ # classic cinematic GIFs
│ │ └── blender/ # cinematic with textured Earth/Moon
│ ├── barnards_star/ # barnards_star_planets_{light,dark}.gif
│ ├── trappist_1/ # trappist_1_planets_{light,dark}.gif
│ ├── tabbys_star/ # tabbys_star_dust_{light,dark}.gif
Expand Down Expand Up @@ -305,6 +308,7 @@ Or render by product:
.venv/bin/python render.py animate --dimension 3d
.venv/bin/python render.py animate --system alpha_centauri
.venv/bin/python render.py animate --system sol_centauri
.venv/bin/python render.py animate --system sol_centauri --blender-bodies
.venv/bin/python render.py animate --system barnards_star
.venv/bin/python render.py animate --system trappist_1
.venv/bin/python render.py animate --system tabbys_star
Expand All @@ -314,30 +318,48 @@ Or render by product:
.venv/bin/python render.py static --dimension 2d
.venv/bin/python render.py neighborhood --ly 10
.venv/bin/python render.py blender --body Earth
.venv/bin/python render.py blender --body Moon --flyby # Moon close-up GIFs
.venv/bin/python render.py blender --body Earth --load # optional debug ingest
.venv/bin/python render.py blender --body Earth --flyby # light/dark close-up GIFs
.venv/bin/python render.py blender --body Moon --flyby # Moon close-up GIFs
.venv/bin/python render.py blender --body Earth --spin # RGBA day/night loop for cinematic
.venv/bin/python render.py blender --body Moon --spin # Moon spin loop for cinematic
.venv/bin/python render.py blender --pipeline # Earth+Moon spins + blender cinematic
```

Blender close-ups: `render.py blender` exports Keplerian body-scene JSON (`#11`). `render.py blender --flyby` renders a body-centered close-up via EEVEE (`#12`/`#36`/`#41`) to `output/animate/blender/{planets,moons}/<body>/` (`*_flyby_{light,dark}.gif`). `render.py blender --spin` writes a fixed-camera transparent PNG day/night loop (`*_spin_<theme>/frame_*.png`) for cinematic reuse. Texture packs live under `data/textures/bodies/`.

Blender → cinematic workflow:

```bash
.venv/bin/python render.py blender --body Earth --spin
.venv/bin/python render.py blender --body Moon --spin
.venv/bin/python render.py animate --system sol_centauri --blender-bodies
# or one-shot:
.venv/bin/python render.py blender --pipeline
```

Blender close-ups: `render.py blender` exports Keplerian body-scene JSON (`#11`). `render.py blender --flyby` renders a body-centered close-up via EEVEE (`#12`/`#36`/`#41`) to `output/animate/blender/{planets,moons}/<body>/` (`*_flyby_{light,dark}.gif`). Texture packs live under `data/textures/bodies/`.
Sol → Centauri cinematic with `--blender-bodies` (issue #42) keeps the same camera tour but composites those Blender spin frames at Earth/Moon **each `FuncAnimation` frame** (not GIF concat). Outputs go under `output/animate/sol_centauri/blender/` (classic dotted GIFs stay in `output/animate/sol_centauri/`).

Alpha Centauri (issue #1) is one `system_id` covering A, B, and Proxima. Animations render to `output/animate/alpha_centauri/`:

- `alpha_centauri_ab_{light,dark}.gif` — A–B binary close-up (±28 AU)
- `alpha_centauri_system_{light,dark}.gif` — wide triple with Proxima (~8.7 kau)
- `proxima_planets_{light,dark}.gif` — confirmed Proxima planets (via shared `exoplanet_system` animator)

Sol → Alpha Centauri cinematic (issue #10) flies from our solar system to the A–B close-up, zooms back out, then finishes on Proxima and its planets — all in Sol XYZ via `SolCentauriFrameTransform`. Animations render to `output/animate/sol_centauri/`:
Sol → Alpha Centauri cinematic (issue #10) flies from our solar system to the A–B close-up, zooms back out, then finishes on Proxima and its planets — all in Sol XYZ via `SolCentauriFrameTransform`:

- `sol_centauri_cinematic_{light,dark}.gif` — Sol → AB → wide → Proxima planets
- `output/animate/sol_centauri/sol_centauri_cinematic_{light,dark}.gif` — classic scatter-dot bodies
- `output/animate/sol_centauri/blender/sol_centauri_cinematic_blender_{light,dark}.gif` — Blender spin billboards on Earth/Moon (`--blender-bodies` or `blender --pipeline`)

Earth Blender close-up (issues #12 / #36) is a body-centered EEVEE view of Earth with NASA Blue Marble texture (light/dark). Render with `render.py blender --body Earth --flyby` to `output/animate/blender/planets/earth/`:
Earth Blender close-up (issues #12 / #36) is a body-centered EEVEE view of Earth with NASA Blue Marble texture (light/dark). Render with `render.py blender --body Earth --flyby` / `--spin` to `output/animate/blender/planets/earth/`:

- `earth_flyby_{light,dark}.gif` — textured sphere, clouds, thin atmosphere limb, elevated turntable camera + spin
- `earth_spin_{light,dark}/frame_*.png` — fixed-camera RGBA day/night loop for the cinematic (`--spin`)

Moon Blender close-up (issue #41) uses the shared pack path with an LRO LROC color map (airless — no atmosphere/clouds) under `output/animate/blender/moons/moon/`:

- `moon_flyby_{light,dark}.gif` — textured lunar sphere, elevated turntable camera + spin
- `moon_spin_{light,dark}/frame_*.png` — fixed-camera RGBA spin loop for the cinematic (`--spin`)

Barnard's Star (issue #15) is a nearby M dwarf (~6 ly) with four confirmed sub-Earth planets. Animations render via `exoplanet_system` to `output/animate/barnards_star/`:

Expand Down
118 changes: 118 additions & 0 deletions animate/blender_body_sprites.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Load Blender spin-loop RGBA frames for the Sol→Centauri cinematic.

Assets come from ``render.py blender --spin`` (fixed camera, full rotation,
transparent PNGs). The cinematic indexes into that loop each ``update()`` —
rendered together, not GIF-concatenated afterward.

Earth: free spin by animation frame.
Moon: tidally locked — spin index follows orbital mean anomaly (one turn / orbit).
"""

from __future__ import annotations

from pathlib import Path

import numpy as np
from PIL import Image

from animate.scenes.blender.flyby_scene import spinFramesDirectory

DEFAULT_DISPLAY_RESOLUTION = 384


def loadSpinLoopFrames(
bodyName: str,
theme: str,
*,
outputDirectory: Path | str = 'output/animate/blender',
) -> list[np.ndarray] | None:
"""Load persistent ``*_spin_<theme>/frame_*.png`` as float RGBA arrays."""
directory = spinFramesDirectory(bodyName, theme, outputDirectory=outputDirectory)
paths = sorted(directory.glob('frame_*.png'))
if not paths:
return None

frames: list[np.ndarray] = []
for path in paths:
with Image.open(path) as image:
frames.append(np.asarray(image.convert('RGBA'), dtype=np.float32) / 255.0)
return frames


def _resizeRgba(rgba: np.ndarray, size: int) -> np.ndarray:
if rgba.shape[0] == size and rgba.shape[1] == size:
return rgba
image = Image.fromarray((np.clip(rgba, 0.0, 1.0) * 255.0).astype(np.uint8), mode='RGBA')
image = image.resize((size, size), Image.Resampling.LANCZOS)
return np.asarray(image, dtype=np.float32) / 255.0


def tidalLockFrameIndex(orbitalPhaseRad: float, frameCount: int) -> int:
"""Map orbital mean anomaly → spin-loop index (synchronous rotation)."""
return int(np.floor(tidalLockFramePosition(orbitalPhaseRad, frameCount))) % frameCount


def tidalLockFramePosition(orbitalPhaseRad: float, frameCount: int) -> float:
"""Fractional spin-loop position for smooth tidally locked blending."""
if frameCount <= 0:
raise ValueError('frameCount must be positive')
turns = float(orbitalPhaseRad) / (2.0 * np.pi)
return turns * frameCount


def _blendSpinFrames(frames: list[np.ndarray], position: float) -> np.ndarray:
"""Linear blend between neighboring loop frames (seamless at wrap)."""
count = len(frames)
wrapped = position % count
index0 = int(np.floor(wrapped)) % count
index1 = (index0 + 1) % count
blend = float(wrapped - np.floor(wrapped))
if blend <= 1e-6:
return frames[index0]
if blend >= 1.0 - 1e-6:
return frames[index1]
return frames[index0] * (1.0 - blend) + frames[index1] * blend


class BlenderBodySpriteAtlas:
"""Theme-scoped Earth/Moon Blender spin loops for in-animation billboards."""

def __init__(self, theme: str, *, outputDirectory: Path | str = 'output/animate/blender'):
self.theme = theme
self.earth = loadSpinLoopFrames('Earth', theme, outputDirectory=outputDirectory)
self.moon = loadSpinLoopFrames('Moon', theme, outputDirectory=outputDirectory)

@property
def hasEarth(self) -> bool:
return bool(self.earth)

@property
def hasMoon(self) -> bool:
return bool(self.moon)

def earthFrame(
self, frame: int, *, resolution: int = DEFAULT_DISPLAY_RESOLUTION
) -> np.ndarray | None:
if not self.earth:
return None
# Fractional position so Earth also eases between PNG samples.
position = float(frame) % len(self.earth)
rgba = _blendSpinFrames(self.earth, position)
return _resizeRgba(rgba, resolution)

def moonFrame(
self,
frame: int,
*,
orbitalPhaseRad: float | None = None,
resolution: int = DEFAULT_DISPLAY_RESOLUTION,
) -> np.ndarray | None:
"""Moon disk. Prefer ``orbitalPhaseRad`` for tidal lock; ``frame`` is fallback."""
if not self.moon:
return None
if orbitalPhaseRad is None:
position = float(frame) % len(self.moon)
else:
position = tidalLockFramePosition(orbitalPhaseRad, len(self.moon))
rgba = _blendSpinFrames(self.moon, position)
return _resizeRgba(rgba, resolution)
25 changes: 25 additions & 0 deletions animate/scenes/blender/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,31 @@ Earth uses NASA Blue Marble color + cloud maps under `data/textures/bodies/earth
.venv/bin/python render.py blender --body Moon --flyby
```

Fixed-camera day/night spin loops (RGBA PNGs for cinematic reuse):

```bash
.venv/bin/python render.py blender --body Earth --spin
.venv/bin/python render.py blender --body Moon --spin
```

End-to-end (Earth + Moon spins, then Sol→Centauri cinematic compositing those frames):

```bash
.venv/bin/python render.py blender --pipeline
```

Products land in:

```text
output/animate/blender/{planets,moons}/<body>/
*_flyby_{light,dark}.gif # gallery close-ups
*_spin_{light,dark}/frame_*.png # cinematic spin loops (transparent)
*_spin_{light,dark}.gif # preview of the spin loop
output/animate/sol_centauri/blender/ # cinematic with Earth/Moon spin billboards
```

Local debug leftovers (`.blend`, `*_smoke_*`, `overlay_probe.png`) are gitignored / not products.

### Debug ingest (orbit view, not the flyby)

```bash
Expand Down
31 changes: 31 additions & 0 deletions animate/scenes/blender/flyby_camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,34 @@ def buildFlybyCameraPath(
)
)
return tuple(samples)


def buildSpinCameraPath(
displayRadiusAu: float,
frameCount: int = 48,
*,
elevationDeg: float = 18.0,
azimuthDeg: float = 35.0,
) -> tuple[FlybyCameraSample, ...]:
"""Fixed camera + full 360° body spin (seamless loop for cinematic reuse)."""
if frameCount < 2:
raise ValueError('frameCount must be >= 2')
cameraAu = flybyCameraLocation(
0,
2,
displayRadiusAu,
elevationDeg=elevationDeg,
azimuthStartDeg=azimuthDeg,
azimuthSweepDeg=0.0,
)
samples: list[FlybyCameraSample] = []
for frame in range(frameCount):
# Omit 360° on the last frame so index 0 and N line up when looping.
samples.append(
FlybyCameraSample(
frame=frame,
cameraAu=cameraAu,
bodyRotationDeg=(frame / frameCount) * 360.0,
)
)
return tuple(samples)
Loading
Loading