diff --git a/bin/road-compiler.js b/bin/road-compiler.js index d5b69cb..493f7f8 100755 --- a/bin/road-compiler.js +++ b/bin/road-compiler.js @@ -4,13 +4,17 @@ const fs = require("fs"); const path = require("path"); const { compiler } = require("../src"); +const { exportNativeRoadPackage } = require("../src/export/native-road-package"); const args = process.argv.slice(2); const index = args.indexOf("--input"); -if (index < 0 || !args[index + 1] || index + 2 !== args.length) { - throw new Error("Usage: road-compiler --input "); +const exportIndex = args.indexOf("--export-zip"); +const expectedLength = exportIndex >= 0 ? 4 : 2; +if (index < 0 || !args[index + 1] || (exportIndex >= 0 && !args[exportIndex + 1]) || args.length !== expectedLength) { + throw new Error("Usage: road-compiler --input [--export-zip ]"); } const inputFile = path.resolve(args[index + 1]); const input = JSON.parse(fs.readFileSync(inputFile, "utf8")); const { result, comparison } = compiler.compileInput(input); +if (exportIndex >= 0) exportNativeRoadPackage(input.outDir, path.resolve(args[exportIndex + 1])); console.log(`NATIVE_ROAD_COMPILE_DONE ${JSON.stringify({ areaId: input.areaId, roads: result.model.roads.length, endpoints: result.model.endpoints.length, diagnostics: result.diagnostics.length, output: input.outDir, comparison })}`); diff --git a/docs/native-road-package-v1.md b/docs/native-road-package-v1.md index 98ab031..c33fc39 100644 --- a/docs/native-road-package-v1.md +++ b/docs/native-road-package-v1.md @@ -73,6 +73,20 @@ shape and owns all path derivation. The editable signal source at `trafficSignalsFile` is a sibling of `outDir`. It is included in the parity baseline because regeneration must be stable. +## ZIP Export + +The Web workbench's **导出道路包** action and the CLI's optional +`--export-zip ` emit the portable package form. The ZIP root is +flat: it contains the six JSON documents above and `layers/*.geojson`, without +an enclosing area directory. It contains no OSM input, overrides, or compiler +workspace paths. `compiled.json` retains only model and movement data needed by +the downstream preview; its internal `source` paths are removed. + +The ZIP manifest adds informational `generator: { name, version }`. Consumers +must select compatibility only from `contract`, `areaId`, and declared files. +Entry order and timestamp are fixed, so unchanged compiled output exports to +the same ZIP hash. + Successful CLI execution prints exactly one completion marker: ```text diff --git a/package-lock.json b/package-lock.json index 8d7bb8b..25a95be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,14 @@ { "name": "@osm-asset/road-compiler", - "version": "0.1.0", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@osm-asset/road-compiler", - "version": "0.1.0", + "version": "0.2.2", "dependencies": { + "fflate": "0.8.3", "ol": "10.10.0" }, "bin": { diff --git a/package.json b/package.json index 7f0fd3b..c0b007d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@osm-asset/road-compiler", - "version": "0.2.2", + "version": "0.3.0", "private": false, "type": "commonjs", "main": "src/index.js", @@ -9,9 +9,11 @@ }, "scripts": { "test": "node test/index.js && node test/fixtures.js", - "road:workbench": "node bin/road-workbench.js" + "road:workbench": "node bin/road-workbench.js", + "road:export": "node bin/road-compiler.js" }, "dependencies": { + "fflate": "0.8.3", "ol": "10.10.0" } } diff --git a/src/export/native-road-package.js b/src/export/native-road-package.js new file mode 100644 index 0000000..0a6582b --- /dev/null +++ b/src/export/native-road-package.js @@ -0,0 +1,56 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { zipSync } = require("fflate"); +const { LAYER_REGISTRY, validatePublishedLayers } = require("../compile/layer-manifest"); + +const ROOT_FILES = [ + "manifest.json", + "compiled.json", + "diagnostics.json", + "comparison.json", + "traffic-signal-assemblies.json", + "traffic-signals.json", +]; + +const GENERATOR = Object.freeze({ name: "road-compiler", version: "0.3.0" }); + +function packageEntries(directory) { + const manifestPath = path.join(directory, "manifest.json"); + if (!fs.existsSync(manifestPath)) throw new Error(`Native road package manifest is missing: ${manifestPath}`); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + if (manifest.contract !== "native-road-package/v1.1") throw new Error("Native road package requires native-road-package/v1.1."); + if (!manifest.areaId || typeof manifest.areaId !== "string") throw new Error("Native road package manifest areaId is required."); + validatePublishedLayers(directory, manifest); + const files = []; + for (const relative of ROOT_FILES) files.push(relative); + for (const layer of LAYER_REGISTRY) files.push(`layers/${layer.source}.geojson`); + for (const relative of files) { + const file = path.join(directory, relative); + if (!fs.existsSync(file) || !fs.statSync(file).isFile()) throw new Error(`Native road package file is missing: ${relative}`); + } + return { manifest, files: [...new Set(files)].sort() }; +} + +function exportNativeRoadPackage(directory, destination = null) { + const { manifest, files } = packageEntries(directory); + const entries = Object.fromEntries(files.map((relative) => [relative, fs.readFileSync(path.join(directory, relative))])); + // The model/movements remain needed by host preview generation, but absolute + // compiler-workspace paths are not part of the exported package contract. + const compiled = JSON.parse(entries["compiled.json"].toString("utf8")); + delete compiled.source; + entries["compiled.json"] = Buffer.from(`${JSON.stringify(compiled, null, 2)}\n`); + entries["manifest.json"] = Buffer.from(`${JSON.stringify({ ...manifest, generator: GENERATOR }, null, 2)}\n`); + // ZIP timestamps start at 1980; a fixed value keeps repeated exports byte-stable. + const bytes = zipSync(entries, { level: 6, mtime: new Date("1980-01-01T00:00:00Z") }); + if (destination) { + fs.mkdirSync(path.dirname(destination), { recursive: true }); + const temporary = `${destination}.tmp-${process.pid}`; + fs.writeFileSync(temporary, bytes); + fs.renameSync(temporary, destination); + } + return { bytes, manifest, files }; +} + +module.exports = { ROOT_FILES, GENERATOR, packageEntries, exportNativeRoadPackage }; diff --git a/src/index.js b/src/index.js index 8744b32..3f66f11 100644 --- a/src/index.js +++ b/src/index.js @@ -10,6 +10,7 @@ module.exports = { nativeTrafficSignals: require("./native-traffic-signals"), nativeRoad: require("./compile/native-road"), layerManifest: require("./compile/layer-manifest"), + nativeRoadPackage: require("./export/native-road-package"), compiler: require("./compile/compiler"), check: require("./check"), }; diff --git a/test/fixtures.js b/test/fixtures.js index 239f3da..acc2794 100644 --- a/test/fixtures.js +++ b/test/fixtures.js @@ -1,9 +1,13 @@ "use strict"; const assert = require("assert/strict"); +const crypto = require("crypto"); +const { execFileSync } = require("child_process"); const fs = require("fs"); const path = require("path"); +const { unzipSync, strFromU8 } = require("fflate"); const { compiler } = require("../src"); +const { exportNativeRoadPackage } = require("../src/export/native-road-package"); const root = path.resolve(__dirname, ".."); for (const areaId of ["fengshu-er-road", "nantaizi-lake-innovation-valley"]) { @@ -23,5 +27,23 @@ for (const areaId of ["fengshu-er-road", "nantaizi-lake-innovation-valley"]) { const { result } = compiler.compileInput(input); assert.equal(result.areaId, areaId); assert.ok(fs.existsSync(path.join(input.outDir, "compiled.json"))); + const first = exportNativeRoadPackage(input.outDir); + const second = exportNativeRoadPackage(input.outDir); + assert.equal(crypto.createHash("sha256").update(first.bytes).digest("hex"), crypto.createHash("sha256").update(second.bytes).digest("hex")); + const entries = unzipSync(first.bytes); + const manifest = JSON.parse(strFromU8(entries["manifest.json"])); + assert.equal(manifest.areaId, areaId); + assert.equal(manifest.contract, "native-road-package/v1.1"); + assert.deepEqual(manifest.generator, { name: "road-compiler", version: "0.3.0" }); + assert.equal("source" in JSON.parse(strFromU8(entries["compiled.json"])), false); + assert.equal(Object.keys(entries).some((entry) => entry.includes("override") || entry.endsWith(".osm")), false); + assert.equal(Object.keys(entries).length, 18); + if (isFengshu) { + const inputFile = path.join(outputRoot, "road-compiler-input.json"); + const archive = path.join(outputRoot, "export.zip"); + fs.writeFileSync(inputFile, `${JSON.stringify(input)}\n`); + execFileSync(process.execPath, [path.join(root, "bin", "road-compiler.js"), "--input", inputFile, "--export-zip", archive]); + assert.ok(fs.existsSync(archive)); + } } console.log("road compiler fixture tests passed"); diff --git a/workbench/client/index.html b/workbench/client/index.html index 528b28e..cfcc79c 100644 --- a/workbench/client/index.html +++ b/workbench/client/index.html @@ -1,4 +1,4 @@ 道路编译工作台 -
道路编译工作台
+
道路编译工作台导出道路包
diff --git a/workbench/server.js b/workbench/server.js index 9957fe0..276641e 100644 --- a/workbench/server.js +++ b/workbench/server.js @@ -7,6 +7,7 @@ const path = require("path"); const { loadOverrides, validateOverrides, writeJsonAtomic } = require("../src/compile/native-road"); const { generate, validateDocument, runtime } = require("../src/native-traffic-signals"); const { convertGeoJson } = require("../src/reference/gaode"); +const { exportNativeRoadPackage } = require("../src/export/native-road-package"); function startWorkbench({ area, configPath, repoRoot, compileFresh, readAreaConfig, junctionReference = null, debug = false, port = 8787 }) { if (typeof junctionReference === "string") junctionReference = readJunctionReference(junctionReference); @@ -31,6 +32,16 @@ function handle(request, response, area, context, junctionReference, debug = fal if (request.method === "GET" && url.pathname === "/app.css") return sendFile(response, path.join(__dirname, "client", "app.css"), "text/css; charset=utf-8"); if (request.method === "GET" && url.pathname.startsWith("/vendor/")) return sendVendorFile(response, url.pathname, context.repoRoot); if (request.method === "GET" && url.pathname === "/api/state") return sendJson(response, 200, state(area, junctionReference, debug)); + if (request.method === "GET" && url.pathname === "/api/export.zip") return Promise.resolve().then(() => { + const exported = exportNativeRoadPackage(area.outputs.nativeRoadDir); + response.writeHead(200, { + "Content-Type": "application/zip", + "Content-Disposition": `attachment; filename="${area.id}.native-road.zip"`, + "Content-Length": exported.bytes.length, + "Cache-Control": "no-store", + }); + response.end(Buffer.from(exported.bytes)); + }).catch((error) => sendJson(response, 400, { ok: false, error: error.message })); if (request.method === "POST" && url.pathname === "/api/traffic-signals") return readBody(request).then((body) => { const document = validateDocument(body, fs.readFileSync(area.input, "utf8")); writeJsonAtomic(area.outputs.nativeTrafficSignals, document);