"use strict"; const fs = require("fs"); const path = require("path"); const crypto = require("crypto"); const SCHEMA = "osm-asset-package/v1"; const CATEGORIES = new Set(["scene", "roads", "buildings", "vegetation", "water"]); const ROLES = new Set(["scene", "layer"]); function relativeUri(uri) { if (typeof uri !== "string" || !uri || uri.startsWith("/") || path.isAbsolute(uri) || uri.includes("\\") || uri.split("/").includes("..")) { throw new Error(`Asset URI must be a package-relative forward-slash path: ${uri}`); } return uri; } function validateManifest(manifest, packageDir, options = {}) { const errors = []; const fail = (message) => errors.push(message); if (!manifest || manifest.schema !== SCHEMA) fail(`schema must be '${SCHEMA}'`); if (typeof manifest?.packageVersion !== "string" || !manifest.packageVersion) fail("packageVersion is required"); if (typeof manifest?.areaId !== "string" || !manifest.areaId) fail("areaId is required"); const coordinateSystem = manifest?.coordinateSystem || {}; if (coordinateSystem.axes !== "ENU" || coordinateSystem.units !== "meters" || coordinateSystem.x !== "east" || coordinateSystem.y !== "north" || coordinateSystem.z !== "up") fail("coordinateSystem must declare ENU meters (east/north/up)"); const placement = manifest?.placement || {}; for (const key of ["longitude", "latitude", "height", "headingCorrectionDegrees"]) if (!Number.isFinite(placement[key])) fail(`placement.${key} must be finite`); if (Number.isFinite(placement.longitude) && (placement.longitude < -180 || placement.longitude > 180)) fail("placement.longitude is out of range"); if (Number.isFinite(placement.latitude) && (placement.latitude < -90 || placement.latitude > 90)) fail("placement.latitude is out of range"); const bounds = manifest?.bounds || {}; for (const key of ["minLon", "minLat", "maxLon", "maxLat"]) if (!Number.isFinite(bounds[key])) fail(`bounds.${key} must be finite`); if (Number.isFinite(bounds.minLon) && Number.isFinite(bounds.maxLon) && bounds.minLon > bounds.maxLon) fail("bounds longitude order is invalid"); if (Number.isFinite(bounds.minLat) && Number.isFinite(bounds.maxLat) && bounds.minLat > bounds.maxLat) fail("bounds latitude order is invalid"); if (!Array.isArray(manifest?.assets) || !manifest.assets.length) fail("assets must be a non-empty array"); const ids = new Set(); let sceneCount = 0; for (const asset of manifest?.assets || []) { if (!asset || typeof asset.id !== "string" || !asset.id) { fail("asset id is required"); continue; } if (ids.has(asset.id)) fail(`duplicate asset id '${asset.id}'`); ids.add(asset.id); if (!ROLES.has(asset.role)) fail(`asset '${asset.id}' has invalid role`); if (!CATEGORIES.has(asset.category)) fail(`asset '${asset.id}' has invalid category`); if (typeof asset.defaultLoad !== "boolean") fail(`asset '${asset.id}' defaultLoad must be boolean`); try { relativeUri(asset.uri); } catch (error) { fail(error.message); continue; } if (asset.role === "scene") { sceneCount += 1; if (asset.category !== "scene" || !asset.defaultLoad) fail("scene asset must be category scene and defaultLoad true"); } else if (asset.category === "scene" || asset.defaultLoad) fail("layer assets must use a semantic category and defaultLoad false"); if (packageDir && options.requireFiles !== false) { const resolved = path.resolve(packageDir, asset.uri); if (!resolved.startsWith(`${path.resolve(packageDir)}${path.sep}`) || !fs.existsSync(resolved)) fail(`asset '${asset.id}' is missing from package: ${asset.uri}`); } } if (sceneCount !== 1) fail("exactly one scene asset is required"); if (errors.length) throw new Error(`Invalid asset package manifest: ${errors.join("; ")}`); return manifest; } function addIntegrity(manifest, packageDir) { for (const asset of manifest.assets) { const file = path.resolve(packageDir, asset.uri); const buffer = fs.readFileSync(file); asset.integrity = { bytes: buffer.length, sha256: crypto.createHash("sha256").update(buffer).digest("hex") }; } return manifest; } module.exports = { SCHEMA, CATEGORIES, ROLES, relativeUri, validateManifest, addIntegrity };