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