Source code for simvx.core.resource

"""
Resource system — URI-based resource resolution with caching.

Mesh resource identifiers:
    "mesh://cube"                          → Mesh.cube()
    "mesh://sphere?radius=2&rings=16"      → Mesh.sphere(radius=2, rings=16)
    "mesh://obj?path=models/ship.obj"      → Mesh.from_obj("models/ship.obj")

Audio resource identifiers:
    "audio://music/theme.ogg"              → AudioStream("music/theme.ogg")
    "audio://sfx/explosion.wav"            → AudioStream("sfx/explosion.wav")

Public API:
    ResourceCache.get().resolve_mesh(uri)
    ResourceCache.get().resolve_audio(uri)
"""


from __future__ import annotations

import logging
from pathlib import Path
from urllib.parse import parse_qs, urlencode, urlparse

from .audio import AudioStream
from .graphics.mesh import Mesh

log = logging.getLogger(__name__)


[docs] class MeshURI: """Builder for mesh resource URIs. Usage: MeshURI.CUBE # "mesh://cube" MeshURI.SPHERE # "mesh://sphere" MeshURI.cube(size=0.5) # "mesh://cube?size=0.5" MeshURI.sphere(radius=2, rings=32) # "mesh://sphere?radius=2&rings=32" MeshURI.obj("models/ship.obj") # "mesh://obj?path=models/ship.obj" """ CUBE = "mesh://cube" SPHERE = "mesh://sphere" CONE = "mesh://cone" CYLINDER = "mesh://cylinder"
[docs] @staticmethod def cube(size: float = 1.0) -> str: if size == 1.0: return MeshURI.CUBE return f"mesh://cube?{urlencode({'size': size})}"
[docs] @staticmethod def sphere(radius: float = 1.0, rings: int = 16, segments: int = 16) -> str: params = {} if radius != 1.0: params["radius"] = radius if rings != 16: params["rings"] = rings if segments != 16: params["segments"] = segments return "mesh://sphere" + (f"?{urlencode(params)}" if params else "")
[docs] @staticmethod def cone(radius: float = 0.5, height: float = 1.0, segments: int = 16) -> str: params = {} if radius != 0.5: params["radius"] = radius if height != 1.0: params["height"] = height if segments != 16: params["segments"] = segments return "mesh://cone" + (f"?{urlencode(params)}" if params else "")
[docs] @staticmethod def cylinder(radius: float = 0.5, height: float = 1.0, segments: int = 16) -> str: params = {} if radius != 0.5: params["radius"] = radius if height != 1.0: params["height"] = height if segments != 16: params["segments"] = segments return "mesh://cylinder" + (f"?{urlencode(params)}" if params else "")
[docs] @staticmethod def obj(path: str) -> str: return f"mesh://obj?{urlencode({'path': path})}"
# Parameter types for each factory method _MESH_PARAM_TYPES = { "cube": {"size": float}, "sphere": {"radius": float, "rings": int, "segments": int}, "cone": {"radius": float, "height": float, "segments": int}, "cylinder": {"radius": float, "height": float, "segments": int}, "obj": {"path": str}, }
[docs] class ResourceCache: """Singleton cache that resolves resource URIs to objects.""" _instance: ResourceCache | None = None def __init__(self): self._meshes: dict[str, Mesh] = {} self._audio: dict[str, AudioStream] = {} self.base_path: str = "" # for resolving relative file paths
[docs] @classmethod def get(cls) -> ResourceCache: """Return the singleton instance, creating it if needed.""" if cls._instance is None: cls._instance = cls() return cls._instance
[docs] def resolve_mesh(self, uri: str) -> Mesh: """Resolve a mesh:// URI to a Mesh object. Results are cached by URI.""" if uri in self._meshes: return self._meshes[uri] parsed = urlparse(uri) if parsed.scheme != "mesh": raise ValueError(f"Unknown resource scheme: {parsed.scheme!r} (expected 'mesh')") factory_name = parsed.netloc or parsed.path.lstrip("/") params = {k: v[0] for k, v in parse_qs(parsed.query).items()} if factory_name == "obj": path = params.pop("path", None) if path is None: raise ValueError("mesh://obj requires a 'path' parameter") if self.base_path and not Path(path).is_absolute(): path = str(Path(self.base_path) / path) mesh = Mesh.from_obj(path) elif factory_name in _MESH_PARAM_TYPES: type_map = _MESH_PARAM_TYPES[factory_name] kwargs = {k: type_map[k](v) for k, v in params.items() if k in type_map} factory = getattr(Mesh, factory_name) mesh = factory(**kwargs) else: raise ValueError(f"Unknown mesh factory: {factory_name!r}") # Ensure the cached mesh stores its URI mesh.resource_uri = uri self._meshes[uri] = mesh return mesh
[docs] def resolve_audio(self, uri: str) -> AudioStream: """Resolve an audio:// URI to an AudioStream object. Results are cached by URI.""" if uri in self._audio: return self._audio[uri] parsed = urlparse(uri) if parsed.scheme != "audio": raise ValueError(f"Unknown resource scheme: {parsed.scheme!r} (expected 'audio')") # Extract file path from URI path = parsed.netloc + parsed.path if parsed.netloc else parsed.path.lstrip("/") if self.base_path and not Path(path).is_absolute(): path = str(Path(self.base_path) / path) audio = AudioStream(path) audio.resource_uri = uri self._audio[uri] = audio return audio
[docs] def unload(self, uri: str) -> bool: """Remove a single resource from the cache. Returns True if the resource was found and removed, False otherwise. Callers holding references to the unloaded resource retain them, but re-resolving the same URI will create a fresh object. """ removed = uri in self._meshes or uri in self._audio self._meshes.pop(uri, None) self._audio.pop(uri, None) if removed: log.debug("Unloaded resource: %s", uri) return removed
[docs] def cached_uris(self) -> list[str]: """Return all currently cached resource URIs.""" return list(self._meshes.keys()) + list(self._audio.keys())
[docs] def clear(self): """Clear all cached resources.""" count = len(self._meshes) + len(self._audio) self._meshes.clear() self._audio.clear() if count: log.debug("Cleared %d cached resources", count)
[docs] @classmethod def reset(cls): """Reset the singleton (useful for tests).""" cls._instance = None