Source code for simvx.editor.project

"""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] @dataclass class EditorProjectMeta: """Persistent project-level configuration stored in project.simvx.""" project_name: str = "Untitled Project" default_scene: str = "" physics_fps: int = 60 window_width: int = 1280 window_height: int = 720 gravity: float = 9.8
[docs] def to_dict(self) -> dict: return { "project_name": self.project_name, "default_scene": self.default_scene, "physics_fps": self.physics_fps, "window_width": self.window_width, "window_height": self.window_height, "gravity": self.gravity, }
[docs] @classmethod def from_dict(cls, data: dict) -> EditorProjectMeta: """Create from a dict, ignoring unknown keys for forward compat.""" known = {f.name for f in cls.__dataclass_fields__.values()} return cls(**{k: v for k, v in data.items() if k in known})
[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)