Source code for simvx.editor.project

"""Project Session — Scene I/O, project settings, and recent files."""

import logging
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING

from simvx.core import Node, Node3D, SceneTree, Signal, Vec2, __version__
from simvx.core.project import ProjectSettings, save_project
from simvx.core.scene_io import SceneFile, load_scene

if TYPE_CHECKING:
    from .state import State

log = logging.getLogger(__name__)

_MAX_RECENT = 10
_PROJECT_FILE = "simvx.toml"

[docs] @dataclass class ProjectMetadata: """Persistent project-level configuration stored in simvx.toml.""" project_name: str = "Untitled Project" default_scene: str = "" physics_fps: int = 60 window_width: int = 1280 window_height: int = 720 gravity: float = 9.8 engine_version: str = ""
[docs] @classmethod def from_dict(cls, data: dict) -> ProjectMetadata: """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})
def _meta_to_toml_settings(meta: ProjectMetadata) -> ProjectSettings: """Convert an ProjectMetadata to ProjectSettings for TOML serialization.""" data: dict = { "name": meta.project_name, "main": meta.default_scene, "display": {"width": meta.window_width, "height": meta.window_height}, "physics": {"fps": meta.physics_fps, "gravity": meta.gravity}, } if meta.engine_version: data["engine"] = {"version": meta.engine_version} return ProjectSettings(data)
[docs] class ProjectSession: """Higher-level project and scene operations. All public methods accept an State so the session 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 = ProjectMetadata() self._recent_files: list[str] = [] # -- Scene lifecycle --------------------------------------------------
[docs] def new_scene(self, state: State) -> 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: State) -> None: """Emit open_file_requested so the editor shows a FileDialog.""" self.open_file_requested.emit()
def _do_open_scene(self, state: State, 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 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: State) -> 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: State) -> None: """Emit save_file_requested so the editor shows a FileDialog.""" self.save_file_requested.emit()
def _do_save_scene(self, state: State, 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) SceneFile.from_runtime(root).save(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] @property def recent_files(self) -> list[str]: """Ordered recent-files list (defensive copy).""" 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 simvx.toml from directory *path* (or a direct file path).""" import tomllib path = Path(path) pf = path / _PROJECT_FILE if path.is_dir() else path if not pf.exists(): self._error(f"Project file not found: {pf}") return False try: raw = tomllib.loads(pf.read_text()) except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc: self._error(f"Invalid project file: {exc}") return False # Templates use a nested [project] section; the canonical writer puts # name/main at the top level. Accept either. section = raw.get("project", {}) data: dict = {} name = section.get("name", section.get("project_name", raw.get("name"))) if name is not None: data["project_name"] = name main = section.get("default_scene", section.get("main", raw.get("main"))) if main is not None: data["default_scene"] = main display = raw.get("display", {}) if "width" in display: data["window_width"] = display["width"] if "height" in display: data["window_height"] = display["height"] physics = raw.get("physics", {}) if "fps" in physics: data["physics_fps"] = physics["fps"] if "gravity" in physics: data["gravity"] = physics["gravity"] engine = raw.get("engine", {}) if "version" in engine: data["engine_version"] = engine["version"] self.settings = ProjectMetadata.from_dict(data) return True
[docs] def save_project(self, path: str | Path) -> bool: """Save simvx.toml into directory *path*. Returns True on success.""" path = Path(path) pf = path / _PROJECT_FILE if path.is_dir() else path # Auto-set engine version on save self.settings.engine_version = __version__ try: toml_settings = _meta_to_toml_settings(self.settings) save_project(toml_settings, pf) 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: State) -> 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}"
[docs] @staticmethod def get_scene_node_count(state: State) -> 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)