"""Project Manager — Scene I/O, project settings, and recent files."""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from simvx.core import Node, Node3D, SceneTree, Signal, Vec2
from simvx.core.scene import PackedScene, load_scene, save_scene
if TYPE_CHECKING:
from .state import EditorState
log = logging.getLogger(__name__)
_MAX_RECENT = 10
_PROJECT_FILE = "project.simvx"
_LEGACY_PROJECT_FILE = "project.json"
[docs]
class ProjectManager:
"""Higher-level project and scene operations.
All public methods accept an EditorState so the manager stays
stateless with respect to the scene — easy to test and re-entrant.
"""
def __init__(self) -> None:
self.open_file_requested = Signal()
self.save_file_requested = Signal()
self.error_occurred = Signal()
self.settings = EditorProjectMeta()
self._recent_files: list[str] = []
# -- Scene lifecycle --------------------------------------------------
[docs]
def new_scene(self, state: EditorState) -> None:
"""Create a fresh scene with a single root Node3D."""
root = Node3D(name="Root")
state.edited_scene = SceneTree(screen_size=Vec2(800, 600))
state.edited_scene.set_root(root)
state.current_scene_path = None
state.selection.clear()
state.undo_stack.clear()
state._modified = False
state.scene_changed.emit()
# -- Open -------------------------------------------------------------
[docs]
def open_scene(self, state: EditorState) -> None:
"""Emit open_file_requested so the editor shows a FileDialog."""
self.open_file_requested.emit()
def _do_open_scene(self, state: EditorState, path: str | Path) -> bool:
"""Load a scene from *path*. Returns True on success."""
path = Path(path)
if not path.exists() or not path.is_file():
self._error(f"Scene file not found: {path}")
return False
# Pre-validate JSON
if path.suffix.lower() == ".json":
try:
json.loads(path.read_text())
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
self._error(f"Invalid scene file: {exc}")
return False
try:
root = load_scene(str(path))
except Exception as exc:
self._error(f"Failed to load scene: {exc}")
return False
if root is None:
self._error("Scene file produced an empty node tree.")
return False
state.edited_scene = SceneTree(screen_size=Vec2(800, 600))
state.edited_scene.set_root(root)
state.current_scene_path = path
state.selection.clear()
state.undo_stack.clear()
state._modified = False
self.add_recent(str(path))
state.scene_changed.emit()
return True
# -- Save -------------------------------------------------------------
[docs]
def save_scene(self, state: EditorState) -> bool:
"""Save directly if path exists, otherwise trigger save-as."""
if state.current_scene_path:
return self._do_save_scene(state, state.current_scene_path)
self.save_scene_as(state)
return False
[docs]
def save_scene_as(self, state: EditorState) -> None:
"""Emit save_file_requested so the editor shows a FileDialog."""
self.save_file_requested.emit()
def _do_save_scene(self, state: EditorState, path: str | Path) -> bool:
"""Persist the current scene to *path*. Returns True on success."""
path = Path(path)
root = state.edited_scene.root if state.edited_scene else None
if root is None:
self._error("No scene to save.")
return False
try:
path.parent.mkdir(parents=True, exist_ok=True)
save_scene(root, str(path))
except Exception as exc:
self._error(f"Failed to save scene: {exc}")
return False
state.current_scene_path = path
state._modified = False
state.scene_modified.emit()
self.add_recent(str(path))
return True
# -- Recent files -----------------------------------------------------
[docs]
def add_recent(self, path: str) -> None:
"""Add path to the front of the recent list (dedup, max 10)."""
resolved = str(Path(path).resolve())
if resolved in self._recent_files:
self._recent_files.remove(resolved)
self._recent_files.insert(0, resolved)
self._recent_files = self._recent_files[:_MAX_RECENT]
[docs]
def get_recent_files(self) -> list[str]:
"""Return the ordered recent-files list."""
return list(self._recent_files)
[docs]
def clear_recent_files(self) -> None:
"""Clear the recent-files list."""
self._recent_files.clear()
# -- Project I/O ------------------------------------------------------
[docs]
def load_project(self, path: str | Path) -> bool:
"""Load project.simvx from directory *path*, with legacy project.json fallback."""
path = Path(path)
if path.is_dir():
pf = path / _PROJECT_FILE
if not pf.exists():
pf = path / _LEGACY_PROJECT_FILE # migration fallback
else:
pf = path
if not pf.exists():
self._error(f"Project file not found: {pf}")
return False
try:
data = json.loads(pf.read_text())
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
self._error(f"Invalid project file: {exc}")
return False
self.settings = EditorProjectMeta.from_dict(data)
# Auto-migrate legacy project.json → project.simvx
if pf.name == _LEGACY_PROJECT_FILE:
new_pf = pf.parent / _PROJECT_FILE
try:
new_pf.write_text(json.dumps(self.settings.to_dict(), indent=2) + "\n")
log.info("Migrated %s → %s", pf, new_pf)
except OSError:
log.warning("Could not migrate %s to %s", pf, new_pf)
return True
[docs]
def save_project(self, path: str | Path) -> bool:
"""Save project.json into directory *path*. Returns True on success."""
path = Path(path)
pf = path / _PROJECT_FILE if path.is_dir() else path
try:
pf.parent.mkdir(parents=True, exist_ok=True)
pf.write_text(json.dumps(self.settings.to_dict(), indent=2) + "\n")
except OSError as exc:
self._error(f"Failed to save project: {exc}")
return False
return True
# -- Window title -----------------------------------------------------
[docs]
def get_window_title(self, state: EditorState) -> str:
"""Return 'SimVX Editor - scene_name*' (asterisk if modified)."""
name = state.current_scene_path.stem if state.current_scene_path else "Untitled"
mod = "*" if state.modified else ""
return f"SimVX Editor - {name}{mod}"
# -- Export helpers ----------------------------------------------------
[docs]
def export_scene_as_packed(self, state: EditorState, path: str | Path) -> bool:
"""Save the current scene as a PackedScene for embedding."""
path = Path(path)
root = state.edited_scene.root if state.edited_scene else None
if root is None:
self._error("No scene to export.")
return False
try:
path.parent.mkdir(parents=True, exist_ok=True)
packed = PackedScene(path)
packed.save(root)
except Exception as exc:
self._error(f"Failed to export PackedScene: {exc}")
return False
return True
[docs]
@staticmethod
def get_scene_node_count(state: EditorState) -> int:
"""Count every node in the current scene (including root)."""
root = state.edited_scene.root if state.edited_scene else None
if root is None:
return 0
return _count_nodes(root)
# -- Internal ---------------------------------------------------------
def _error(self, msg: str) -> None:
"""Log a warning and emit the error_occurred signal."""
log.warning(msg)
self.error_occurred.emit(msg)
def _count_nodes(node: Node) -> int:
"""Recursively count *node* and all descendants."""
return 1 + sum(_count_nodes(child) for child in node.children)