nepher 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nepher/__init__.py +36 -0
- nepher/api/__init__.py +6 -0
- nepher/api/client.py +384 -0
- nepher/api/endpoints.py +97 -0
- nepher/auth.py +150 -0
- nepher/cli/__init__.py +2 -0
- nepher/cli/commands/__init__.py +6 -0
- nepher/cli/commands/auth.py +37 -0
- nepher/cli/commands/cache.py +85 -0
- nepher/cli/commands/config.py +77 -0
- nepher/cli/commands/download.py +72 -0
- nepher/cli/commands/list.py +75 -0
- nepher/cli/commands/upload.py +69 -0
- nepher/cli/commands/view.py +310 -0
- nepher/cli/main.py +30 -0
- nepher/cli/utils.py +28 -0
- nepher/config.py +202 -0
- nepher/core.py +67 -0
- nepher/env_cfgs/__init__.py +7 -0
- nepher/env_cfgs/base.py +32 -0
- nepher/env_cfgs/manipulation/__init__.py +4 -0
- nepher/env_cfgs/navigation/__init__.py +45 -0
- nepher/env_cfgs/navigation/abstract_nav_cfg.py +159 -0
- nepher/env_cfgs/navigation/preset_nav_cfg.py +590 -0
- nepher/env_cfgs/navigation/usd_nav_cfg.py +644 -0
- nepher/env_cfgs/registry.py +31 -0
- nepher/loader/__init__.py +9 -0
- nepher/loader/base.py +27 -0
- nepher/loader/category_loaders/__init__.py +2 -0
- nepher/loader/preset_loader.py +80 -0
- nepher/loader/registry.py +63 -0
- nepher/loader/usd_loader.py +49 -0
- nepher/storage/__init__.py +8 -0
- nepher/storage/bundle.py +78 -0
- nepher/storage/cache.py +145 -0
- nepher/storage/manifest.py +80 -0
- nepher/utils/__init__.py +12 -0
- nepher/utils/fast_spawn_sampler.py +334 -0
- nepher/utils/free_zone_finder.py +239 -0
- nepher-0.1.0.dist-info/METADATA +235 -0
- nepher-0.1.0.dist-info/RECORD +45 -0
- nepher-0.1.0.dist-info/WHEEL +5 -0
- nepher-0.1.0.dist-info/entry_points.txt +2 -0
- nepher-0.1.0.dist-info/licenses/LICENSE +97 -0
- nepher-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""View environment command (Isaac Sim integration)."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Optional
|
|
7
|
+
import click
|
|
8
|
+
from nepher.loader.registry import load_env, load_scene
|
|
9
|
+
from nepher.cli.utils import print_error, print_info, print_success
|
|
10
|
+
|
|
11
|
+
original_argv = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _check_isaaclab_installed():
|
|
15
|
+
"""Check if isaaclab is installed and raise error if not."""
|
|
16
|
+
try:
|
|
17
|
+
import isaaclab # noqa: F401
|
|
18
|
+
except ImportError:
|
|
19
|
+
try:
|
|
20
|
+
import isaacsim # noqa: F401
|
|
21
|
+
raise ImportError(
|
|
22
|
+
"Isaac Lab is not installed. The 'view' command requires Isaac Lab to be installed.\n"
|
|
23
|
+
"Please install Isaac Lab to use this command. See: https://isaac-sim.github.io/IsaacLab/"
|
|
24
|
+
)
|
|
25
|
+
except ImportError:
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
script_paths = [
|
|
28
|
+
Path(__file__).parent.parent.parent / "scripts" / "nepher_view.py",
|
|
29
|
+
Path("envhub/scripts/nepher_view.py"),
|
|
30
|
+
Path("scripts/nepher_view.py"),
|
|
31
|
+
]
|
|
32
|
+
script_found = next((p for p in script_paths if p.exists()), None)
|
|
33
|
+
if script_found:
|
|
34
|
+
try:
|
|
35
|
+
script_found = str(script_found.relative_to(Path.cwd()))
|
|
36
|
+
except ValueError:
|
|
37
|
+
script_found = str(script_found)
|
|
38
|
+
help_msg = (
|
|
39
|
+
"Isaac Lab is not available in the current environment.\n\n"
|
|
40
|
+
"The 'view' command must be run through Isaac Lab's Python environment.\n"
|
|
41
|
+
f"Please use the script instead:\n\n"
|
|
42
|
+
f" isaaclab.bat -p {script_found} <env_id> --category <category> [--scene <scene>]\n\n"
|
|
43
|
+
"Or on Linux/Mac:\n"
|
|
44
|
+
f" ./isaaclab.sh -p {script_found} <env_id> --category <category> [--scene <scene>]\n\n"
|
|
45
|
+
"Example:\n"
|
|
46
|
+
f" isaaclab.bat -p {script_found} digital-twin-warehouse-v1 --category navigation --scene small_warehouse"
|
|
47
|
+
)
|
|
48
|
+
else:
|
|
49
|
+
help_msg = (
|
|
50
|
+
"Isaac Lab is not available in the current environment.\n\n"
|
|
51
|
+
"The 'view' command must be run through Isaac Lab's Python environment.\n"
|
|
52
|
+
"Please use the script 'scripts/nepher_view.py' with isaaclab.bat or isaaclab.sh.\n\n"
|
|
53
|
+
"Example:\n"
|
|
54
|
+
" isaaclab.bat -p scripts/nepher_view.py <env_id> --category <category> [--scene <scene>]"
|
|
55
|
+
)
|
|
56
|
+
raise ImportError(help_msg)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _spawn_usd_scene(scene_cfg, env_cache_path=None):
|
|
60
|
+
"""Spawn a USD scene in Isaac Sim."""
|
|
61
|
+
import omni.usd
|
|
62
|
+
from pxr import Gf, UsdGeom
|
|
63
|
+
from pathlib import Path
|
|
64
|
+
import isaaclab.sim as sim_utils
|
|
65
|
+
|
|
66
|
+
if not scene_cfg.usd_path:
|
|
67
|
+
raise ValueError("USD scene config missing usd_path")
|
|
68
|
+
|
|
69
|
+
usd_path = Path(scene_cfg.usd_path)
|
|
70
|
+
if not usd_path.is_absolute():
|
|
71
|
+
usd_path = (env_cache_path / usd_path) if env_cache_path else usd_path.resolve()
|
|
72
|
+
usd_path_str = str(usd_path.resolve())
|
|
73
|
+
|
|
74
|
+
print_info(f"Loading USD scene: {usd_path_str}")
|
|
75
|
+
stage = omni.usd.get_context().get_stage()
|
|
76
|
+
|
|
77
|
+
if not stage.GetPrimAtPath("/World"):
|
|
78
|
+
UsdGeom.Xform.Define(stage, "/World")
|
|
79
|
+
|
|
80
|
+
scene_prim_path = "/World/Scene"
|
|
81
|
+
scene_prim = stage.DefinePrim(scene_prim_path, "Xform")
|
|
82
|
+
scene_prim.GetReferences().AddReference(usd_path_str)
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
children = scene_prim.GetChildren()
|
|
86
|
+
if children:
|
|
87
|
+
print_info(f"USD scene reference loaded with {len(children)} top-level prims")
|
|
88
|
+
elif scene_prim.IsValid():
|
|
89
|
+
from pxr import Usd
|
|
90
|
+
for child in Usd.PrimRange(scene_prim):
|
|
91
|
+
if child != scene_prim:
|
|
92
|
+
print_info(f"Found prim in scene: {child.GetPath()}")
|
|
93
|
+
break
|
|
94
|
+
except Exception as e:
|
|
95
|
+
print_error(f"Warning: Could not verify USD scene loading: {e}")
|
|
96
|
+
|
|
97
|
+
xform = UsdGeom.Xformable(scene_prim)
|
|
98
|
+
|
|
99
|
+
def _apply_xform_op(attr_name, default_value, op_type):
|
|
100
|
+
"""Apply transform op if value differs from default."""
|
|
101
|
+
value = getattr(scene_cfg, attr_name, None)
|
|
102
|
+
if not value or value == default_value:
|
|
103
|
+
return
|
|
104
|
+
existing_ops = xform.GetOrderedXformOps()
|
|
105
|
+
op = next((o for o in existing_ops if o.GetOpType() == op_type), None)
|
|
106
|
+
if op is None:
|
|
107
|
+
op = xform.AddTranslateOp() if op_type == UsdGeom.XformOp.TypeTranslate else xform.AddScaleOp()
|
|
108
|
+
op.Set(Gf.Vec3d(*value))
|
|
109
|
+
|
|
110
|
+
_apply_xform_op('usd_position', (0.0, 0.0, 0.0), UsdGeom.XformOp.TypeTranslate)
|
|
111
|
+
_apply_xform_op('usd_scale', (1.0, 1.0, 1.0), UsdGeom.XformOp.TypeScale)
|
|
112
|
+
|
|
113
|
+
print_success(f"USD scene loaded at {scene_prim_path}")
|
|
114
|
+
|
|
115
|
+
sky_light_path = "/World/SkyLight"
|
|
116
|
+
if not stage.GetPrimAtPath(sky_light_path):
|
|
117
|
+
light_cfg = sim_utils.DomeLightCfg(intensity=1000.0, color=(1.0, 1.0, 1.0))
|
|
118
|
+
light_cfg.func(sky_light_path, light_cfg)
|
|
119
|
+
print_success("Added sky light")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _spawn_preset_scene(scene_cfg):
|
|
123
|
+
"""Spawn a preset scene in Isaac Sim."""
|
|
124
|
+
import isaaclab.sim as sim_utils
|
|
125
|
+
import omni.usd
|
|
126
|
+
from pxr import Gf, UsdGeom
|
|
127
|
+
from isaaclab.terrains import TerrainImporter
|
|
128
|
+
|
|
129
|
+
print_info("Spawning preset scene...")
|
|
130
|
+
|
|
131
|
+
stage = omni.usd.get_context().get_stage()
|
|
132
|
+
if not stage.GetPrimAtPath("/World"):
|
|
133
|
+
UsdGeom.Xform.Define(stage, "/World")
|
|
134
|
+
|
|
135
|
+
terrain_cfg = scene_cfg.get_terrain_cfg()
|
|
136
|
+
if terrain_cfg:
|
|
137
|
+
if not getattr(terrain_cfg, 'prim_path', None):
|
|
138
|
+
terrain_cfg.prim_path = "/World/Terrain"
|
|
139
|
+
TerrainImporter(terrain_cfg)
|
|
140
|
+
print_success(f"Terrain spawned (type: {terrain_cfg.terrain_type})")
|
|
141
|
+
|
|
142
|
+
if hasattr(scene_cfg, 'get_light_cfgs'):
|
|
143
|
+
for name, light_cfg in (scene_cfg.get_light_cfgs() or {}).items():
|
|
144
|
+
prim_path = getattr(light_cfg, 'prim_path', f"/World/Lights/{name}")
|
|
145
|
+
if getattr(light_cfg, 'spawn', None) is not None:
|
|
146
|
+
light_cfg.spawn.func(prim_path, light_cfg.spawn)
|
|
147
|
+
print_success(f"Light '{name}' spawned at {prim_path}")
|
|
148
|
+
|
|
149
|
+
if hasattr(scene_cfg, 'get_obstacle_cfgs'):
|
|
150
|
+
obstacle_cfgs = scene_cfg.get_obstacle_cfgs()
|
|
151
|
+
if obstacle_cfgs:
|
|
152
|
+
env_prim_path = "/World/envs/env_0"
|
|
153
|
+
for path in ["/World/envs", env_prim_path]:
|
|
154
|
+
if not stage.GetPrimAtPath(path):
|
|
155
|
+
UsdGeom.Xform.Define(stage, path)
|
|
156
|
+
|
|
157
|
+
for name, obs_cfg in obstacle_cfgs.items():
|
|
158
|
+
prim_path = getattr(obs_cfg, 'prim_path', f"{env_prim_path}/{name}").replace(
|
|
159
|
+
"{ENV_REGEX_NS}", env_prim_path
|
|
160
|
+
)
|
|
161
|
+
if getattr(obs_cfg, 'spawn', None) is not None:
|
|
162
|
+
obs_cfg.spawn.func(prim_path, obs_cfg.spawn)
|
|
163
|
+
init_state = getattr(obs_cfg, 'init_state', None)
|
|
164
|
+
if init_state is not None:
|
|
165
|
+
prim = stage.GetPrimAtPath(prim_path)
|
|
166
|
+
if prim.IsValid():
|
|
167
|
+
xformable = UsdGeom.Xformable(prim)
|
|
168
|
+
xformable.ClearXformOpOrder()
|
|
169
|
+
|
|
170
|
+
if getattr(init_state, 'pos', None) is not None:
|
|
171
|
+
pos = init_state.pos
|
|
172
|
+
xformable.AddTranslateOp().Set(Gf.Vec3d(pos[0], pos[1], pos[2]))
|
|
173
|
+
|
|
174
|
+
rot = getattr(init_state, 'rot', None)
|
|
175
|
+
if rot is not None:
|
|
176
|
+
import math
|
|
177
|
+
if len(rot) == 4:
|
|
178
|
+
xformable.AddOrientOp(UsdGeom.XformOp.PrecisionDouble).Set(
|
|
179
|
+
Gf.Quatd(rot[0], rot[1], rot[2], rot[3])
|
|
180
|
+
)
|
|
181
|
+
elif len(rot) == 3:
|
|
182
|
+
xformable.AddRotateXYZOp(UsdGeom.XformOp.PrecisionDouble).Set(
|
|
183
|
+
Gf.Vec3d(math.radians(rot[0]), math.radians(rot[1]), math.radians(rot[2]))
|
|
184
|
+
)
|
|
185
|
+
print_success(f"Spawned {len(obstacle_cfgs)} obstacles")
|
|
186
|
+
|
|
187
|
+
sky_light_path = "/World/SkyLight"
|
|
188
|
+
if not stage.GetPrimAtPath(sky_light_path):
|
|
189
|
+
light_cfg = sim_utils.DomeLightCfg(intensity=1000.0, color=(1.0, 1.0, 1.0))
|
|
190
|
+
light_cfg.func(sky_light_path, light_cfg)
|
|
191
|
+
print_success("Added default sky light")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _setup_app_launcher():
|
|
195
|
+
"""Set up AppLauncher and return the simulation app.
|
|
196
|
+
|
|
197
|
+
This function extracts AppLauncher arguments from the original sys.argv
|
|
198
|
+
and creates the AppLauncher instance. It should be called before importing
|
|
199
|
+
any Isaac Lab modules that require the app to be running.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
The SimulationApp instance from the AppLauncher.
|
|
203
|
+
"""
|
|
204
|
+
_check_isaaclab_installed()
|
|
205
|
+
|
|
206
|
+
from isaaclab.app import AppLauncher
|
|
207
|
+
|
|
208
|
+
argv_to_parse = original_argv if original_argv is not None else sys.argv
|
|
209
|
+
|
|
210
|
+
app_launcher_args = []
|
|
211
|
+
i = 1 # Skip script name
|
|
212
|
+
while i < len(argv_to_parse):
|
|
213
|
+
arg = argv_to_parse[i]
|
|
214
|
+
if arg in ("--category", "--scene"):
|
|
215
|
+
i += 2 if i + 1 < len(argv_to_parse) else 1
|
|
216
|
+
continue
|
|
217
|
+
if not arg.startswith("--") and i == 1:
|
|
218
|
+
i += 1
|
|
219
|
+
continue
|
|
220
|
+
app_launcher_args.append(arg)
|
|
221
|
+
i += 1
|
|
222
|
+
|
|
223
|
+
parser = argparse.ArgumentParser()
|
|
224
|
+
AppLauncher.add_app_launcher_args(parser)
|
|
225
|
+
args_cli = parser.parse_args(app_launcher_args)
|
|
226
|
+
|
|
227
|
+
app_launcher = AppLauncher(args_cli)
|
|
228
|
+
return app_launcher.app
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@click.command()
|
|
232
|
+
@click.argument("env_id")
|
|
233
|
+
@click.option("--category", required=True, help="Environment category")
|
|
234
|
+
@click.option("--scene", help="Scene name or index")
|
|
235
|
+
def view(env_id: str, category: str, scene: Optional[str]):
|
|
236
|
+
"""View environment in Isaac Sim (requires isaaclab)."""
|
|
237
|
+
simulation_app = None
|
|
238
|
+
try:
|
|
239
|
+
simulation_app = _setup_app_launcher()
|
|
240
|
+
|
|
241
|
+
import isaaclab.sim as sim_utils
|
|
242
|
+
from isaaclab.sim import SimulationContext
|
|
243
|
+
import omni.usd
|
|
244
|
+
|
|
245
|
+
env = load_env(env_id, category)
|
|
246
|
+
|
|
247
|
+
if scene:
|
|
248
|
+
scene_cfg = load_scene(env, scene, category)
|
|
249
|
+
print_info(f"Loaded scene: {scene}")
|
|
250
|
+
|
|
251
|
+
usd_path = getattr(scene_cfg, 'usd_path', None)
|
|
252
|
+
|
|
253
|
+
sim_cfg = sim_utils.SimulationCfg(dt=0.01)
|
|
254
|
+
if usd_path:
|
|
255
|
+
_spawn_usd_scene(scene_cfg, env_cache_path=env.cache_path)
|
|
256
|
+
sim = SimulationContext(sim_cfg)
|
|
257
|
+
sim.set_camera_view(eye=[30.0, 30.0, 20.0], target=[0.0, 0.0, 0.0])
|
|
258
|
+
else:
|
|
259
|
+
sim = SimulationContext(sim_cfg)
|
|
260
|
+
_spawn_preset_scene(scene_cfg)
|
|
261
|
+
sim.set_camera_view(eye=[15.0, 15.0, 12.0], target=[0.0, 0.0, 0.0])
|
|
262
|
+
|
|
263
|
+
sim.reset()
|
|
264
|
+
|
|
265
|
+
print_success("Scene loaded successfully!")
|
|
266
|
+
click.echo("\n" + "=" * 60)
|
|
267
|
+
click.echo("Camera Controls:")
|
|
268
|
+
click.echo(" • Left-click + drag: Rotate view")
|
|
269
|
+
click.echo(" • Right-click + drag: Pan view")
|
|
270
|
+
click.echo(" • Scroll: Zoom in/out")
|
|
271
|
+
click.echo(" • F: Focus on selection")
|
|
272
|
+
click.echo("\nPress Ctrl+C in terminal to exit")
|
|
273
|
+
click.echo("=" * 60)
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
simulation_app = sim.app
|
|
277
|
+
click.echo("\nRunning simulation...\n (Press Ctrl+C to exit)")
|
|
278
|
+
if simulation_app and hasattr(simulation_app, 'is_running'):
|
|
279
|
+
while simulation_app.is_running():
|
|
280
|
+
sim.step()
|
|
281
|
+
else:
|
|
282
|
+
while not sim.is_stopped():
|
|
283
|
+
sim.step()
|
|
284
|
+
except KeyboardInterrupt:
|
|
285
|
+
click.echo("\n\nExiting...")
|
|
286
|
+
except Exception as e:
|
|
287
|
+
if os.getenv("NEPHER_DEBUG"):
|
|
288
|
+
import traceback
|
|
289
|
+
traceback.print_exc()
|
|
290
|
+
click.echo(f"\nWarning: Could not start simulation loop: {e}")
|
|
291
|
+
click.echo(" Scene is loaded. You can interact with it in the Isaac Sim viewport.")
|
|
292
|
+
else:
|
|
293
|
+
print_info(f"Environment: {env.name}")
|
|
294
|
+
click.echo(f" Scenes: {len(env.get_all_scenes())}")
|
|
295
|
+
click.echo("\nAvailable scenes:")
|
|
296
|
+
for i, scene_obj in enumerate(env.get_all_scenes()):
|
|
297
|
+
click.echo(f" [{i}] {scene_obj.name}")
|
|
298
|
+
|
|
299
|
+
except KeyboardInterrupt:
|
|
300
|
+
click.echo("\n\nExiting...")
|
|
301
|
+
except Exception as e:
|
|
302
|
+
import traceback
|
|
303
|
+
print_error(f"Failed to view environment: {str(e)}")
|
|
304
|
+
if __debug__ or os.getenv("NEPHER_DEBUG"):
|
|
305
|
+
click.echo(f"Exception type: {type(e).__name__}")
|
|
306
|
+
traceback.print_exc()
|
|
307
|
+
finally:
|
|
308
|
+
if simulation_app is not None:
|
|
309
|
+
simulation_app.close()
|
|
310
|
+
|
nepher/cli/main.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main CLI entry point.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from nepher.cli.commands import auth, list_cmd, download, upload, cache, view, config
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@click.group()
|
|
10
|
+
@click.version_option()
|
|
11
|
+
def main():
|
|
12
|
+
"""Nepher: Universal Isaac Lab Environments Platform."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Register commands
|
|
17
|
+
main.add_command(auth.login)
|
|
18
|
+
main.add_command(auth.logout)
|
|
19
|
+
main.add_command(auth.whoami)
|
|
20
|
+
main.add_command(list_cmd.list_cmd)
|
|
21
|
+
main.add_command(download.download)
|
|
22
|
+
main.add_command(upload.upload)
|
|
23
|
+
main.add_command(cache.cache)
|
|
24
|
+
main.add_command(view.view)
|
|
25
|
+
main.add_command(config.config)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if __name__ == "__main__":
|
|
29
|
+
main()
|
|
30
|
+
|
nepher/cli/utils.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""CLI utility functions."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich import print as rprint
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def print_success(message: str):
|
|
11
|
+
"""Print success message."""
|
|
12
|
+
rprint(f"[green]✓[/green] {message}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def print_error(message: str):
|
|
16
|
+
"""Print error message."""
|
|
17
|
+
rprint(f"[red]✗[/red] {message}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def print_info(message: str):
|
|
21
|
+
"""Print info message."""
|
|
22
|
+
rprint(f"[blue]ℹ[/blue] {message}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def print_warning(message: str):
|
|
26
|
+
"""Print warning message."""
|
|
27
|
+
rprint(f"[yellow]⚠[/yellow] {message}")
|
|
28
|
+
|
nepher/config.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration management for Nepher.
|
|
3
|
+
|
|
4
|
+
Supports multiple configuration sources with priority:
|
|
5
|
+
1. CLI arguments
|
|
6
|
+
2. Environment variables
|
|
7
|
+
3. Config file
|
|
8
|
+
4. Category-specific overrides
|
|
9
|
+
5. Default values
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Dict, Optional
|
|
15
|
+
from functools import lru_cache
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import tomllib # Python 3.11+
|
|
19
|
+
except ImportError:
|
|
20
|
+
import tomli as tomllib # Fallback for Python < 3.11
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Config:
|
|
24
|
+
"""Centralized configuration manager."""
|
|
25
|
+
|
|
26
|
+
def __init__(self):
|
|
27
|
+
self._config: Dict[str, Any] = {}
|
|
28
|
+
self._config_file: Optional[Path] = None
|
|
29
|
+
self._load_config()
|
|
30
|
+
|
|
31
|
+
def _load_config(self):
|
|
32
|
+
"""Load configuration from all sources."""
|
|
33
|
+
self._config = {
|
|
34
|
+
"api_url": "https://envhub.nepher.ai",
|
|
35
|
+
"api_key": None,
|
|
36
|
+
"cache_dir": "~/.nepher/cache",
|
|
37
|
+
"default_category": None,
|
|
38
|
+
"categories": {},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
config_file = self._find_config_file()
|
|
42
|
+
if config_file and config_file.exists():
|
|
43
|
+
self._config_file = config_file
|
|
44
|
+
try:
|
|
45
|
+
if config_file.suffix == ".toml":
|
|
46
|
+
with open(config_file, "rb") as f:
|
|
47
|
+
file_config = tomllib.load(f)
|
|
48
|
+
self._config.update(file_config)
|
|
49
|
+
elif config_file.suffix == ".json":
|
|
50
|
+
import json
|
|
51
|
+
|
|
52
|
+
with open(config_file, "r") as f:
|
|
53
|
+
file_config = json.load(f)
|
|
54
|
+
self._config.update(file_config)
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
if os.getenv("NEPHER_API_URL"):
|
|
59
|
+
self._config["api_url"] = os.getenv("NEPHER_API_URL")
|
|
60
|
+
if os.getenv("NEPHER_API_KEY"):
|
|
61
|
+
self._config["api_key"] = os.getenv("NEPHER_API_KEY")
|
|
62
|
+
if os.getenv("NEPHER_CACHE_DIR"):
|
|
63
|
+
self._config["cache_dir"] = os.getenv("NEPHER_CACHE_DIR")
|
|
64
|
+
|
|
65
|
+
def _find_config_file(self) -> Optional[Path]:
|
|
66
|
+
"""Find config file in standard locations."""
|
|
67
|
+
cwd_config = Path.cwd() / ".nepherrc"
|
|
68
|
+
if cwd_config.exists():
|
|
69
|
+
return cwd_config
|
|
70
|
+
|
|
71
|
+
home_config = Path.home() / ".nepher" / "config.toml"
|
|
72
|
+
if home_config.exists():
|
|
73
|
+
return home_config
|
|
74
|
+
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
78
|
+
"""
|
|
79
|
+
Get configuration value.
|
|
80
|
+
|
|
81
|
+
Supports dot notation for nested keys (e.g., 'categories.navigation.cache_dir').
|
|
82
|
+
"""
|
|
83
|
+
keys = key.split(".")
|
|
84
|
+
value = self._config
|
|
85
|
+
|
|
86
|
+
for k in keys:
|
|
87
|
+
if isinstance(value, dict) and k in value:
|
|
88
|
+
value = value[k]
|
|
89
|
+
else:
|
|
90
|
+
return default
|
|
91
|
+
|
|
92
|
+
return value
|
|
93
|
+
|
|
94
|
+
def set(self, key: str, value: Any, save: bool = True):
|
|
95
|
+
"""
|
|
96
|
+
Set configuration value.
|
|
97
|
+
|
|
98
|
+
Supports dot notation for nested keys.
|
|
99
|
+
Creates config file if it doesn't exist.
|
|
100
|
+
"""
|
|
101
|
+
keys = key.split(".")
|
|
102
|
+
config = self._config
|
|
103
|
+
|
|
104
|
+
for k in keys[:-1]:
|
|
105
|
+
if k not in config or not isinstance(config[k], dict):
|
|
106
|
+
config[k] = {}
|
|
107
|
+
config = config[k]
|
|
108
|
+
|
|
109
|
+
config[keys[-1]] = value
|
|
110
|
+
|
|
111
|
+
if save:
|
|
112
|
+
self._save_config()
|
|
113
|
+
|
|
114
|
+
def get_cache_dir(self, category: Optional[str] = None, override: Optional[str] = None) -> Path:
|
|
115
|
+
"""
|
|
116
|
+
Get cache directory path.
|
|
117
|
+
|
|
118
|
+
Priority:
|
|
119
|
+
1. override (CLI argument)
|
|
120
|
+
2. Category-specific cache_dir
|
|
121
|
+
3. Global cache_dir
|
|
122
|
+
4. Default
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
category: Optional category name for category-specific cache
|
|
126
|
+
override: Optional override path (from CLI flag)
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Resolved Path object
|
|
130
|
+
"""
|
|
131
|
+
if override:
|
|
132
|
+
path_str = override
|
|
133
|
+
elif category:
|
|
134
|
+
cat_config = self.get(f"categories.{category}", {})
|
|
135
|
+
path_str = cat_config.get("cache_dir") or self.get("cache_dir")
|
|
136
|
+
else:
|
|
137
|
+
path_str = self.get("cache_dir")
|
|
138
|
+
|
|
139
|
+
path = Path(path_str).expanduser().resolve()
|
|
140
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
|
|
142
|
+
return path
|
|
143
|
+
|
|
144
|
+
def get_api_url(self) -> str:
|
|
145
|
+
"""Get API URL."""
|
|
146
|
+
return self.get("api_url")
|
|
147
|
+
|
|
148
|
+
def get_api_key(self) -> Optional[str]:
|
|
149
|
+
"""Get API key (may be None if not set)."""
|
|
150
|
+
return self.get("api_key")
|
|
151
|
+
|
|
152
|
+
def _remove_none_values(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
153
|
+
"""Recursively remove None values from dictionary for TOML serialization."""
|
|
154
|
+
result = {}
|
|
155
|
+
for key, value in data.items():
|
|
156
|
+
if value is None:
|
|
157
|
+
continue # Skip None values
|
|
158
|
+
elif isinstance(value, dict):
|
|
159
|
+
cleaned = self._remove_none_values(value)
|
|
160
|
+
if cleaned: # Only include non-empty dicts
|
|
161
|
+
result[key] = cleaned
|
|
162
|
+
else:
|
|
163
|
+
result[key] = value
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
def _save_config(self):
|
|
167
|
+
"""Save configuration to file."""
|
|
168
|
+
if not self._config_file:
|
|
169
|
+
config_dir = Path.home() / ".nepher"
|
|
170
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
171
|
+
self._config_file = config_dir / "config.toml"
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
import tomli_w
|
|
175
|
+
|
|
176
|
+
config_to_save = self._remove_none_values(self._config)
|
|
177
|
+
|
|
178
|
+
with open(self._config_file, "wb") as f:
|
|
179
|
+
tomli_w.dump(config_to_save, f)
|
|
180
|
+
except ImportError:
|
|
181
|
+
import json
|
|
182
|
+
|
|
183
|
+
with open(self._config_file.with_suffix(".json"), "w") as f:
|
|
184
|
+
json.dump(self._config, f, indent=2)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
_config_instance: Optional[Config] = None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@lru_cache(maxsize=1)
|
|
191
|
+
def get_config() -> Config:
|
|
192
|
+
"""Get global configuration instance."""
|
|
193
|
+
global _config_instance
|
|
194
|
+
if _config_instance is None:
|
|
195
|
+
_config_instance = Config()
|
|
196
|
+
return _config_instance
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def set_config(key: str, value: Any, save: bool = True):
|
|
200
|
+
"""Set configuration value."""
|
|
201
|
+
get_config().set(key, value, save=save)
|
|
202
|
+
|
nepher/core.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core data structures for Nepher.
|
|
3
|
+
|
|
4
|
+
Defines Environment, Scene, and related types.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import List, Optional, Dict, Any, Union
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Scene:
|
|
14
|
+
"""Represents a single scene within an environment."""
|
|
15
|
+
|
|
16
|
+
name: str
|
|
17
|
+
description: Optional[str] = None
|
|
18
|
+
usd: Optional[Path] = None
|
|
19
|
+
preset: Optional[str] = None
|
|
20
|
+
scene: Optional[str] = None # Python scene file path (for USD scenes with custom config)
|
|
21
|
+
omap_meta: Optional[Path] = None
|
|
22
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Environment:
|
|
27
|
+
"""Represents an environment bundle."""
|
|
28
|
+
|
|
29
|
+
id: str
|
|
30
|
+
name: str
|
|
31
|
+
description: Optional[str] = None
|
|
32
|
+
category: str = "navigation"
|
|
33
|
+
type: str = "usd" # "usd" or "preset"
|
|
34
|
+
version: Optional[str] = None
|
|
35
|
+
author: Optional[str] = None
|
|
36
|
+
scenes: List[Scene] = field(default_factory=list)
|
|
37
|
+
preset_scenes: List[Scene] = field(default_factory=list)
|
|
38
|
+
benchmark: bool = False
|
|
39
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
40
|
+
cache_path: Optional[Path] = None
|
|
41
|
+
|
|
42
|
+
def get_scene(self, scene: Union[str, int]) -> Optional[Scene]:
|
|
43
|
+
"""
|
|
44
|
+
Get a scene by name or index.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
scene: Scene name (str) or index (int)
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Scene object or None if not found
|
|
51
|
+
"""
|
|
52
|
+
all_scenes = self.scenes + self.preset_scenes
|
|
53
|
+
|
|
54
|
+
if isinstance(scene, int):
|
|
55
|
+
if 0 <= scene < len(all_scenes):
|
|
56
|
+
return all_scenes[scene]
|
|
57
|
+
else:
|
|
58
|
+
for s in all_scenes:
|
|
59
|
+
if s.name == scene:
|
|
60
|
+
return s
|
|
61
|
+
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
def get_all_scenes(self) -> List[Scene]:
|
|
65
|
+
"""Get all scenes (USD and preset combined)."""
|
|
66
|
+
return self.scenes + self.preset_scenes
|
|
67
|
+
|
nepher/env_cfgs/base.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base environment configuration interface.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseEnvCfg:
|
|
9
|
+
"""Base interface for all environment configs (category-agnostic).
|
|
10
|
+
|
|
11
|
+
Note: Not using ABC to avoid pickling issues with @configclass decorator.
|
|
12
|
+
Subclasses should override methods and raise NotImplementedError if not implemented.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
name: str = ""
|
|
16
|
+
description: str = ""
|
|
17
|
+
category: str = ""
|
|
18
|
+
|
|
19
|
+
def get_terrain_cfg(self) -> Any:
|
|
20
|
+
"""Return terrain configuration.
|
|
21
|
+
|
|
22
|
+
Subclasses must override this method.
|
|
23
|
+
"""
|
|
24
|
+
raise NotImplementedError("Subclass must implement get_terrain_cfg()")
|
|
25
|
+
|
|
26
|
+
def get_scene_cfg(self) -> Any:
|
|
27
|
+
"""Return scene configuration.
|
|
28
|
+
|
|
29
|
+
Subclasses must override this method.
|
|
30
|
+
"""
|
|
31
|
+
raise NotImplementedError("Subclass must implement get_scene_cfg()")
|
|
32
|
+
|