Update models tiles and directories for source

This commit is contained in:
parent 870b3f4e42
commit aa36dea08e
13 changed files with 396 additions and 14 deletions

1
.gitignore vendored
View file

@ -244,6 +244,7 @@ pyrightconfig.json
# Runtime-generated data (do not commit) # Runtime-generated data (do not commit)
logs/ logs/
gnss/tiles/ gnss/tiles/
gnss/tilesets/
gnss/uploads/ gnss/uploads/
# Frontend artifacts # Frontend artifacts

View file

@ -236,6 +236,16 @@ body, #root {
color: #f4f7fb; color: #f4f7fb;
} }
.tileset-row {
display: flex;
gap: 10px;
align-items: center;
}
.tileset-row select {
flex: 1;
}
.file-row { .file-row {
display: flex; display: flex;
gap: 8px; gap: 8px;

View file

@ -30,6 +30,8 @@ type AppConfig = {
tiles_url_tms?: string; tiles_url_tms?: string;
vector_tiles_url?: string; vector_tiles_url?: string;
vector_metadata_url?: string; vector_metadata_url?: string;
tilesets_url?: string;
tilesets_activate_url?: string;
ws_url: string; ws_url: string;
gnss_source: string; gnss_source: string;
}; };
@ -77,11 +79,14 @@ export default function App() {
const [track, setTrack] = useState<TrackPoint[]>([]); const [track, setTrack] = useState<TrackPoint[]>([]);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const [mapMode, setMapMode] = useState<"raster" | "vector">("raster"); const [mapMode, setMapMode] = useState<"raster" | "vector">("raster");
const [tilesets, setTilesets] = useState<string[]>([]);
const [activeTileset, setActiveTileset] = useState<string>("");
const [selectedFileName, setSelectedFileName] = useState<string>(""); const [selectedFileName, setSelectedFileName] = useState<string>("");
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const [sliceParams, setSliceParams] = useState({ const [sliceParams, setSliceParams] = useState({
source: "", source: "",
destination: "", destination: "",
tileset: "",
min_zoom: 10, min_zoom: 10,
max_zoom: 16, max_zoom: 16,
tile_format: "png", tile_format: "png",
@ -104,6 +109,16 @@ export default function App() {
.then((res) => { .then((res) => {
setSliceParams((p) => ({ ...p, source: res.path })); setSliceParams((p) => ({ ...p, source: res.path }));
setSelectedFileName(res.filename); setSelectedFileName(res.filename);
setSliceParams((p) => {
if (p.tileset) return p;
const proposed = String(res.filename || "")
.replace(/\.[^.]+$/, "")
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 64);
return proposed ? { ...p, tileset: proposed } : p;
});
setSliceStatus("idle"); setSliceStatus("idle");
sendFrontLog(`uploaded source ${res.filename}`, "info"); sendFrontLog(`uploaded source ${res.filename}`, "info");
if (res.filename?.toLowerCase?.().endsWith(".mbtiles")) { if (res.filename?.toLowerCase?.().endsWith(".mbtiles")) {
@ -151,7 +166,10 @@ export default function App() {
useEffect(() => { useEffect(() => {
fetch(`${backendBase}/config`, { cache: "no-store" }) fetch(`${backendBase}/config`, { cache: "no-store" })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`status ${r.status}`)))) .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`status ${r.status}`))))
.then(setConfig) .then((cfg) => {
setConfig(cfg);
return cfg;
})
.catch((err) => { .catch((err) => {
console.error("Config error", err); console.error("Config error", err);
sendFrontLog(`config fetch failed: ${err}`, "error"); sendFrontLog(`config fetch failed: ${err}`, "error");
@ -159,6 +177,46 @@ export default function App() {
}); });
}, []); }, []);
const refreshTilesets = async () => {
const url = config?.tilesets_url ? `${backendBase}${config.tilesets_url}` : `${backendBase}/tilesets`;
const res = await fetch(url, { cache: "no-store" });
const data = await res.json();
if (!res.ok) throw new Error(data?.detail || `status ${res.status}`);
setTilesets(data.tilesets || []);
setActiveTileset(data.active || "");
};
useEffect(() => {
if (!config) return;
refreshTilesets().catch((err) => sendFrontLog(`tilesets refresh failed: ${err}`, "warn"));
}, [config]);
const activateTileset = async (name: string) => {
const url = config?.tilesets_activate_url ? `${backendBase}${config.tilesets_activate_url}` : `${backendBase}/tilesets/activate`;
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.detail || `status ${res.status}`);
setActiveTileset(data.active || name);
sendFrontLog(`tileset activated: ${data.active || name}`, "info");
};
const createTileset = async (name: string) => {
const res = await fetch(`${backendBase}/tilesets`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.detail || `status ${res.status}`);
await refreshTilesets();
setSliceParams((p) => ({ ...p, tileset: data.name || name }));
sendFrontLog(`tileset created: ${data.name || name}`, "info");
};
useEffect(() => { useEffect(() => {
if (!config) return; if (!config) return;
const base = backendBase.replace(/\/+$/, ""); const base = backendBase.replace(/\/+$/, "");
@ -410,6 +468,26 @@ export default function App() {
<div className="card-label">GNSS source</div> <div className="card-label">GNSS source</div>
<div className="card-value">{config?.gnss_source ?? "—"}</div> <div className="card-value">{config?.gnss_source ?? "—"}</div>
</div> </div>
<div className="card">
<div className="card-label">Tileset</div>
<div className="card-value">
<div className="tileset-row">
<select
value={activeTileset}
onChange={(e) => activateTileset(e.target.value).catch((err) => sendFrontLog(`${err}`, "error"))}
>
{tilesets.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
<button type="button" className="btn ghost" onClick={() => refreshTilesets().catch(() => {})}>
Обновить
</button>
</div>
</div>
</div>
<div className="card"> <div className="card">
<div className="card-label">Last fix</div> <div className="card-label">Last fix</div>
<div className="card-value">{formatTime(lastPoint?.timestamp)}</div> <div className="card-value">{formatTime(lastPoint?.timestamp)}</div>
@ -479,6 +557,7 @@ export default function App() {
body: JSON.stringify({ body: JSON.stringify({
source: sliceParams.source, source: sliceParams.source,
destination: sliceParams.destination || undefined, destination: sliceParams.destination || undefined,
tileset: sliceParams.tileset || undefined,
min_zoom: Number(sliceParams.min_zoom), min_zoom: Number(sliceParams.min_zoom),
max_zoom: Number(sliceParams.max_zoom), max_zoom: Number(sliceParams.max_zoom),
tile_format: sliceParams.tile_format, tile_format: sliceParams.tile_format,
@ -490,6 +569,9 @@ export default function App() {
.then((res) => { .then((res) => {
setSliceStatus("ok"); setSliceStatus("ok");
sendFrontLog(`tiles sliced to ${res.tiles_dir}`, "info"); sendFrontLog(`tiles sliced to ${res.tiles_dir}`, "info");
refreshTilesets().catch(() => {});
if (res.tileset) setActiveTileset(res.tileset);
if (res.tileset) activateTileset(res.tileset).catch(() => {});
}) })
.catch((err) => { .catch((err) => {
setSliceStatus("error"); setSliceStatus("error");
@ -552,11 +634,40 @@ export default function App() {
<option value="xyz">Папка XYZ</option> <option value="xyz">Папка XYZ</option>
</select> </select>
</label> </label>
<label className="field">
<span>Tileset (имя папки)</span>
<div className="file-row">
<input
type="text"
list="tilesets-list"
placeholder="например: moscow (пусто → активный)"
value={sliceParams.tileset}
onChange={(e) => setSliceParams((p) => ({ ...p, tileset: e.target.value }))}
/>
<button
type="button"
className="btn ghost"
onClick={() => {
const name = sliceParams.tileset.trim();
if (!name) return;
createTileset(name).catch((err) => sendFrontLog(`tileset create failed: ${err}`, "error"));
}}
>
Создать
</button>
</div>
<datalist id="tilesets-list">
{tilesets.map((t) => (
<option key={t} value={t} />
))}
</datalist>
<small className="muted">Активный: {activeTileset || "—"}</small>
</label>
<label className="field"> <label className="field">
<span>Папка тайлов (опц.)</span> <span>Папка тайлов (опц.)</span>
<input <input
type="text" type="text"
placeholder="пусто → gnss/tiles" placeholder="лучше не трогать (по умолчанию tileset)"
value={sliceParams.destination} value={sliceParams.destination}
onChange={(e) => setSliceParams((p) => ({ ...p, destination: e.target.value }))} onChange={(e) => setSliceParams((p) => ({ ...p, destination: e.target.value }))}
/> />
@ -601,6 +712,7 @@ export default function App() {
setSliceParams({ setSliceParams({
source: "", source: "",
destination: "", destination: "",
tileset: "",
min_zoom: 10, min_zoom: 10,
max_zoom: 16, max_zoom: 16,
tile_format: "png", tile_format: "png",

View file

@ -12,6 +12,8 @@ async def get_config() -> dict:
"tiles_url_tms": "/tiles/tms/{z}/{x}/{y}.png", "tiles_url_tms": "/tiles/tms/{z}/{x}/{y}.png",
"vector_tiles_url": "/tiles/vector/{z}/{x}/{y}.pbf", "vector_tiles_url": "/tiles/vector/{z}/{x}/{y}.pbf",
"vector_metadata_url": "/tiles/vector/metadata", "vector_metadata_url": "/tiles/vector/metadata",
"tilesets_url": "/tilesets",
"tilesets_activate_url": "/tilesets/activate",
"ws_url": "/ws/track", "ws_url": "/ws/track",
"gnss_source": config.GNSS_SOURCE, "gnss_source": config.GNSS_SOURCE,
} }

View file

@ -7,6 +7,7 @@ from ...api.dependencies import get_tile_service
from ...models.tiles import TileSliceRequest from ...models.tiles import TileSliceRequest
from ...services.tiles import TileCutError, TileService from ...services.tiles import TileCutError, TileService
from ... import config from ... import config
from ...services.tilesets import TilesetService, TilesetError
router = APIRouter(tags=["tiles"]) router = APIRouter(tags=["tiles"])
@ -24,7 +25,16 @@ async def serve_tile_tms(zoom: int, x: int, y: int, tile_service: TileService =
@router.post("/tiles/slice") @router.post("/tiles/slice")
async def slice_tiles(payload: TileSliceRequest, tile_service: TileService = Depends(get_tile_service)) -> dict: async def slice_tiles(payload: TileSliceRequest, tile_service: TileService = Depends(get_tile_service)) -> dict:
source = payload.source_path() source = payload.source_path()
destination = payload.destination_path(tile_service.tiles_dir) tileset_svc = TilesetService(tilesets_dir=config.TILESSETS_DIR, default_tileset=config.DEFAULT_TILESET)
tileset_svc.ensure()
tileset_name = payload.tileset_name()
if tileset_name:
try:
tileset_svc.create(tileset_name)
except TilesetError as exc:
raise HTTPException(status_code=400, detail=str(exc))
destination_default = tileset_svc.resolve(tileset_name)
destination = payload.destination_path(destination_default)
try: try:
if payload.source_format in {"tms", "xyz"}: if payload.source_format in {"tms", "xyz"}:
src = tile_service.unpack_if_archive(source) src = tile_service.unpack_if_archive(source)
@ -43,7 +53,13 @@ async def slice_tiles(payload: TileSliceRequest, tile_service: TileService = Dep
) )
except TileCutError as exc: except TileCutError as exc:
raise HTTPException(status_code=400, detail=str(exc)) raise HTTPException(status_code=400, detail=str(exc))
return {"status": "ok", "tiles_dir": str(destination), "source": str(source), "scheme": payload.scheme} return {
"status": "ok",
"tiles_dir": str(destination),
"source": str(source),
"scheme": payload.scheme,
"tileset": tileset_name or tileset_svc.get_active(),
}
@router.post("/tiles/upload") @router.post("/tiles/upload")

View file

@ -0,0 +1,49 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from ..dependencies import get_tile_service
from ... import config
from ...services.tiles import TileService
from ...services.tilesets import TilesetError, TilesetService
router = APIRouter(tags=["tilesets"])
def _tileset_service() -> TilesetService:
return TilesetService(tilesets_dir=config.TILESSETS_DIR, default_tileset=config.DEFAULT_TILESET)
class CreateTileset(BaseModel):
name: str
class ActivateTileset(BaseModel):
name: str
@router.get("/tilesets")
async def list_tilesets() -> dict:
svc = _tileset_service()
return {"tilesets": svc.list_tilesets(), "active": svc.get_active()}
@router.post("/tilesets")
async def create_tileset(payload: CreateTileset) -> dict:
svc = _tileset_service()
try:
name = svc.create(payload.name)
except TilesetError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"status": "ok", "name": name}
@router.post("/tilesets/activate")
async def activate_tileset(payload: ActivateTileset, tile_service: TileService = Depends(get_tile_service)) -> dict:
svc = _tileset_service()
try:
active = svc.set_active(payload.name)
except TilesetError as exc:
raise HTTPException(status_code=400, detail=str(exc))
tile_service.set_tiles_dir(svc.resolve(active))
return {"status": "ok", "active": active}

View file

@ -2,6 +2,8 @@ from pathlib import Path
BASE_DIR = Path(__file__).parent BASE_DIR = Path(__file__).parent
TILES_DIR = BASE_DIR / "tiles" TILES_DIR = BASE_DIR / "tiles"
TILESSETS_DIR = BASE_DIR / "tilesets"
DEFAULT_TILESET = "default"
TILE_PLACEHOLDER = BASE_DIR / "tiles" / "placeholder.png" TILE_PLACEHOLDER = BASE_DIR / "tiles" / "placeholder.png"
UPLOAD_DIR = BASE_DIR / "uploads" UPLOAD_DIR = BASE_DIR / "uploads"
GNSS_SOURCE = "simulated" GNSS_SOURCE = "simulated"
@ -9,6 +11,13 @@ GNSS_PORT = "/dev/ttyAMA0"
GNSS_BAUD = 9600 GNSS_BAUD = 9600
GNSS_TIMEOUT = 1.0 GNSS_TIMEOUT = 1.0
SIM_START_POINT = (55.75222, 37.61556) SIM_START_POINT = (55.75222, 37.61556)
SIM_PATH = "circle" # circle | rectangle
# Для симуляции движения по прямоугольнику раскомментируй строку ниже:
# SIM_PATH = "rectangle"
SIM_RADIUS_METERS = 250.0 SIM_RADIUS_METERS = 250.0
SIM_POINT_INTERVAL = 1.0 SIM_RECT_WIDTH_METERS = 400.0
WS_BROADCAST_INTERVAL = 1.0 SIM_RECT_HEIGHT_METERS = 250.0
SIM_STEP_METERS = 20.0
SIM_ALT_METERS = 50.0
SIM_POINT_INTERVAL = 1
WS_BROADCAST_INTERVAL = 1

View file

@ -6,11 +6,12 @@ from fastapi.middleware.cors import CORSMiddleware
from . import config from . import config
from .api import state from .api import state
from .api.routes import config_route, health, logs, tiles, vector_tiles, ws from .api.routes import config_route, health, logs, tiles, tilesets, vector_tiles, ws
from .services.gnss import GNSSService, SerialNMEASource, SimulatedPathSource from .services.gnss import GNSSService, RectangularPathSource, SerialNMEASource, SimulatedPathSource
from .services.logs import LogService from .services.logs import LogService
from .services.tiles import TileService from .services.tiles import TileService
from .services.vector_mbtiles import VectorMBTilesService from .services.vector_mbtiles import VectorMBTilesService
from .services.tilesets import TilesetService
logger = logging.getLogger("gnss") logger = logging.getLogger("gnss")
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@ -26,15 +27,28 @@ def create_gnss_service() -> GNSSService:
) )
else: else:
start_lat, start_lon = config.SIM_START_POINT start_lat, start_lon = config.SIM_START_POINT
source = SimulatedPathSource( path = (getattr(config, "SIM_PATH", "circle") or "circle").lower()
center_lat=start_lat, if path == "rectangle":
center_lon=start_lon, source = RectangularPathSource(
radius_m=config.SIM_RADIUS_METERS, center_lat=start_lat,
) center_lon=start_lon,
width_m=config.SIM_RECT_WIDTH_METERS,
height_m=config.SIM_RECT_HEIGHT_METERS,
step_m=config.SIM_STEP_METERS,
alt_m=config.SIM_ALT_METERS,
)
else:
source = SimulatedPathSource(
center_lat=start_lat,
center_lon=start_lon,
radius_m=config.SIM_RADIUS_METERS,
)
return GNSSService(source=source, broadcast_interval=config.WS_BROADCAST_INTERVAL) return GNSSService(source=source, broadcast_interval=config.WS_BROADCAST_INTERVAL)
tiles_dir = Path(config.TILES_DIR) tileset_service = TilesetService(tilesets_dir=Path(config.TILESSETS_DIR), default_tileset=config.DEFAULT_TILESET)
tileset_service.ensure()
tiles_dir = tileset_service.resolve(None)
placeholder_tile = Path(config.TILE_PLACEHOLDER) placeholder_tile = Path(config.TILE_PLACEHOLDER)
tile_service = TileService(tiles_dir=tiles_dir, placeholder=placeholder_tile, upload_dir=Path(config.UPLOAD_DIR)) tile_service = TileService(tiles_dir=tiles_dir, placeholder=placeholder_tile, upload_dir=Path(config.UPLOAD_DIR))
log_service = LogService(base_dir=Path("logs")) log_service = LogService(base_dir=Path("logs"))
@ -63,12 +77,29 @@ app.include_router(tiles.router)
app.include_router(ws.router) app.include_router(ws.router)
app.include_router(logs.router) app.include_router(logs.router)
app.include_router(vector_tiles.router) app.include_router(vector_tiles.router)
app.include_router(tilesets.router)
@app.on_event("startup") @app.on_event("startup")
async def on_startup() -> None: async def on_startup() -> None:
tile_service.ensure_tiles_dir() tile_service.ensure_tiles_dir()
tile_service.ensure_upload_dir() tile_service.ensure_upload_dir()
tileset_service.ensure()
legacy_tiles_dir = Path(config.TILES_DIR)
default_tileset_dir = tileset_service.resolve(config.DEFAULT_TILESET)
if legacy_tiles_dir.is_dir() and any(legacy_tiles_dir.iterdir()):
if not default_tileset_dir.is_dir() or not any(default_tileset_dir.iterdir()):
default_tileset_dir.mkdir(parents=True, exist_ok=True)
for item in legacy_tiles_dir.iterdir():
target = default_tileset_dir / item.name
if item.is_dir():
import shutil
shutil.copytree(item, target, dirs_exist_ok=True)
else:
import shutil
shutil.copy2(item, target)
try: try:
await gnss_service.start() await gnss_service.start()
except Exception as exc: except Exception as exc:

View file

@ -6,6 +6,7 @@ from pydantic import BaseModel, Field, field_validator
class TileSliceRequest(BaseModel): class TileSliceRequest(BaseModel):
source: str source: str
destination: str | None = None destination: str | None = None
tileset: str | None = None
min_zoom: int = Field(ge=0) min_zoom: int = Field(ge=0)
max_zoom: int = Field(ge=0) max_zoom: int = Field(ge=0)
resampling: str = "bilinear" resampling: str = "bilinear"
@ -57,3 +58,8 @@ class TileSliceRequest(BaseModel):
if self.destination: if self.destination:
return Path(self.destination).expanduser().resolve() return Path(self.destination).expanduser().resolve()
return default.resolve() return default.resolve()
def tileset_name(self) -> str | None:
if not self.tileset:
return None
return self.tileset.strip()

View file

@ -93,6 +93,73 @@ class SimulatedPathSource(BaseGNSSSource):
) )
class RectangularPathSource(BaseGNSSSource):
def __init__(
self,
center_lat: float,
center_lon: float,
width_m: float,
height_m: float,
step_m: float = 20.0,
alt_m: float = 50.0,
):
self.center_lat = center_lat
self.center_lon = center_lon
self.width_m = width_m
self.height_m = height_m
self.step_m = step_m
self.alt_m = alt_m
self._edge_index = 0
self._edge_progress_m = 0.0
self._current_lat = center_lat
self._current_lon = center_lon
async def start(self) -> None:
self._edge_index = 0
self._edge_progress_m = 0.0
half_h = self.height_m / 2.0
half_w = self.width_m / 2.0
lat_s, lon_s = move_point(self.center_lat, self.center_lon, half_h, 180.0)
lat_sw, lon_sw = move_point(lat_s, lon_s, half_w, 270.0)
self._current_lat, self._current_lon = lat_sw, lon_sw
async def stop(self) -> None:
return None
async def next_point(self) -> Optional[GNSSPoint]:
edges = [
(90.0, self.width_m),
(0.0, self.height_m),
(270.0, self.width_m),
(180.0, self.height_m),
]
bearing, length = edges[self._edge_index]
step = self.step_m
remaining = length - self._edge_progress_m
if remaining <= 0.0:
self._edge_index = (self._edge_index + 1) % len(edges)
self._edge_progress_m = 0.0
bearing, length = edges[self._edge_index]
remaining = length
if step > remaining:
step = remaining
self._current_lat, self._current_lon = move_point(
self._current_lat, self._current_lon, step, bearing
)
self._edge_progress_m += step
return GNSSPoint(
lat=self._current_lat,
lon=self._current_lon,
alt=self.alt_m,
timestamp=current_utc_time(),
source="simulated",
)
def build_status_payload(status: str, detail: Optional[str] = None) -> dict: def build_status_payload(status: str, detail: Optional[str] = None) -> dict:
payload = {"type": "status", "status": status, "timestamp": current_utc_time()} payload = {"type": "status", "status": status, "timestamp": current_utc_time()}
if detail: if detail:

View file

@ -91,6 +91,10 @@ class TileService:
y_converted = (2**zoom - 1) - y y_converted = (2**zoom - 1) - y
return self.get_tile_response(zoom, x, y_converted) return self.get_tile_response(zoom, x, y_converted)
def set_tiles_dir(self, tiles_dir: Path) -> None:
self.tiles_dir = Path(tiles_dir)
self.ensure_tiles_dir()
def unpack_if_archive(self, source: Path) -> Path: def unpack_if_archive(self, source: Path) -> Path:
lower = source.suffix.lower() lower = source.suffix.lower()
if lower == ".zip": if lower == ".zip":

75
gnss/services/tilesets.py Normal file
View file

@ -0,0 +1,75 @@
import re
from pathlib import Path
from typing import Optional
class TilesetError(RuntimeError):
pass
def _sanitize_tileset_name(name: str) -> str:
n = (name or "").strip().lower()
n = re.sub(r"[^a-z0-9._-]+", "-", n)
n = n.strip("-")
if not n:
raise TilesetError("tileset name is empty")
if len(n) > 64:
raise TilesetError("tileset name is too long")
return n
class TilesetService:
def __init__(self, tilesets_dir: Path, default_tileset: str = "default"):
self.tilesets_dir = Path(tilesets_dir)
self.default_tileset = _sanitize_tileset_name(default_tileset)
self._active_file = self.tilesets_dir / ".active"
def ensure(self) -> None:
self.tilesets_dir.mkdir(parents=True, exist_ok=True)
default_dir = self.tilesets_dir / self.default_tileset
default_dir.mkdir(parents=True, exist_ok=True)
if not self._active_file.exists():
self._active_file.write_text(self.default_tileset, encoding="utf-8")
def list_tilesets(self) -> list[str]:
self.ensure()
items: list[str] = []
for p in self.tilesets_dir.iterdir():
if not p.is_dir():
continue
if p.name.startswith("."):
continue
items.append(p.name)
return sorted(items)
def get_active(self) -> str:
self.ensure()
try:
name = self._active_file.read_text(encoding="utf-8").strip()
except FileNotFoundError:
name = self.default_tileset
if not name:
name = self.default_tileset
return _sanitize_tileset_name(name)
def set_active(self, name: str) -> str:
self.ensure()
tileset = _sanitize_tileset_name(name)
path = self.tilesets_dir / tileset
if not path.is_dir():
raise TilesetError(f"tileset not found: {tileset}")
self._active_file.write_text(tileset, encoding="utf-8")
return tileset
def create(self, name: str) -> str:
self.ensure()
tileset = _sanitize_tileset_name(name)
path = self.tilesets_dir / tileset
path.mkdir(parents=True, exist_ok=True)
return tileset
def resolve(self, name: Optional[str] = None) -> Path:
self.ensure()
tileset = self.get_active() if not name else _sanitize_tileset_name(name)
return self.tilesets_dir / tileset