feat(assets): publish reusable area packages

This commit is contained in:
2026-08-11 16:07:07 +08:00
parent b4a81331e8
commit f385009043
18 changed files with 292 additions and 108 deletions

View File

@@ -26,6 +26,9 @@ function normalizeAreaConfig(raw, options = {}) {
const compress = normalizeCompressConfig(raw.compress);
const budget = normalizeBudgetConfig(raw.budget);
const pipelineDir = path.resolve(outputOverrides.pipelineDir || path.join(areaDir, "_pipeline"));
const packageDir = path.resolve(outputOverrides.packageDir || path.join(areaDir, "package"));
const packageStagingDir = path.resolve(outputOverrides.packageStagingDir || path.join(pipelineDir, "package-staging"));
const previewDir = path.resolve(outputOverrides.previewDir || path.join(areaDir, "_preview"));
const geojsonDir = path.resolve(outputOverrides.geojsonDir || path.join(areaDir, "osm2streets_web_out"));
const outputs = {
areaDir,
@@ -35,18 +38,28 @@ function normalizeAreaConfig(raw, options = {}) {
qgisPreview: path.resolve(outputOverrides.qgisPreview || path.join(areaDir, `${fileStem}-preview.png`)),
blend: path.resolve(outputOverrides.blend || path.join(areaDir, `${fileStem}.blend`)),
render: path.resolve(outputOverrides.render || path.join(areaDir, `${fileStem}.png`)),
glb: path.resolve(outputOverrides.glb || path.join(areaDir, `${fileStem}.glb`)),
// Published static assets are staged first and promoted by the package stage.
packageDir,
packageStagingDir,
packageManifest: path.resolve(outputOverrides.packageManifest || path.join(packageDir, "manifest.json")),
packageStagingManifest: path.resolve(outputOverrides.packageStagingManifest || path.join(packageStagingDir, "manifest.json")),
packageModelDir: path.resolve(outputOverrides.packageModelDir || path.join(packageDir, "models")),
packageStagingModelDir: path.resolve(outputOverrides.packageStagingModelDir || path.join(packageStagingDir, "models")),
packagePrimaryGlb: path.resolve(outputOverrides.packagePrimaryGlb || path.join(packageDir, "models", `${fileStem}.glb`)),
glb: path.resolve(outputOverrides.glb || path.join(packageStagingDir, "models", `${fileStem}.glb`)),
trafficSignalsDynamicGlb: path.resolve(
outputOverrides.trafficSignalsDynamicGlb || path.join(areaDir, `${fileStem}-traffic-signals-dynamic.glb`),
outputOverrides.trafficSignalsDynamicGlb || path.join(previewDir, `${fileStem}-traffic-signals-dynamic.glb`),
),
trafficSignalsCountdown0Glb: path.resolve(outputOverrides.trafficSignalsCountdown0Glb || path.join(areaDir, `${fileStem}-traffic-signals-countdown-0.glb`)),
trafficSignalsCountdown1Glb: path.resolve(outputOverrides.trafficSignalsCountdown1Glb || path.join(areaDir, `${fileStem}-traffic-signals-countdown-1.glb`)),
metadata: path.resolve(outputOverrides.metadata || path.join(areaDir, `${fileStem}.json`)),
trafficSignalsCountdown0Glb: path.resolve(outputOverrides.trafficSignalsCountdown0Glb || path.join(previewDir, `${fileStem}-traffic-signals-countdown-0.glb`)),
trafficSignalsCountdown1Glb: path.resolve(outputOverrides.trafficSignalsCountdown1Glb || path.join(previewDir, `${fileStem}-traffic-signals-countdown-1.glb`)),
metadata: path.resolve(outputOverrides.metadata || path.join(packageStagingDir, "manifest.json")),
cesiumPreview: path.resolve(
outputOverrides.cesiumPreview || path.join(areaDir, `${fileStem}-cesium-preview.html`),
),
vehicleRoute: path.resolve(outputOverrides.vehicleRoute || path.join(areaDir, `${fileStem}-vehicle-route.json`)),
vehicleModel: path.resolve(outputOverrides.vehicleModel || path.join(areaDir, `${fileStem}-vehicle-car.gltf`)),
previewDir,
previewDescriptor: path.resolve(outputOverrides.previewDescriptor || path.join(previewDir, "descriptor.json")),
vehicleRoute: path.resolve(outputOverrides.vehicleRoute || path.join(previewDir, `${fileStem}-vehicle-route.json`)),
vehicleModel: path.resolve(outputOverrides.vehicleModel || path.join(previewDir, `${fileStem}-vehicle-car.gltf`)),
trafficSignalAssemblies: path.resolve(
outputOverrides.trafficSignalAssemblies || path.join(geojsonDir, "traffic_signal_assemblies.geojson"),
),
@@ -68,8 +81,9 @@ function normalizeAreaConfig(raw, options = {}) {
blender: raw.stages?.blender ?? true,
cesium: raw.stages?.cesium ?? true,
reimport: false,
preview: false,
preview: true,
compress: true,
package: true,
},
qgis: {
arrowScale: raw.qgis?.arrowScale ?? raw.arrowScale ?? 0.8,

View File

@@ -10,6 +10,7 @@ const {
layerFile,
} = require("./scene-layers");
const { digest: glbDigest } = require("../glb-digest");
const { validateManifest } = require("./package-contract");
const {
BUDGETS,
evaluateGlbBudget,
@@ -31,8 +32,8 @@ function analyzeArea(configPath, options = {}) {
const artifacts = artifactStatus(area);
const manifests = stageManifestStatus(area, resolvedConfig);
const metadataWarnings = [];
const metadata = metadataSummary(area.outputs.metadata, metadataWarnings);
const glb = fs.existsSync(area.outputs.glb) ? glbDigest(area.outputs.glb) : null;
const metadata = metadataSummary(area.outputs.packageManifest, metadataWarnings);
const glb = fs.existsSync(area.outputs.packagePrimaryGlb) ? glbDigest(area.outputs.packagePrimaryGlb) : null;
const warnings = [
...metadataWarnings,
...collectWarnings(area, osm, artifacts, manifests, glb, metadata),
@@ -331,8 +332,8 @@ function artifactStatus(area) {
["Traffic signal runtime", area.outputs.trafficSignals, true, "file"],
["Blend scene", area.outputs.blend, true, "file"],
["Render PNG", area.outputs.render, true, "file"],
["Cesium GLB", area.outputs.glb, true, "file"],
["Cesium metadata", area.outputs.metadata, true, "file"],
["Package manifest", area.outputs.packageManifest, true, "file"],
["Package primary GLB", area.outputs.packagePrimaryGlb, true, "file"],
["Cesium preview", area.outputs.cesiumPreview, true, "file"],
];
return entries.map(([label, file, expected, type]) => {
@@ -359,6 +360,7 @@ function metadataSummary(file, warnings) {
if (!fs.existsSync(file)) return null;
try {
const metadata = JSON.parse(fs.readFileSync(file, "utf8"));
if (metadata.schema === "osm-asset-package/v1") validateManifest(metadata, path.dirname(file));
return {
asset: metadata.asset || null,
assets: Array.isArray(metadata.assets) ? metadata.assets.length : 0,
@@ -748,7 +750,7 @@ function classifyAreaQuality(result) {
warnings.push(`OSM ways reference ${osm.ways.missingNodeRefs} missing node(s).`);
}
const fatalArtifacts = new Set(["Cesium GLB", "Cesium metadata", "Cesium preview"]);
const fatalArtifacts = new Set(["Package manifest", "Package primary GLB", "Cesium preview"]);
for (const artifact of artifacts) {
if (fatalArtifacts.has(artifact.label)) {
if (!artifact.exists) {

View File

@@ -13,7 +13,7 @@ function writeCesiumPreviewSupportFiles(outDir) {
}
}
function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, areaId, vehicleModelNames = [], trafficSignalsName = null) {
function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, areaId, vehicleModelNames = [], trafficSignalsName = null, previewDescriptorName = null) {
const previewConfig = {
areaId,
glbName,
@@ -22,6 +22,7 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
vehicleModelName,
vehicleModelNames,
trafficSignalsName,
previewDescriptorName,
};
return `<!doctype html>
<html lang="zh-CN">
@@ -105,10 +106,10 @@ function escapeScriptJson(value) {
function previewSummary(area) {
const route = JSON.parse(fs.readFileSync(area.outputs.vehicleRoute, "utf8"));
return {
glbName: path.basename(area.outputs.glb),
metadataName: path.basename(area.outputs.metadata),
routeName: path.basename(area.outputs.vehicleRoute),
vehicleModelName: path.basename(area.outputs.vehicleModel),
glbName: "package/manifest.json",
metadataName: "package/manifest.json",
routeName: path.relative(area.outputs.areaDir, area.outputs.vehicleRoute).split(path.sep).join("/"),
vehicleModelName: path.relative(area.outputs.areaDir, area.outputs.vehicleModel).split(path.sep).join("/"),
trafficSignalsName: path.relative(area.outputs.areaDir, area.outputs.trafficSignals).split(path.sep).join("/"),
routeSegments: Array.isArray(route.segments) ? route.segments.length : null,
};

View File

@@ -4,17 +4,19 @@ const STAGES = [
{ id: "intermediates", label: "OSM / QGIS intermediates", description: "Rebuild GeoJSON and GeoPackage from OSM" },
{ id: "reimport", label: "Reimport QGIS edits", description: "Copy GeoPackage layers back to GeoJSON" },
{ id: "blender", label: "Blender scene", description: "Generate the editable scene and render" },
{ id: "cesium", label: "Cesium export", description: "Export GLB, metadata, and preview" },
{ id: "preview", label: "Preview refresh", description: "Regenerate preview HTML and vehicles" },
{ id: "compress", label: "Compress delivery assets", description: "Replace standard GLB, metadata, and preview with compressed deliverables" },
{ id: "cesium", label: "Cesium export", description: "Export static package staging assets" },
{ id: "compress", label: "Compress staged package", description: "Compress the staged primary GLB" },
{ id: "package", label: "Publish asset package", description: "Validate and atomically publish package/" },
{ id: "preview", label: "Preview refresh", description: "Regenerate local verification preview and dynamic assets" },
];
const ALIASES = {
all: ["intermediates", "blender", "cesium", "compress"],
all: ["intermediates", "blender", "cesium", "compress", "package", "preview"],
qgis: ["intermediates"], osm2streets: ["intermediates"], geojson: ["intermediates"], intermediate: ["intermediates"],
intermediates: ["intermediates"], reimport: ["reimport"], gpkg: ["reimport"], blender: ["blender"], scene: ["blender"],
cesium: ["cesium"], glb: ["cesium"], preview: ["preview"], html: ["preview"], cesiumPreview: ["preview"],
compress: ["compress"], compression: ["compress"], compressedCesium: ["compress"],
package: ["package"], publish: ["package"], assetPackage: ["package"],
};
function resolveStages(defaults, requested) {

View File

@@ -40,6 +40,8 @@
async function main() {
setLoadingMessage("Loading scene", config.areaId || "");
const metadata = await fetchJson(config.metadataName);
const descriptor = config.previewDescriptorName ? await fetchOptionalJson(config.previewDescriptorName) : null;
adaptPackageManifest(metadata, descriptor);
const routeData = await fetchOptionalJson(config.routeName);
const signalData = await fetchOptionalJson(config.trafficSignalsName);
const placement = scenePlacement(metadata);
@@ -145,11 +147,11 @@
}
function scenePlacement(metadata) {
const anchor = metadata.anchor || {};
const anchor = metadata.anchor || metadata.placement || {};
const longitude = Number(anchor.longitude || 0);
const latitude = Number(anchor.latitude || 0);
const height = Number(anchor.height || 0);
const heading = Number(metadata.heading_correction_degrees || 0);
const heading = Number(metadata.heading_correction_degrees ?? metadata.placement?.headingCorrectionDegrees ?? 0);
const position = Cesium.Cartesian3.fromDegrees(longitude, latitude, height);
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(position);
const correction = Cesium.Matrix3.fromRotationZ(Cesium.Math.toRadians(heading));
@@ -164,6 +166,10 @@
}
function normalizedAssets(metadata) {
if (metadata.schema === "osm-asset-package/v1") {
const staticAssets = metadata.assets.map((asset) => ({ id: asset.id, label: asset.id, type: "model", url: asset.uri, enabled: asset.defaultLoad, category: asset.category, semantic: asset.role === "layer" }));
return staticAssets.concat(metadata.previewAssets || []);
}
const assets = Array.isArray(metadata.assets) && metadata.assets.length
? metadata.assets
: [{
@@ -176,6 +182,15 @@
return assets.filter((asset) => (asset.type || "model") === "model" && asset.url);
}
function adaptPackageManifest(metadata, descriptor) {
if (metadata.schema !== "osm-asset-package/v1") return;
metadata.anchor = metadata.placement;
metadata.heading_correction_degrees = metadata.placement.headingCorrectionDegrees;
metadata.scene_stats = metadata.sceneStats || {};
metadata.bounds = { min_lon: metadata.bounds.minLon, min_lat: metadata.bounds.minLat, max_lon: metadata.bounds.maxLon, max_lat: metadata.bounds.maxLat };
if (descriptor?.assets) metadata.previewAssets = descriptor.assets;
}
// One broken entry in metadata.assets should not blank the whole preview, so
// failures are collected and surfaced in the diagnostics panel instead.
async function loadSceneAssets(viewer, metadata, placement) {
@@ -187,7 +202,7 @@
loaded.push(entry);
// Semantic assets are inspection aids. Defer their network and GPU cost
// until the user explicitly switches out of the normal scene view.
if (entry.category === "semantic") continue;
if (entry.semantic || entry.enabled === false) continue;
await loadAsset(viewer, entry, placement);
}
if (!loaded.some((asset) => asset.model && asset.category !== "semantic")) {

View File

@@ -0,0 +1,66 @@
"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 };