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
24 changes: 17 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ Built with `numpy`, `pandas`, and `matplotlib`. Orbital elements include ellipti

| Layer | Path | Role |
|-------|------|------|
| CLI | `render.py` | Single entry point: `animate` / `static` / `neighborhood` / `all` |
| Main product | `animate/` | GIF animations (2D inner system + 3D zoom tour); Blender close-ups planned under `animate/scenes/blender/` |
| CLI | `render.py` | Single entry point: `animate` / `static` / `neighborhood` / `blender` / `all` |
| Main product | `animate/` | GIF animations (2D inner system + 3D zoom tour); Blender close-ups scaffold under `animate/scenes/blender/` |
| Side product | `static/` | Multi-zoom JPGs light/dark (2D top-down / 3D) and neighborhood star map |
| Shared physics | `solsys/physics/` | Constants, orbits, catalogs, belt generators, view registry |
| Shared motion | `solsys/motion/` | Animated asteroid / Hilda / Trojan / Kuiper / Oort populations |
Expand Down Expand Up @@ -61,8 +61,12 @@ SOLSYS/
│ ├── trappist_1.py # TRAPPIST-1 planets via exoplanet_system
│ ├── tabbys_star.py # Tabby's Star dust-cloud dimming schematic
│ ├── interstellar_objects.py # 1I/2I/3I hyperbolic passages (side + oblique)
│ └── blender/ # Future planet close-ups
│ └── README.md
│ └── blender/ # Blender close-up pipeline (catalog → JSON → bpy)
│ ├── README.md # Pipeline docs + CLI examples
│ ├── body_scene.py # Versioned body-scene schema + PlanetCatalog build
│ ├── export_body.py # Write output/animate/blender/*_body_scene.json
│ ├── load_body.py # Blender ingest (stdlib + optional bpy)
│ └── flyby_scene.py # Extension point for first flyby (#12)
├── static/ # SIDE PRODUCT — still images
│ ├── __init__.py
Expand All @@ -73,7 +77,8 @@ SOLSYS/
├── tests/ # Unit tests (stdlib unittest)
│ ├── test_frame_transform.py # Sol ↔ α Cen frame transform
│ └── test_sol_centauri_cinematic.py # Sol → α Cen cinematic helpers
│ ├── test_sol_centauri_cinematic.py # Sol → α Cen cinematic helpers
│ └── test_blender_pipeline.py # Blender export / ingest scaffold
├── solsys/ # Shared libraries (no plotting)
│ ├── physics/
Expand Down Expand Up @@ -104,7 +109,8 @@ SOLSYS/
│ ├── barnards_star/ # barnards_star_planets_{light,dark}.gif
│ ├── trappist_1/ # trappist_1_planets_{light,dark}.gif
│ ├── tabbys_star/ # tabbys_star_dust_{light,dark}.gif
│ └── interstellar_objects/ # {oumuamua,borisov,atlas}_{side,oblique}_{light,dark}.gif
│ ├── interstellar_objects/ # {oumuamua,borisov,atlas}_{side,oblique}_{light,dark}.gif
│ └── blender/ # *_body_scene.json (Blender ingest; flybys #12)
├── 2d/ # Static top-down zoom JPGs (*_{light,dark}.jpg)
├── 3d/ # Static perspective zoom JPGs (*_{light,dark}.jpg)
└── neighborhood/ # interstellar_neighborhood_*ly.jpg
Expand Down Expand Up @@ -287,8 +293,12 @@ Or render by product:
.venv/bin/python render.py animate --system oumuamua
.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 Earth --load # optional; needs blender on PATH
```

Blender close-ups (issue #11 scaffold): `render.py blender` exports Keplerian keyframes for one `PlanetCatalog` body to `output/animate/blender/<body>_body_scene.json`. Ingest with `animate/scenes/blender/load_body.py` (host dry-run or `blender --python …`). Polished light/dark flybys land in issue #12 via `flyby_scene.renderPlanetFlyby`.

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)
Expand Down Expand Up @@ -332,4 +342,4 @@ CLI: `render.py animate --system interstellar` (all) or `--object oumuamua|boris
## Roadmap

