From 4415b4221d56fedadffbb29ffdbdf7248b6c32ba Mon Sep 17 00:00:00 2001 From: ThomasAFink Date: Fri, 7 Aug 2026 22:23:27 +0200 Subject: [PATCH] feat(SOLSYS-11): scaffold Blender close-up catalog export pipeline Export PlanetCatalog bodies as versioned JSON keyframes, add a stdlib Blender ingest stub, and wire render.py blender plus the #12 flyby extension point. --- README.md | 24 +++- animate/scenes/blender/README.md | 56 +++++++- animate/scenes/blender/__init__.py | 30 ++++ animate/scenes/blender/body_scene.py | 195 ++++++++++++++++++++++++++ animate/scenes/blender/export_body.py | 33 +++++ animate/scenes/blender/flyby_scene.py | 55 ++++++++ animate/scenes/blender/load_body.py | 144 +++++++++++++++++++ render.py | 74 ++++++++++ tests/test_blender_pipeline.py | 99 +++++++++++++ 9 files changed, 702 insertions(+), 8 deletions(-) create mode 100644 animate/scenes/blender/__init__.py create mode 100644 animate/scenes/blender/body_scene.py create mode 100644 animate/scenes/blender/export_body.py create mode 100644 animate/scenes/blender/flyby_scene.py create mode 100644 animate/scenes/blender/load_body.py create mode 100644 tests/test_blender_pipeline.py diff --git a/README.md b/README.md index 66d7c3a..a051120 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 @@ -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/ @@ -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 @@ -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_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) @@ -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) diff --git a/animate/scenes/blender/README.md b/animate/scenes/blender/README.md index 06df5fc..dc86369 100644 --- a/animate/scenes/blender/README.md +++ b/animate/scenes/blender/README.md @@ -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_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 diff --git a/animate/scenes/blender/__init__.py b/animate/scenes/blender/__init__.py new file mode 100644 index 0000000..bde746a --- /dev/null +++ b/animate/scenes/blender/__init__.py @@ -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', +] diff --git a/animate/scenes/blender/body_scene.py b/animate/scenes/blender/body_scene.py new file mode 100644 index 0000000..296f670 --- /dev/null +++ b/animate/scenes/blender/body_scene.py @@ -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, + ) diff --git a/animate/scenes/blender/export_body.py b/animate/scenes/blender/export_body.py new file mode 100644 index 0000000..15141dc --- /dev/null +++ b/animate/scenes/blender/export_body.py @@ -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 diff --git a/animate/scenes/blender/flyby_scene.py b/animate/scenes/blender/flyby_scene.py new file mode 100644 index 0000000..b26f1cb --- /dev/null +++ b/animate/scenes/blender/flyby_scene.py @@ -0,0 +1,55 @@ +"""Extension point for the first Blender planet flyby (issue #12). + +Pipeline stage after catalog export / Blender ingest: + +1. ``preparePlanetFlybyExport`` — write body-scene JSON (done here). +2. Blender camera path + shading + light/dark renders — land in #12. +3. Wire polished outputs into the README gallery. +""" + +from __future__ import annotations + +from pathlib import Path + +from animate.scenes.blender.export_body import DEFAULT_OUTPUT_DIRECTORY, exportPlanetBodyScene + +FLYBY_EXTENSION_POINT = 'animate.scenes.blender.flyby_scene.renderPlanetFlyby' + + +def preparePlanetFlybyExport( + planetName: str = 'Earth', + *, + frameCount: int = 120, + outputDirectory: Path | str = DEFAULT_OUTPUT_DIRECTORY, +) -> Path: + """Export catalog state for a future flyby render. Does not invoke Blender.""" + return exportPlanetBodyScene( + planetName, + frameCount=frameCount, + outputDirectory=outputDirectory, + ) + + +def renderPlanetFlyby( + planetName: str = 'Earth', + *, + theme: str = 'dark', + frameCount: int = 120, + outputDirectory: Path | str = DEFAULT_OUTPUT_DIRECTORY, +) -> Path: + """Reserved for issue #12 — first polished light/dark flyby. + + Currently only prepares the body-scene export so the CLI hook and gallery + wiring have a stable call site. + """ + _ = theme # light/dark renders land with the flyby implementation. + exportPath = preparePlanetFlybyExport( + planetName, + frameCount=frameCount, + outputDirectory=outputDirectory, + ) + raise NotImplementedError( + f'{FLYBY_EXTENSION_POINT} is the issue #12 extension point. ' + f'Body scene prepared at {exportPath}. ' + 'Implement Blender camera path + light/dark renders next.' + ) diff --git a/animate/scenes/blender/load_body.py b/animate/scenes/blender/load_body.py new file mode 100644 index 0000000..b25bd50 --- /dev/null +++ b/animate/scenes/blender/load_body.py @@ -0,0 +1,144 @@ +"""Blender ingest for SOLSYS body-scene JSON. + +Runs two ways: + +1. Host dry-run (no bpy):: + + python animate/scenes/blender/load_body.py output/animate/blender/earth_body_scene.json + +2. Inside Blender:: + + blender --background --python animate/scenes/blender/load_body.py -- \\ + output/animate/blender/earth_body_scene.json + +Keep this module stdlib-only so Blender's bundled Python can import it. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +SCHEMA_ID = 'solsys.blender_body_scene/v1' + + +def _argvAfterDoubleDash(argv: list[str]) -> list[str]: + if '--' in argv: + return argv[argv.index('--') + 1 :] + # Host dry-run: script path is argv[0]. + return argv[1:] + + +def loadPayload(path: Path) -> dict[str, Any]: + payload = json.loads(path.read_text(encoding='utf-8')) + if payload.get('schema') != SCHEMA_ID: + raise ValueError( + f'Unsupported body scene schema: {payload.get("schema")!r} (expected {SCHEMA_ID!r})' + ) + if 'body' not in payload or 'keyframes' not in payload: + raise ValueError('Body scene JSON must include body and keyframes') + if not payload['keyframes']: + raise ValueError('Body scene JSON must include at least one keyframe') + return payload + + +def summarizePayload(payload: dict[str, Any]) -> str: + body = payload['body'] + keyframes = payload['keyframes'] + return ( + f'Loaded {body["name"]} ({body["kind"]}, system={body["systemId"]}) ' + f'with {len(keyframes)} keyframes; ' + f'displayRadiusAu={body["displayRadiusAu"]:.4f}; ' + f'cameraHintDistanceAu={payload["cameraHintDistanceAu"]:.4f}' + ) + + +def applyPayloadInBlender(payload: dict[str, Any]) -> str: + """Create a UV sphere, material, and location keyframes from the payload.""" + import bpy # type: ignore[import-not-found] + + body = payload['body'] + keyframes = payload['keyframes'] + name = str(body['name']) + radius = float(body['displayRadiusAu']) + color = [float(channel) for channel in body['colorRgba']] + + # Fresh scene for the stub ingest. + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.ops.mesh.primitive_uv_sphere_add(radius=radius, location=(0.0, 0.0, 0.0)) + obj = bpy.context.active_object + assert obj is not None + obj.name = name + + material = bpy.data.materials.new(name=f'{name}Material') + # Blender 5+ materials are node-based by default; avoid deprecated use_nodes. + nodeTree = getattr(material, 'node_tree', None) + if nodeTree is not None: + principled = nodeTree.nodes.get('Principled BSDF') + if principled is not None: + principled.inputs['Base Color'].default_value = color + obj.data.materials.append(material) + + scene = bpy.context.scene + assert scene is not None + scene.frame_start = int(keyframes[0]['frame']) + scene.frame_end = int(keyframes[-1]['frame']) + + for keyframe in keyframes: + frame = int(keyframe['frame']) + position = keyframe['positionAu'] + obj.location = (float(position[0]), float(position[1]), float(position[2])) + obj.keyframe_insert(data_path='location', frame=frame) + + distance = float(payload['cameraHintDistanceAu']) + bpy.ops.object.camera_add(location=(distance, -distance, distance * 0.6)) + camera = bpy.context.active_object + assert camera is not None + camera.name = f'{name}Camera' + scene.camera = camera + + # Point camera at the body origin of the first keyframe. + first = keyframes[0]['positionAu'] + track = camera.constraints.new(type='TRACK_TO') + track.target = obj + track.track_axis = 'TRACK_NEGATIVE_Z' + track.up_axis = 'UP_Y' + camera.location = ( + float(first[0]) + distance, + float(first[1]) - distance, + float(first[2]) + distance * 0.6, + ) + + return f'Blender scene built for {name} ({len(keyframes)} location keyframes)' + + +def main(argv: list[str] | None = None) -> int: + args = _argvAfterDoubleDash(list(argv if argv is not None else sys.argv)) + if not args: + print( + 'Usage: load_body.py \n' + ' or: blender --background --python load_body.py -- ', + file=sys.stderr, + ) + return 2 + + path = Path(args[0]) + payload = loadPayload(path) + summary = summarizePayload(payload) + print(summary) + + try: + import bpy # type: ignore[import-not-found] # noqa: F401 + except ImportError: + print('bpy not available — dry-run validation only (export JSON is ready for Blender).') + return 0 + + message = applyPayloadInBlender(payload) + print(message) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/render.py b/render.py index 7066d5b..26a2a33 100644 --- a/render.py +++ b/render.py @@ -3,11 +3,16 @@ from __future__ import annotations import argparse +import shutil +import subprocess +import sys +from pathlib import Path from typing import Literal from animate import renderAllAnimations from animate.scenes.alpha_centauri import renderAlphaCentauriAnimations from animate.scenes.barnards_star import renderBarnardsStarAnimations +from animate.scenes.blender.export_body import exportPlanetBodyScene from animate.scenes.interstellar_objects import renderInterstellarObjectAnimations from animate.scenes.sol_centauri_cinematic import renderSolCentauriCinematicAnimations from animate.scenes.tabbys_star import renderTabbysStarAnimations @@ -15,6 +20,8 @@ from static import renderAll as renderStatic from static import renderNeighborhood +BLENDER_LOAD_SCRIPT = Path('animate/scenes/blender/load_body.py') + Dimension = Literal['2d', '3d'] # Output geometry lives here so README-friendly sizing is one place to tune. @@ -168,9 +175,57 @@ def buildParser() -> argparse.ArgumentParser: help='For interstellar scenes: object_id or all (default: all)', ) + blenderParser = subparsers.add_parser( + 'blender', + help='Export Sol planet catalog state for Blender close-ups (scaffold for flybys)', + ) + blenderParser.add_argument( + '--body', + default='Earth', + help='PlanetCatalog name to export (default: Earth)', + ) + blenderParser.add_argument( + '--frames', + type=int, + default=120, + help='Number of orbit keyframes to sample (default: 120)', + ) + blenderParser.add_argument( + '--output-dir', + default='output/animate/blender', + help='Directory for body-scene JSON (default: output/animate/blender)', + ) + blenderParser.add_argument( + '--load', + action='store_true', + help='After export, run Blender to ingest the JSON (requires blender on PATH)', + ) + return parser +def _runBlenderLoad(scenePath: Path) -> None: + blenderExecutable = shutil.which('blender') + if blenderExecutable is None: + raise SystemExit( + 'blender not found on PATH. Install Blender or omit --load and run:\n' + f' blender --background --python {BLENDER_LOAD_SCRIPT} -- {scenePath}' + ) + completed = subprocess.run( + [ + blenderExecutable, + '--background', + '--python', + str(BLENDER_LOAD_SCRIPT), + '--', + str(scenePath), + ], + check=False, + ) + if completed.returncode != 0: + raise SystemExit(f'Blender ingest failed with exit code {completed.returncode}') + + def _renderAnimations( systemChoice: str, dimensions: tuple[Dimension, ...], @@ -245,6 +300,25 @@ def main(argv: list[str] | None = None) -> None: ) return + if args.command == 'blender': + scenePath = exportPlanetBodyScene( + args.body, + frameCount=args.frames, + outputDirectory=args.output_dir, + ) + print(f'Exported Blender body scene → {scenePath}') + if args.load: + _runBlenderLoad(scenePath) + else: + print( + 'Next: dry-run validate with\n' + f' {sys.executable} {BLENDER_LOAD_SCRIPT} {scenePath}\n' + 'or ingest with\n' + f' blender --background --python {BLENDER_LOAD_SCRIPT} -- {scenePath}\n' + 'Flyby renders: animate.scenes.blender.flyby_scene.renderPlanetFlyby (#12).' + ) + return + dimensions = _parseDimensions(args.dimension) systemChoice = getattr(args, 'system', 'sol') diff --git a/tests/test_blender_pipeline.py b/tests/test_blender_pipeline.py new file mode 100644 index 0000000..be8e224 --- /dev/null +++ b/tests/test_blender_pipeline.py @@ -0,0 +1,99 @@ +"""Unit tests for the Blender close-up pipeline scaffold.""" + +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from animate.scenes.blender.body_scene import ( + SCHEMA_ID, + BodyScene, + buildPlanetBodyScene, + loadBodyScene, +) +from animate.scenes.blender.export_body import exportPlanetBodyScene +from animate.scenes.blender.flyby_scene import ( + FLYBY_EXTENSION_POINT, + preparePlanetFlybyExport, + renderPlanetFlyby, +) +from animate.scenes.blender.load_body import loadPayload, summarizePayload + +REPO_ROOT = Path(__file__).resolve().parents[1] +LOAD_BODY_SCRIPT = REPO_ROOT / 'animate' / 'scenes' / 'blender' / 'load_body.py' + + +class BodySceneBuildTests(unittest.TestCase): + def test_build_earth_scene(self) -> None: + scene = buildPlanetBodyScene('Earth', frameCount=24) + self.assertEqual(scene.schema, SCHEMA_ID) + self.assertEqual(scene.body.name, 'Earth') + self.assertEqual(scene.body.systemId, 'sol') + self.assertEqual(len(scene.keyframes), 24) + self.assertGreater(scene.body.displayRadiusAu, 0.0) + self.assertGreater(scene.cameraHintDistanceAu, 0.0) + # First keyframe should sit near 1 AU for a low-eccentricity Earth orbit. + radius = sum(component**2 for component in scene.keyframes[0].positionAu) ** 0.5 + self.assertAlmostEqual(radius, 1.0, delta=0.05) + + def test_unknown_planet_raises(self) -> None: + with self.assertRaises(ValueError): + buildPlanetBodyScene('Nibiru') + + def test_round_trip_json(self) -> None: + scene = buildPlanetBodyScene('Jupiter', frameCount=8) + restored = BodyScene.fromDict(json.loads(scene.toJson())) + self.assertEqual(restored.body.name, 'Jupiter') + self.assertEqual(len(restored.keyframes), 8) + self.assertEqual(restored.keyframes[0].positionAu, scene.keyframes[0].positionAu) + + +class ExportAndLoadTests(unittest.TestCase): + def test_export_and_host_load(self) -> None: + with tempfile.TemporaryDirectory() as temporaryDirectory: + outputDirectory = Path(temporaryDirectory) + path = exportPlanetBodyScene('Earth', frameCount=12, outputDirectory=outputDirectory) + self.assertTrue(path.is_file()) + self.assertEqual(path.name, 'earth_body_scene.json') + + scene = loadBodyScene(path) + self.assertEqual(scene.body.name, 'Earth') + + payload = loadPayload(path) + summary = summarizePayload(payload) + self.assertIn('Earth', summary) + self.assertIn('12 keyframes', summary) + + completed = subprocess.run( + [sys.executable, str(LOAD_BODY_SCRIPT), str(path)], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertIn('Earth', completed.stdout) + self.assertIn('dry-run', completed.stdout.lower()) + + +class FlybyExtensionTests(unittest.TestCase): + def test_prepare_export(self) -> None: + with tempfile.TemporaryDirectory() as temporaryDirectory: + path = preparePlanetFlybyExport('Mars', frameCount=6, outputDirectory=temporaryDirectory) + self.assertTrue(path.is_file()) + self.assertEqual(path.name, 'mars_body_scene.json') + + def test_render_flyby_is_extension_point(self) -> None: + with tempfile.TemporaryDirectory() as temporaryDirectory: + with self.assertRaises(NotImplementedError) as context: + renderPlanetFlyby('Earth', outputDirectory=temporaryDirectory) + message = str(context.exception) + self.assertIn(FLYBY_EXTENSION_POINT, message) + self.assertIn('earth_body_scene.json', message) + + +if __name__ == '__main__': + unittest.main()