- Additional star systems via `SystemCatalog` / `data/systems.csv` (`exoplanet_system.py` for planet disks; dedicated scenes for dust / other phenomena)
- Blender-based planet close-ups / flybys in `animate/scenes/blender/`
- First Blender planet flyby (#12) on top of the `animate/scenes/blender/` catalog → JSON → bpy scaffold (#11)
56 changes: 55 additions & 1 deletion animate/scenes/blender/README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,57 @@
# Blender planet close-ups

Future home for Blender-based planet flybys / zoom-ins that plug into the SOLSYS animation product.
Pipeline for Blender-based planet flybys / zoom-ins that plug into the SOLSYS animation product.

## Chosen export path

**Catalog orbit state → JSON keyframes → Blender ingest**

| Stage | Module | Runs in | Role |
|-------|--------|---------|------|
| 1. Build | `body_scene.py` | SOLSYS venv | Load one `PlanetCatalog` body; sample Keplerian positions (`OrbitCalculator.ellipticalPosition`) into a versioned JSON scene |
| 2. Export | `export_body.py` | SOLSYS venv | Write `output/animate/blender/<body>_body_scene.json` |
| 3. Ingest | `load_body.py` | Blender (`bpy`) or host dry-run | Create UV sphere + material + location keyframes from JSON (**stdlib-only** so Blender’s Python can run it) |
| 4. Flyby | `flyby_scene.py` | SOLSYS → Blender | Extension point for issue #12 (camera path, light/dark renders) |

Why JSON keyframes (not a live `bpy` import of `solsys`)?

- Blender ships its own Python; it should not need the SOLSYS venv.
- CI can test export + JSON validation without Blender.
- Flyby scenes (#12) can re-export and re-ingest the same schema.

Schema id: `solsys.blender_body_scene/v1`

## CLI

Export Earth (default) for Blender:

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

Validate JSON without Blender (dry-run):

```bash
.venv/bin/python animate/scenes/blender/load_body.py output/animate/blender/earth_body_scene.json
```

Ingest inside Blender:

```bash
blender --background --python animate/scenes/blender/load_body.py -- \
output/animate/blender/earth_body_scene.json
```

Or from the CLI (requires `blender` on `PATH`):

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

## Extension point (#12)

`flyby_scene.renderPlanetFlyby` is the stable call site for the first polished flyby:

- prepare export (already works via `preparePlanetFlybyExport`)
- add camera path / shading / light+dark output under `output/animate/blender/`
- link gallery entries in the root README
30 changes: 30 additions & 0 deletions animate/scenes/blender/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""Blender close-up pipeline: catalog export → Blender ingest → flyby scenes."""

from __future__ import annotations

from animate.scenes.blender.body_scene import (
SCHEMA_ID,
BodyKeyframe,
BodyScene,
BodySceneBody,
buildPlanetBodyScene,
loadBodyScene,
)
from animate.scenes.blender.export_body import DEFAULT_OUTPUT_DIRECTORY, exportPlanetBodyScene
from animate.scenes.blender.flyby_scene import (
FLYBY_EXTENSION_POINT,
preparePlanetFlybyExport,
)

__all__ = [
'SCHEMA_ID',
'BodyKeyframe',
'BodyScene',
'BodySceneBody',
'DEFAULT_OUTPUT_DIRECTORY',
'FLYBY_EXTENSION_POINT',
'buildPlanetBodyScene',
'exportPlanetBodyScene',
'loadBodyScene',
'preparePlanetFlybyExport',
]
195 changes: 195 additions & 0 deletions animate/scenes/blender/body_scene.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
"""Catalog → Blender body-scene JSON (host-side, no bpy)."""

from __future__ import annotations

import json
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any

import numpy as np
from solsys.physics.astronomical_constants import AstronomicalConstants
from solsys.physics.catalogs.planet_catalog import PlanetCatalog, PlanetOrbit
from solsys.physics.orbit_calculator import OrbitCalculator

SCHEMA_ID = 'solsys.blender_body_scene/v1'

# Matplotlib / catalog color names → RGBA for Blender materials.
_COLOR_RGBA: dict[str, tuple[float, float, float, float]] = {
'gray': (0.55, 0.55, 0.55, 1.0),
'yellow': (0.95, 0.85, 0.2, 1.0),
'blue': (0.25, 0.45, 0.95, 1.0),
'red': (0.9, 0.25, 0.2, 1.0),
'orange': (0.95, 0.55, 0.15, 1.0),
'gold': (0.9, 0.75, 0.25, 1.0),
'lightblue': (0.55, 0.8, 0.95, 1.0),
'brown': (0.55, 0.35, 0.2, 1.0),
}

# Physical planet radii are ~1e-5 AU; exaggerate so a UV sphere is visible near the body.
_DISPLAY_RADIUS_EXAGGERATION = 800.0
_MIN_DISPLAY_RADIUS_AU = 0.002


@dataclass(frozen=True)
class BodySceneBody:
name: str
kind: str
systemId: str
semiMajorAxisAu: float
eccentricity: float
inclinationDeg: float
orbitalPeriodDays: float
diameterKm: int
color: str
colorRgba: tuple[float, float, float, float]
displayRadiusAu: float


@dataclass(frozen=True)
class BodyKeyframe:
frame: int
day: float
positionAu: tuple[float, float, float]


@dataclass(frozen=True)
class BodyScene:
schema: str
body: BodySceneBody
keyframes: tuple[BodyKeyframe, ...]
cameraHintDistanceAu: float

def toDict(self) -> dict[str, Any]:
payload = asdict(self)
# Nested tuples → lists for JSON.
payload['body']['colorRgba'] = list(self.body.colorRgba)
payload['keyframes'] = [
{
'frame': keyframe.frame,
'day': keyframe.day,
'positionAu': list(keyframe.positionAu),
}
for keyframe in self.keyframes
]
return payload

def toJson(self, *, indent: int = 2) -> str:
return json.dumps(self.toDict(), indent=indent) + '\n'

@classmethod
def fromDict(cls, payload: dict[str, Any]) -> BodyScene:
if payload.get('schema') != SCHEMA_ID:
raise ValueError(
f'Unsupported body scene schema: {payload.get("schema")!r} (expected {SCHEMA_ID!r})'
)
bodyPayload = payload['body']
body = BodySceneBody(
name=str(bodyPayload['name']),
kind=str(bodyPayload['kind']),
systemId=str(bodyPayload['systemId']),
semiMajorAxisAu=float(bodyPayload['semiMajorAxisAu']),
eccentricity=float(bodyPayload['eccentricity']),
inclinationDeg=float(bodyPayload['inclinationDeg']),
orbitalPeriodDays=float(bodyPayload['orbitalPeriodDays']),
diameterKm=int(bodyPayload['diameterKm']),
color=str(bodyPayload['color']),
colorRgba=tuple(float(channel) for channel in bodyPayload['colorRgba']), # type: ignore[arg-type]
displayRadiusAu=float(bodyPayload['displayRadiusAu']),
)
keyframes = tuple(
BodyKeyframe(
frame=int(item['frame']),
day=float(item['day']),
positionAu=(
float(item['positionAu'][0]),
float(item['positionAu'][1]),
float(item['positionAu'][2]),
),
)
for item in payload['keyframes']
)
return cls(
schema=str(payload['schema']),
body=body,
keyframes=keyframes,
cameraHintDistanceAu=float(payload['cameraHintDistanceAu']),
)


def loadBodyScene(path: Path | str) -> BodyScene:
payload = json.loads(Path(path).read_text(encoding='utf-8'))
return BodyScene.fromDict(payload)


def displayRadiusAu(diameterKm: int, constants: AstronomicalConstants) -> float:
physicalRadiusAu = (diameterKm / 2.0) / constants.auToKm
return max(physicalRadiusAu * _DISPLAY_RADIUS_EXAGGERATION, _MIN_DISPLAY_RADIUS_AU)


def colorRgbaForName(colorName: str) -> tuple[float, float, float, float]:
return _COLOR_RGBA.get(colorName.lower(), (0.7, 0.7, 0.7, 1.0))


def _bodyFromPlanet(planet: PlanetOrbit, constants: AstronomicalConstants) -> BodySceneBody:
return BodySceneBody(
name=planet.name,
kind='planet',
systemId='sol',
semiMajorAxisAu=planet.semiMajorAxisAu,
eccentricity=planet.eccentricity,
inclinationDeg=planet.inclinationDeg,
orbitalPeriodDays=planet.orbitalPeriodDays,
diameterKm=planet.diameterKm,
color=planet.color,
colorRgba=colorRgbaForName(planet.color),
displayRadiusAu=displayRadiusAu(planet.diameterKm, constants),
)


def buildPlanetBodyScene(
planetName: str = 'Earth',
*,
frameCount: int = 120,
constants: AstronomicalConstants | None = None,
) -> BodyScene:
"""Build a Blender-ingestible scene for one Sol planet from ``PlanetCatalog``."""
if frameCount < 2:
raise ValueError('frameCount must be >= 2')

constants = constants or AstronomicalConstants()
catalog = PlanetCatalog(constants)
try:
planet = catalog.planets[planetName]
except KeyError as error:
known = ', '.join(sorted(catalog.planets))
raise ValueError(f'Unknown planet {planetName!r}. Known: {known}') from error

body = _bodyFromPlanet(planet, constants)
calculator = OrbitCalculator()
trueAnomalies = np.linspace(0.0, 2.0 * np.pi, frameCount, endpoint=False)
daysPerFrame = planet.orbitalPeriodDays / frameCount

keyframes: list[BodyKeyframe] = []
for frame, trueAnomaly in enumerate(trueAnomalies):
positionX, positionY, positionZ = calculator.ellipticalPosition(
planet.semiMajorAxisAu,
planet.eccentricity,
planet.inclinationDeg,
float(trueAnomaly),
)
keyframes.append(
BodyKeyframe(
frame=frame,
day=frame * daysPerFrame,
positionAu=(float(positionX), float(positionY), float(positionZ)),
)
)

cameraHintDistanceAu = max(body.displayRadiusAu * 8.0, 0.05)
return BodyScene(
schema=SCHEMA_ID,
body=body,
keyframes=tuple(keyframes),
cameraHintDistanceAu=cameraHintDistanceAu,
)
33 changes: 33 additions & 0 deletions animate/scenes/blender/export_body.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Write Blender body-scene JSON under output/animate/blender/."""

from __future__ import annotations

from pathlib import Path

from animate.scenes.blender.body_scene import BodyScene, buildPlanetBodyScene

DEFAULT_OUTPUT_DIRECTORY = Path('output/animate/blender')


def exportPlanetBodyScene(
planetName: str = 'Earth',
*,
frameCount: int = 120,
outputDirectory: Path | str = DEFAULT_OUTPUT_DIRECTORY,
) -> Path:
"""Export one catalog planet to JSON for Blender ingest. Returns the written path."""
scene = buildPlanetBodyScene(planetName, frameCount=frameCount)
return writeBodyScene(scene, outputDirectory=outputDirectory)


def writeBodyScene(
scene: BodyScene,
*,
outputDirectory: Path | str = DEFAULT_OUTPUT_DIRECTORY,
) -> Path:
directory = Path(outputDirectory)
directory.mkdir(parents=True, exist_ok=True)
stem = scene.body.name.lower().replace(' ', '_')
outputPath = directory / f'{stem}_body_scene.json'
outputPath.write_text(scene.toJson(), encoding='utf-8')
return outputPath
Loading
Loading