ReviewOS

also looking at this

stacks/drone

feat: import LAS surfaces and consume maintained source releases

#107
Merged chris wants to merge codex/las-stockpile-processing into main
35 files +1061 -416
.github/workflows/ci.ymlmodified+12-0
Changes to .github/workflows/ci.yml
@@ -389,6 +389,18 @@ jobs:
389389 working-directory: ${{ runner.temp }}
390390 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone volumes:import "$GITHUB_WORKSPACE/examples/stockpile/surface.asc" --format asc --metadata "$GITHUB_WORKSPACE/examples/stockpile/metadata.json" --measurement "$GITHUB_WORKSPACE/examples/stockpile/measurement.json" --output "$RUNNER_TEMP/volume-grid"
391391 working-directory: ${{ runner.temp }}
392 - name: Qualify copied LAS point-grid source and volume evidence
393 run: bun scripts/qualify-las-cli.ts "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone" "$RUNNER_TEMP/las-stockpile-cli"
394 - uses: actions/upload-artifact@v4
395 if: always()
396 with:
397 name: las-stockpile-cli-evidence
398 if-no-files-found: ignore
399 path: |
400 ${{ runner.temp }}/las-stockpile-cli/*.stdout
401 ${{ runner.temp }}/las-stockpile-cli/*.stderr
402 ${{ runner.temp }}/las-stockpile-cli/receipt.json
403 ${{ runner.temp }}/las-stockpile-cli/dataset/**
392404 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone inspection:plan "$GITHUB_WORKSPACE/examples/inspection.json"
393405 working-directory: ${{ runner.temp }}
394406 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone inspection:simulate --visit before --output "$RUNNER_TEMP/inspection-before"
AGENTS.mdmodified+2-0
Changes to AGENTS.md
@@ -18,6 +18,8 @@ This is the `stacksjs/drone` Stacks application. Read `README.md` for implemente
1818- Imported TypeScript modules explicitly import their dependencies. Browser entry bindings do not leak into modules.
1919- `better-dx` supplies shared dev tooling; do not redeclare its tools. Keep Bun's hoisted linker.
2020- No `catalog:` dependency ranges. Dependency updates belong to buddy-bot.
21- Dependency fixes and upgrades: read `docs/dependency-source-releases.md`.
22 Fix the owning source and consume its qualified release; dependency patches are prohibited.
2123- No em-dash or separator en-dash in newly authored visible copy.
2224- Before finishing run `./buddy lint`, `./buddy typecheck`, `./buddy test` and relevant browser scenarios.
2325- Test behavioral seams: decision input/output, controller lifecycle, real local UDP and protocol fixtures.
README.mdmodified+4-0
Changes to README.md
@@ -243,6 +243,10 @@ it keeps acquisition, reconstruction and measurement separate.
243243./buddy volumes:import examples/stockpile/surface.asc --format asc --metadata examples/stockpile/metadata.json --measurement examples/stockpile/measurement.json
244244```
245245
246A separate [LAS point-grid adapter](docs/volumes-las.md) constructs declared cell means
247from selected LAS1.2 UTM points and retains the original file and gridding evidence.
248Missing cells and unknown representation error remain explicit.
249
246250Read the [numeric import, integration and audit contract](docs/volumes.md). Examples are synthetic;
247251physical surveyed accuracy, raw-point meshing and richer format adapters remain unverified.
248252
app/Commands/VolumesImport.tsmodified+30-11
Changes to app/Commands/VolumesImport.ts
@@ -1,44 +1,63 @@
11import { defineCommand } from '@stacksjs/cli'
2import { print, printError } from '@stacksjs/cli/output'
2import { printAsync, printErrorAsync } from '../../resources/functions/cli/output'
33import { importGridSurface, importPlySurface } from '../../resources/functions/drone/volumes/import'
44import { importGeoTiffSurface } from '../../resources/functions/drone/volumes/geotiff'
5import { readGeoTiffFile } from '../../resources/functions/drone/volumes/files'
5import { readGeoTiffFile, readLasFile } from '../../resources/functions/drone/volumes/files'
6import { importLasSurface } from '../../resources/functions/drone/volumes/las'
67import { processStockpile, validateSurfaceMetadata, validateVolumeRequest } from '../../resources/functions/drone/volumes/engine'
78import { writeVolumeDataset } from '../../resources/functions/drone/volumes/export'
89import { readMissionFile } from '../../resources/functions/drone/missions/files'
910
11const lasDiagnosticCodes = new Set([
12 'las-surface-input', 'las-surface-budget', 'las-surface-unavailable',
13 'las-surface-no-supported-cells', 'las-source-sha256-mismatch',
14 'las-file-unavailable', 'las-dataset-source-input', 'las-dataset-source-mismatch',
15])
16function lasDiagnostic(error: unknown): string {
17 const message = error instanceof Error ? error.message : ''
18 return lasDiagnosticCodes.has(message) ? message : 'las-import-unavailable'
19}
20
1021function parseDocument(text: string): unknown {
1122 try { return JSON.parse(text) }
1223 catch { throw new TypeError('Invalid surface metadata or measurement JSON') }
1324}
1425export default defineCommand({
1526 name: 'volumes:import <surface>',
16 description: 'Import an ASC grid, indexed ASCII PLY or declared numeric GeoTIFF with a measurement contract',
27 description: 'Import ASC, indexed PLY, declared GeoTIFF or a declared LAS point grid with a measurement contract',
1728 options: {
18 '--format <name>': 'Required: asc, ply or geotiff',
19 '--metadata <path>': 'Surface metadata for asc/ply, or a wrapped elevation declaration for geotiff',
29 '--format <name>': 'Required: asc, ply, geotiff or las',
30 '--metadata <path>': 'Surface metadata for asc/ply, or a wrapped declaration for geotiff/las',
2031 '--measurement <path>': 'Required JSON: version, id, boundary, base and reference',
2132 '--output <directory>': 'New private audit dataset',
2233 },
2334 async handle(options, path) {
2435 try {
2536 const geotiff = options.format === 'geotiff'
26 if ((!geotiff && !['asc', 'ply'].includes(options.format ?? '')) || !options.metadata || !options.measurement) throw new TypeError('Specify --format asc|ply, --metadata and --measurement')
27 const metadata = geotiff
37 const las = options.format === 'las'
38 if (!['asc', 'ply', 'geotiff', 'las'].includes(options.format ?? '') || !options.metadata || !options.measurement) throw new TypeError('Specify --format asc|ply|geotiff|las, --metadata and --measurement')
39 const metadata = geotiff || las
2840 ? await readMissionFile(options.metadata, { preserveBom: true, maxBytes: 32768 })
2941 : validateSurfaceMetadata(parseDocument(await readMissionFile(options.metadata)))
3042 const measurement = parseDocument(await readMissionFile(options.measurement))
3143 if (!measurement || typeof measurement !== 'object' || Array.isArray(measurement) || Object.keys(measurement).length !== 5 || Object.keys(measurement).some(key => !['version', 'id', 'boundary', 'base', 'reference'].includes(key))) throw new TypeError('Measurement must declare exactly version, id, boundary, base and reference')
32 const surface = typeof metadata === 'string'
44 const lasBytes = las ? await readLasFile(path) : null
45 const lasImport = lasBytes && typeof metadata === 'string' ? await importLasSurface(lasBytes, metadata) : null
46 const surface = lasImport ? lasImport.surface : typeof metadata === 'string'
3347 ? await importGeoTiffSurface(await readGeoTiffFile(path), metadata)
3448 : options.format === 'asc'
3549 ? await importGridSurface(await readMissionFile(path, { preserveBom: true }), metadata)
3650 : await importPlySurface(await readMissionFile(path, { preserveBom: true }), metadata)
3751 const request = validateVolumeRequest({ ...measurement, surface })
3852 const { integrationPieces, ...report } = processStockpile(request)
39 const dataset = options.output ? await writeVolumeDataset(options.output, request) : null
40 print(JSON.stringify({ report, contributionCount: integrationPieces.length, dataset }, null, 2))
53 const dataset = options.output ? await writeVolumeDataset(options.output, request, lasBytes && typeof metadata === 'string' ? { format: 'las', bytes: lasBytes, declarationJson: metadata } : undefined) : null
54 const sourceProcessing = lasImport ? Object.fromEntries(Object.entries(lasImport.processing).filter(([key]) => key !== 'cells')) : null
55 await printAsync(JSON.stringify({ report, contributionCount: integrationPieces.length, dataset, ...(sourceProcessing ? { sourceProcessing } : {}) }, null, 2))
56 }
57 catch (error) {
58 process.exitCode = 1
59 try { await printErrorAsync(options.format === 'las' ? lasDiagnostic(error) : error instanceof Error ? error.message : 'Volume import unavailable') }
60 catch { /* Preserve failure when the diagnostic pipe is closed. */ }
4161 }
42 catch (error) { printError(error instanceof Error ? error.message : String(error)); process.exitCode = 1 }
4362 },
4463})
bun.lockmodified+4-7
Changes to bun.lock
@@ -25,7 +25,7 @@
2525 "@tufjs/canonical-json": "2.0.0",
2626 "stacks": "^0.75.221",
2727 "ts-images": "0.2.31",
28 "tuf-js": "6.0.0",
28 "tuf-js": "https://github.com/stacksjs/tuf-js/releases/download/sdk-v6.0.1/stacksjs-tuf-js-6.0.1.tgz",
2929 },
3030 "devDependencies": {
3131 "@stacksjs/query-builder": "0.75.221",
@@ -38,11 +38,8 @@
3838 "version": "0.75.221",
3939 },
4040 },
41 "patchedDependencies": {
42 "tuf-js@6.0.0": "patches/tuf-js@6.0.0.patch",
43 },
4441 "overrides": {
45 "@stacksjs/bunpress": "0.2.22",
42 "@stacksjs/bunpress": "0.2.25",
4643 },
4744 "packages": {
4845 "@buddysh/buddy": ["@buddysh/buddy@0.11.13", "", { "dependencies": { "prompts": "^2.4.2", "ts-pantry": "^0.11.64" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.117.1 <0.123.0" }, "optionalPeers": ["@anthropic-ai/sdk"], "bin": { "buddy": "./dist/bin/cli.js" } }, "sha512-xES8SfgtsXeswuQmFLVj/YV0nOECA4LGl7Oe2ZBqh6otABQY7LT7hAcz9KD/ORvVWtVr+tUt2sA+Vgd5hsm+Eg=="],
@@ -93,7 +90,7 @@
9390
9491 "@stacksjs/bun-router": ["@stacksjs/bun-router@0.1.24", "", { "dependencies": { "@stacksjs/clapp": "^0.2.0", "ts-rate-limiter": "^0.4.2" }, "peerDependencies": { "@stacksjs/stx": ">=0.2.227" }, "optionalPeers": ["@stacksjs/stx"], "bin": { "router": "./dist/cli.js", "bun-router": "./dist/cli.js" } }, "sha512-vXzpmYBe9o0NOVWAZ1kqZJv7VAKTd84rEs3e8e+oEeX3Q4zE078SHTxrYsNcr2n1tb8EwQawaC4YGmFFKrgrzA=="],
9592
96 "@stacksjs/bunpress": ["@stacksjs/bunpress@0.2.22", "", { "dependencies": { "@cwcss/crosswind": "^0.2.16", "@stacksjs/clapp": "^0.2.0", "@stacksjs/stx": "^0.2.550", "@stacksjs/ts-i18n": "^0.1.11", "bunfig": "^0.15.17", "ts-syntax-highlighter": "^0.2.14" }, "peerDependencies": { "@stacksjs/ts-cloud": "^0.7.51" }, "optionalPeers": ["@stacksjs/ts-cloud"], "bin": { "bunpress": "./dist/bin/cli.js" } }, "sha512-xxyLY7rU5v0VGDzv+7AzpFWPMEhMVQn6vZ6FrrAsmb2NShc5MfowO5yig1I3YYdzrSrHH/pDxETUtpC9f8X84A=="],
93 "@stacksjs/bunpress": ["@stacksjs/bunpress@0.2.25", "", { "dependencies": { "@cwcss/crosswind": "^0.2.16", "@stacksjs/clapp": "^0.2.0", "@stacksjs/stx": "^0.2.550", "@stacksjs/ts-i18n": "^0.1.11", "bunfig": "^0.15.17", "ts-syntax-highlighter": "^0.2.14" }, "peerDependencies": { "@stacksjs/ts-cloud": "^0.7.51" }, "optionalPeers": ["@stacksjs/ts-cloud"], "bin": { "bunpress": "./dist/bin/cli.js" } }, "sha512-4mzXXQlCgMbkFe4Re6jAcKyGE2hSevpPFBbIytBdrrpEhCDl3VR5UH0v6Ei73wh3jkPAtxWBbJu4B7f72nqrsQ=="],
9794
9895 "@stacksjs/cache": ["@stacksjs/cache@0.75.221", "", { "dependencies": { "@stacksjs/ts-cache": "^0.1.7" } }, "sha512-D4B2tkif9FISL+5xaMff1Ceu2A1iSlMPLyLbgHGzWuqp2mR8KoB/8Ilpk5uq0Bd/xTf63U9opxkQvKlOr1jkTA=="],
9996
@@ -465,7 +462,7 @@
465462
466463 "ts-videos": ["ts-videos@0.1.2", "", { "dependencies": { "bunfig": "^0.15.6" }, "optionalDependencies": { "@ts-videos/mp4": "0.1.2", "@ts-videos/webm": "0.1.2" }, "bin": { "ts-videos": "./dist/bin/cli.js" } }, "sha512-J3s3hd6om0y7f7cANlCY72dTYReS/V/D6x3jKUQa358Y1cw5hdggF5LV/6BAYjhMFK5m6Ss7x2GbPyvKmoNimQ=="],
467464
468 "tuf-js": ["tuf-js@6.0.0", "", { "dependencies": { "@gar/promise-retry": "^1.0.3", "@tufjs/models": "5.0.0", "debug": "^4.4.3" } }, "sha512-zlJVOIO68hmgo1//X4ENEcTGfuOTAtDPi8PsTsG+FyxD85E/ww1ZnwBbWo/yCEExGpI+Kilg7Z3qCdHX2BoJTQ=="],
465 "tuf-js": ["@stacksjs/tuf-js@https://github.com/stacksjs/tuf-js/releases/download/sdk-v6.0.1/stacksjs-tuf-js-6.0.1.tgz", { "dependencies": { "@gar/promise-retry": "^1.0.3", "@tufjs/models": "5.0.0", "debug": "^4.4.3" } }, "sha512-E24zEwH9SkFXyxWBUESJ+D0uTHO+4lXOHyYft1tNoEBrNuJe08rUK2xW2rcMYg06WaQOFrcYttm2fDCPK7NWQQ=="],
469466
470467 "typescript": ["typescript@7.1.0-dev.20261011.1", "", { "optionalDependencies": { "@typescript/typescript-darwin-arm64": "7.1.0-dev.20261011.1", "@typescript/typescript-darwin-x64": "7.1.0-dev.20261011.1", "@typescript/typescript-linux-arm": "7.1.0-dev.20261011.1", "@typescript/typescript-linux-arm64": "7.1.0-dev.20261011.1", "@typescript/typescript-linux-x64": "7.1.0-dev.20261011.1", "@typescript/typescript-win32-arm64": "7.1.0-dev.20261011.1", "@typescript/typescript-win32-x64": "7.1.0-dev.20261011.1" }, "bin": { "tsc": "bin/tsc" } }, "sha512-Oestzc61TT5cqxAyVxRsbL+pmMF8pzPlsnLcbCqAWqvC0eYQcp8EJrLWatIgjdUh4RiL79avUCHfM8hdkv07xg=="],
471468
config/docs.tsmodified+1-0
Changes to config/docs.ts
@@ -102,6 +102,7 @@ const config: BunPressOptions = {
102102 items: [
103103 { text: 'Aerial survey collection', link: '/mapping' },
104104 { text: 'Known-surface stockpile volumes', link: '/volumes' },
105 { text: 'LAS point-grid processing', link: '/volumes-las' },
105106 { text: 'Owned asset inspection', link: '/inspection' },
106107 { text: 'Owned agriculture sensing', link: '/agriculture' },
107108 { text: 'Open Farming integration', link: '/open-farming' },
docs/api.mdmodified+6-0
Changes to docs/api.md
@@ -78,6 +78,12 @@ transport cancellation/recovery cases. The [operator contract](cargo-fleet.md#re
7878retains current authority, a single physical point, original receive deadlines and
7979attempted-intake uncertainty. It does not restore a failed vehicle session.
8080
81`importLasSurface(bytes, declarationJson)` in `volumes/las.ts` produces a declared
82cell-mean surface and separate point-selection/gridding audit. The [LAS contract](volumes-las.md)
83describes profile limits, missing cells and unknown representation error. The server-only
84`writeVolumeDataset(directory, request, { format: 'las', bytes, declarationJson })` verifies
85that the retained original bytes/declaration reproduce the requested surface before publishing.
86
8187## Target sources
8288
8389`createGpsTargetSource({ targetId, origin, maxSpeedMps?, now? })` converts trusted phone or GNSS fixes
docs/dependency-source-releases.mdadded+43-0
Changes to docs/dependency-source-releases.md
@@ -0,0 +1,43 @@
1# Dependency source releases
2
3Dependencies must come from maintained source releases. Dependency patches and
4edits to installed package files are prohibited. Fix and test the owning source
5repository, run its `bun run release:patch` command, then qualify the released
6package in this application with a native frozen install.
7
8## Maintained TUF client
9
10The `tuf-js` dependency key points to the immutable
11[`@stacksjs/tuf-js` 6.0.1 release archive](https://github.com/stacksjs/tuf-js/releases/tag/sdk-v6.0.1).
12The source is maintained in [stacksjs/tuf-js](https://github.com/stacksjs/tuf-js),
13with release source commit `40a7992babc29eb99c3ee18290ef7aa438421c08`.
14This is a maintained fork of the MIT-licensed upstream client, distinct from the
15upstream npm package. Its original license is retained in the installed package
16and `licenses/tuf-js.LICENSE` in this repository. The
17[SDK source license](https://github.com/stacksjs/tuf-js/blob/sdk-v6.0.1/packages/client/LICENSE)
18retains the original attribution.
19
20The release publishes its package archive, `SHA512SUMS` and `source.json`.
21Verify the immutable release identity, source commit and archive integrity before
22installation. Retain the exact release URL and frozen lockfile. The existing
23`tuf-js` import name resolves to the scoped maintained package through that
24dependency key. The SDK source repository's CLI and conformance harness use the
25scoped name explicitly.
26
27The observer, copied reference time and typed failure contracts are documented
28in [runtime updates](runtime-updates.md#maintained-sdk-persistence-seam). Do not
29remove their verification or persistence behavior during an upgrade. Qualify
30SDK observer cases, enrollment, persistent store/recovery, interrupted updates,
31rollback floors and copied standalone CLI campaigns against the actual release.
32Do not claim physical firmware deployment from these synthetic tests.
33
34## Documentation engine
35
36BunPress 0.2.25 includes the deployed-path search fix from
37[source commit 096004cc6e7b4800bfd1e90993866c9011ca706d](https://github.com/stacksjs/bunpress/commit/096004cc6e7b4800bfd1e90993866c9011ca706d).
38Consume the source release rather than editing installed files. The public
39search regressions in `tests/unit/docs-search.test.ts`, native documentation
40build and actual browser search selection qualify the application integration.
41
42[Issue #108](https://github.com/stacksjs/drone/issues/108) tracks source-release
43migration and downstream qualification evidence.
docs/index.mdmodified+1-1
Changes to docs/index.md
@@ -78,7 +78,7 @@ See the [scenario commands and expected outcomes](vehicles-vtol.md) before compa
7878|---|---|---|
7979| Camera tracking and follow | Explicit target selection, image-space loss handling and synthetic calibrated fusion | [Tracking](camera-tracking.md), [fusion](vision.md) |
8080| Aerial mapping | Survey planning, synthetic acknowledged capture and dataset export; reconstruction remains open | [Survey collection](mapping.md) |
81| Stockpile volumes | Numeric grids/TINs, declared base surfaces, cut/fill and uncertainty; acquisition accuracy is separate | [Volume processing](volumes.md) |
81| Stockpile volumes | Numeric grids/TINs and explicit LAS cell means, declared base surfaces, cut/fill and uncertainty; acquisition accuracy is separate | [Volume processing](volumes.md) |
8282| Infrastructure inspection | Asset-relative modeled capture, explicit observations and repeat-visit comparison | [Inspection](inspection.md) |
8383| Farming | Synthetic crop sensing and declared planning previews; agronomy and authenticated app integration remain separate | [Agriculture](agriculture.md), [Open Farming](open-farming.md) |
8484| Prescribed application | Owned ground simulation for inert water or granules, with explicit implement authority | [Application](application.md) |
docs/roadmap/feature-coverage.mdmodified+1-1
Changes to docs/roadmap/feature-coverage.md
@@ -23,7 +23,7 @@ means there is no native implementation yet. A protocol-compatible transport is
2323| Antenna pan/tilt and telemetry pointing | Owned selected-target tracking, calibrated geometry, bounded trajectories and modeled mount bench; physical/RF validation pending | Not exposed | [#16](https://github.com/stacksjs/drone/issues/16) |
2424| Calibrated 3D visual position and paired target fusion | Owned calibrated lens/crop/mount import, registered-depth localization, explicit GNSS pairing, conservative covariance intersection and sensor-to-control physics; measured calibration, heading, depth adapters and hardware campaigns pending | Not exposed | [#17](https://github.com/stacksjs/drone/issues/17) |
2525| Aerial survey, overlap/GSD, photogrammetry collection and georeferencing | Owned clipped survey planning, capture-bound synthetic PNG collection and auditable export; reconstruction, measured calibration and field accuracy pending | Not exposed | [#18](https://github.com/stacksjs/drone/issues/18) |
26| LiDAR, surface processing and stockpile volume/uncertainty | Owned known-TIN/cell-grid clipping, explicit base/cut/fill, gaps, conditional bounds, reference comparison, ASC/PLY and audit export; meshing, richer formats and physical survey qualification pending | Not exposed | [#19](https://github.com/stacksjs/drone/issues/19) |
26| LiDAR, surface processing and stockpile volume/uncertainty | Owned known-TIN/cell-grid clipping, explicit base/cut/fill, gaps, conditional bounds, reference comparison, ASC/PLY, declared numeric GeoTIFF and LAS1.2 selected-point cell means with retained source/gridding audit; broader LAS/LAZ, point triangulation and physical survey qualification pending | Not exposed | [#19](https://github.com/stacksjs/drone/issues/19) |
2727| Power lines, turbines, solar, bridges and pipeline inspection | Owned bridge-face route/clearance simulation, captured visible/synthetic thermal imagery, quality, explicit source-bound reviews and revisit comparison; real asset/hardware/defect qualification and richer families pending | Not exposed | [#20](https://github.com/stacksjs/drone/issues/20) |
2828| Crop scouting, multispectral calibration and livestock monitoring | Owned raw red/NIR scouting, panel/irradiance model, clipped numeric sampling layer, parcel reports and source-bound explicit animal declarations; measured hardware/field/agronomy and richer interchange pending | Not exposed | [#21](https://github.com/stacksjs/drone/issues/21) |
2929| Spraying, spreading, prescription maps and implement interlocks | Owned inert-material physics, separate implement leases, quantized meters, exact coverage and shutdown audit | Physical calibration, delivery, field dose/drift, local requirements and HIL remain open | [#22](https://github.com/stacksjs/drone/issues/22) |
docs/roadmap/issues.jsonmodified+22-0
Changes to docs/roadmap/issues.json
@@ -618,6 +618,28 @@
618618 "nativeDependencies": [
619619 18,
620620 8
621 ],
622 "implementationStatus": "processing-implemented-validation-pending",
623 "implemented": [
624 "Known-TIN and constant-cell numerical integration with explicit CRS/datum/base, cut/fill, coverage gaps, conditional uncertainty and declared-reference comparison",
625 "Bounded ASC/indexed PLY and declared numeric GeoTIFF imports with private completion-marked audit datasets",
626 "Declared LAS1.2 UTM formats0-3 to selected-point cell means, count/spread gaps and retained original source/declaration/gridding audit through native CLI"
627 ],
628 "evidence": [
629 "docs/volumes.md",
630 "docs/volumes-las.md",
631 "tests/unit/volumes-engine.test.ts",
632 "tests/unit/volumes-import.test.ts",
633 "tests/unit/volumes-export.test.ts",
634 "tests/unit/volumes-geotiff.test.ts",
635 "tests/unit/volumes-las.test.ts",
636 "tests/unit/volumes-las-cli.test.ts",
637 "scripts/qualify-las-cli.ts"
638 ],
639 "remaining": [
640 "Independent physical survey/classification/calibration and external-reader comparison campaigns",
641 "Broader LAS/LAZ/VLR and GeoTIFF profiles, raw-point triangulation, richer base surfaces and tiled/streaming processing",
642 "Measured representation/georeferencing uncertainty, acquisition/reconstruction and hardware/storage qualification"
621643 ]
622644 },
623645 {
docs/runtime-updates.mdmodified+7-5
Changes to docs/runtime-updates.md
@@ -480,7 +480,7 @@ See the [host slot decision](adr/0029-host-slot-health.md).
480480
481481Use a standard TUF repository with `metadata/` and `targets/` directories,
482482including consistent-snapshot names when enabled. Verification uses the pinned
483[`tuf-js` 6.0.0 client](https://github.com/theupdateframework/tuf-js) for root
483[maintained `@stacksjs/tuf-js` 6.0.1 client](https://github.com/stacksjs/tuf-js/releases/tag/sdk-v6.0.1) for root
484484rotation, signature thresholds, expiration, inter-role versions/hashes and
485485target length/hashes. Standalone staging/inspection use a private, randomly
486486addressed loopback server supplying bounded local files to the public HTTP
@@ -496,10 +496,12 @@ expiry comparisons; malformed or automatically normalized dates are rejected.
496496
497497### Maintained SDK persistence seam
498498
499This repository applies `patches/tuf-js@6.0.0.patch` through Bun's root
500`patchedDependencies`. It extends the pinned MIT-licensed client; these are
501repository-maintained options, not options supplied by unmodified upstream 6.0.0.
502The patch retains upstream signature, role and target verification.
499This repository consumes the immutable maintained source release through the
500`tuf-js` dependency key, with no distribution patches. These are maintained fork
501options, not options supplied by unmodified upstream 6.0.0. The source client
502retains upstream signature, role and target verification. See the
503[source release policy](dependency-source-releases.md) for source identity,
504original MIT attribution, release integrity and upgrade qualification.
503505
504506`onVerifiedMetadata(role, bytes)` receives an owned byte copy after the SDK
505507accepts remote metadata. The SDK awaits it before writing that cache file or
docs/volumes-las.mdadded+151-0
Changes to docs/volumes-las.md
@@ -0,0 +1,151 @@
1# LAS point-grid stockpile processing
2
3`importLasSurface(bytes, declarationJson)` converts explicitly selected georeferenced
4LAS points into a declared cell-mean surface. The existing [volume engine](volumes.md)
5then integrates that representation against an operator boundary and plane base. This
6separates point acquisition, representation construction and volume computation.
7
8```sh
9./buddy volumes:import /path/points.las --format las --metadata /path/las-declaration.json --measurement /path/measurement.json --output /tmp/new-las-volume
10```
11
12The same command compiles into the standalone CLI. It never acquires LiDAR, commands
13a vehicle, classifies a stockpile or verifies physical survey accuracy. LAS handler failures
14use bounded format-specific diagnostics without echoing private filenames or input bodies.
15
16LAS errors retain the fixed library codes `las-surface-input`, `las-surface-budget`,
17`las-surface-unavailable`, `las-surface-no-supported-cells`,
18`las-source-sha256-mismatch`, `las-file-unavailable`, `las-dataset-source-input` and
19`las-dataset-source-mismatch`. Other handler failures, including declaration,
20measurement and output filesystem failures, emit `las-import-unavailable` with
21exit 1. Output writes are awaited; an error-pipe failure keeps exit 1. Partial
22output or partial dataset roots can remain after a delivery failure. Accept a
23successful result only after complete streams and exit 0.
24
25## Explicit declaration
26
27The UTF-8 JSON declaration is limited to 32,768 bytes and code units. Closed objects
28require the following fields. JSON duplicate keys follow native parsing, so the final
29value is validated; a BOM is rejected.
30
31| Field | Meaning |
32|---|---|
33| `version` | `1` |
34| `profile` | `"las12-utm-mean-grid-v1"` |
35| `verticalReference` | `"declared-sidecar"`, with no vertical transformation |
36| `metadata` | Complete existing `SurfaceMetadata`, including exact original LAS SHA256 |
37| `selection.classes` | Nonempty sorted unique class numbers, 0 through 31; no default ground filter |
38| `selection.returns` | `"all"`, `"first"` or `"last"` |
39| `selection.synthetic` | `"exclude"` or `"include"`; withheld points are always excluded |
40| `grid.westM`, `grid.southM` | Declared southwest grid corner in metres |
41| `grid.width`, `grid.height` | Positive integer dimensions, at most 32,768 cells total |
42| `grid.cellSizeM` | Square cell size from 0.00001 through 1,000,000 metres |
43| `grid.minimumPoints` | Minimum selected samples per cell, 1 through 200,000 |
44| `grid.maximumSpreadM` | Maximum selected elevation range per cell, 0 through 100,000,000 metres |
45
46The metadata requires WGS84 UTM and either synthetic analytic or operator-imported
47LiDAR provenance. The header projection must match its EPSG code and metre units.
48The vertical datum is an explicit sidecar declaration. An embedded vertical CRS must
49both match that declaration and belong to the verified metre-up profile below. A matching
50label or explicit metre-unit key cannot override a CRS's actual units or axis direction.
51No coordinate or geoid transformation is performed; datum accuracy remains unverified.
52Unknown classification, calibration and georeferencing status remain unknown.
53
54Points are assigned by `floor((east-west)/size)` and `floor((north-south)/size)`.
55Cells include their west/south boundaries and exclude east/north boundaries. Internal
56edges belong to the east/north cell. Output rows run north to south. No epsilon snap
57changes a point's cell. The importer uses compensated elevation sums and reports
58counts, minima, maxima and means. Cells below the count threshold or above the spread
59threshold remain null. A file with no supported cell is refused rather than reported
60as a zero-volume stockpile.
61
62Selection exclusions are disjoint in this order: withheld, synthetic, class, return,
63outside grid. The audit also retains the complete source class/return histograms.
64Class numbers are source labels, not verified material identities. Last returns do
65not automatically establish bare earth, and class 2 alone must not silently substitute
66the underlying ground for a stockpile top.
67
68## Representation and uncertainty
69
70Each accepted sample mean represents its entire cell by an explicit modeling assumption.
71The volume engine's covered area refers to these modeled cells, not independently
72verified laser footprints or ground coverage. Duplicate points carry duplicate sample
73weight. Count and within-cell spread do not bound unsampled terrain, systematic bias,
74occlusion or representation error. `pointSamplingCoverageVerified` and
75`physicalAccuracyVerified` remain false; `representationErrorBoundM` remains null.
76No uncertainty is inferred from sample count or spread.
77
78Missing cells are not filled, and covered-domain volumes are not scaled to a full
79boundary total. Conditional declared vertical bounds retain the volume engine's
80assumptions; they do not become an overall physical accuracy bound. The volume report
81keeps acquisition/reconstruction flags false for the integration stage. The separate
82processing audit records `griddingPerformed: true` and its declared cell model.
83
84## Supported binary profile
85
86This profile follows the header, point layouts and classification flags in the
87[ASPRS LAS 1.2 specification](https://liblas.org/_static/files/specifications/asprs_las_format_v12.pdf),
88particularly its public-header, GeoKey-directory and point-record tables. It accepts
89the 227-byte little-endian 1.2 header and uncompressed point formats 0, 1, 2 and 3 with
90their exact standard record lengths. Scale/offset coordinates, source bounds and
91per-return counts are checked against every point, including excluded points.
92Bounding-box comparison permits only eight binary64 epsilon-scaled rounding units;
93this numerical check is not a survey tolerance. Returns must be from 1 through 5,
94consistent with total returns; signed scan-angle ranks must lie within 90 degrees.
95
96The sole VLR must be an inline `LASF_Projection` GeoKey directory. Required keys are
97projected model, matching WGS84 UTM CRS and metre linear units. Optional point-raster,
98WGS84 geographic CRS and degree angular units have explicit checks. The optional
99vertical-unit key must declare metres. Embedded vertical CRS admission is limited to
100EPSG:3855 (EGM2008), 5701 (ODN), 5703 (NAVD88), 5714 (MSL height), 5773 (EGM96) and
1015783 (DHHN92). These map to the metre/up axis in the pinned
102[PROJ vertical CRS records](https://github.com/OSGeo/PROJ/blob/5fa31c29f0a806568ed7bc76c1a557604db4a804/data/sql/vertical_crs.sql)
103and [axis definitions](https://github.com/OSGeo/PROJ/blob/5fa31c29f0a806568ed7bc76c1a557604db4a804/data/sql/axis.sql).
104Feet-based, depth-positive, unknown and other unqualified embedded CRSs are refused,
105even if their IDs match the sidecar and a separate key claims metres. The processing
106audit retains the actual embedded ID or its absence, declared datum, metre/up
107interpretation and explicit false transformation/datum-verification flags. Unsupported keys, extra VLRs, referenced parameter tables, custom transforms,
108compressed data, extended headers/records and arbitrary trailers are refused. Zero
109padding before the point table is permitted. Source files are limited to 8 MiB and
110200,000 points. Coordinates and grid edges must fit within 100,000,000 metres.
111
112GPS fields in formats 1 and 3 retain week-seconds or adjusted standard GPS time with
113an observed range. They are not UTC or validated sensor-clock alignment. RGB, intensity,
114user data, source IDs and scan-direction/key-point flags do not determine the elevation
115mean; original bytes remain available in exported datasets. Colors are not interpreted
116as calibrated imagery. This is an explicit supported profile, not general LAS/LAZ support.
117
118The pure importer synchronously captures native unshared, fixed Uint8Array/Buffer
119visible bytes before its first await. It avoids caller accessors and iteration hooks;
120proxy, shared, resizable and detached storage are refused. The Node-only `readLasFile`
121uses the existing bounded regular-file reader and stays outside the pure drone barrel.
122
123## Retained evidence and replay
124
125With `--output`, the private completion-marked dataset contains the usual request,
126report, contribution CSV, generated ASC grid and matching sidecar, plus:
127
128- `source.las`: exact original point bytes;
129- `source.declaration.json`: exact declaration used for selection and gridding;
130- `source.gridding.json`: source/declaration hashes, format, time convention,
131 exclusions, histograms, grid choices and per-cell statistics.
132
133The writer recomputes gridding and refuses a source/declaration that does not reproduce
134the requested surface before creating a dataset. The completion manifest hashes every
135file and distinguishes original point bytes from the regenerated ASC hash. Replaying
136`request.json`, reimporting the ASC, or importing the retained LAS/declaration preserves
137the numerical report. The normal console summary omits the per-cell table; it remains
138in the audit file and the pure importer result.
139
140The literal eight-point worksheet in `tests/fixtures/volumes/las.ts` was independently
141encoded from the format tables. Five selected points yield north-first means
142`[[3,6],[9,null]]`: three modeled square metres and 18 cubic metres above an explicit
143zero base, with the fourth cell unknown. It is synthetic evidence. Tests cover count,
144spread, class/return/flag selection, formats 0-3, malformed headers/records/projection,
145byte ownership, digest refusal, publication integrity and native replay. The copied
146CLI qualifier retains actual outputs and dataset hashes outside the checkout.
147
148Broader LAS/LAZ and VLR profiles, point triangulation, richer surfaces, streaming/tiled
149processing, measured calibration/classification and independent surveyed comparisons
150remain open under [#19](https://github.com/stacksjs/drone/issues/19). Physical survey
151and storage power-loss qualification are separate from these software checks.
docs/volumes.mdmodified+8-3
Changes to docs/volumes.md
@@ -2,7 +2,9 @@
22
33The owned volume engine processes an existing georeferenced surface. Acquisition, reconstruction
44and volume measurement are separate stages. Survey PNG imagery is not a numerical elevation
5surface. This engine neither reconstructs it nor invents terrain from unordered points.
5surface. The engine integrates declared surfaces. A separate [LAS point-grid adapter](volumes-las.md)
6can construct explicit cell means from selected georeferenced points, with representation
7error left unknown; it does not infer a continuous terrain mesh.
68
79```sh
810./buddy volumes:demo --scenario pyramid --output /tmp/new-pyramid-volume
@@ -42,7 +44,7 @@ base TIN overlays and inferred toe/base fitting remain future work and are rejec
4244`volumes:import` requires a source file, its metadata JSON and a measurement JSON containing exactly
4345`version`, `id`, `boundary`, `base`, `reference`. For ASC/PLY, the metadata SHA256 must match the exact
4446original UTF-8 bytes, including line endings. UTF-8 decoding preserves a BOM so the ASCII parser
45rejects it. GeoTIFF uses the separate wrapped declaration below and hashes the original binary bytes.
47rejects it. GeoTIFF and LAS use separate wrapped declarations and hash original binary bytes.
4648
4749- AAIGrid `.asc`: square, corner-origin cells (`xllcorner`, `yllcorner`, `cellsize`), north-first
4850 rows, finite decimal heights and optional finite `NODATA_value`. Each cell is an explicitly
@@ -55,13 +57,16 @@ rejects it. GeoTIFF uses the separate wrapped declaration below and hashes the o
5557- Numeric GeoTIFF: the restrictive `classic-le-f32-utm-area-v1` profile below produces the same
5658 constant-cell geometry, with the original binary digest retained. This is explicit
5759 `--format geotiff`; neither file extension nor TIFF magic chooses a format automatically.
60- LAS 1.2: the explicit [UTM point-grid profile](volumes-las.md) decodes uncompressed formats
61 0-3, selects declared classes/returns and constructs cell means. Counts/spread rejection
62 preserve missing cells. Original point bytes, declaration and per-cell evidence can be retained.
5863- Typed JSON retains either numerical representation directly. Imports are bounded at 8 MiB for
5964 ASC/PLY; library JSON parsing allows 16 MiB, while the current CLI regular-file reader allows
6065 8 MiB. Caps include 32,768 vertices/cells, 16,384 triangles, 64 boundary vertices, eight holes,
6166 and separate candidate/intersection/contribution budgets. Over-budget work fails explicitly.
6267
6368Numeric ASC/PLY exports include a matching regenerated sidecar. The original source hash remains
64in the retained request and manifest. LAS/LAZ, broader GeoTIFF profiles, raw-point meshing,
69in the retained request and manifest. Broader LAS/LAZ, GeoTIFF profiles, raw-point triangulation,
6570classification and photogrammetric reconstruction require separately qualified adapters. File extensions do not
6671establish CRS, vertical datum, ground classification or accuracy.
6772
patches/tuf-js.LICENSE licenses/tuf-js.LICENSErenamed+0-0

Renamed from patches/tuf-js.LICENSE. The contents are unchanged.

package.jsonmodified+2-5
Changes to package.json
@@ -50,7 +50,7 @@
5050 "@stacksjs/validation": "^0.75.221",
5151 "stacks": "^0.75.221",
5252 "ts-images": "0.2.31",
53 "tuf-js": "6.0.0",
53 "tuf-js": "https://github.com/stacksjs/tuf-js/releases/download/sdk-v6.0.1/stacksjs-tuf-js-6.0.1.tgz",
5454 "@tufjs/canonical-json": "2.0.0"
5555 },
5656 "workspaces": [
@@ -81,9 +81,6 @@
8181 "url": "https://github.com/stacksjs/drone/issues"
8282 },
8383 "overrides": {
84 "@stacksjs/bunpress": "0.2.22"
85 },
86 "patchedDependencies": {
87 "tuf-js@6.0.0": "patches/tuf-js@6.0.0.patch"
84 "@stacksjs/bunpress": "0.2.25"
8885 }
8986}
patches/README.mddeleted+0-35
Changes to patches/README.md
@@ -1,35 +0,0 @@
1# Maintained dependency patches
2
3## tuf-js 6.0.0
4
5`tuf-js@6.0.0.patch` applies to the npm 6.0.0 release from
6[the TUF JavaScript client](https://github.com/theupdateframework/tuf-js), upstream
7commit `c30653d07f0c41fbe5a2030d25eca6fd04f8547f`. The original MIT copyright and permission
8notice is retained in `tuf-js.LICENSE` and in the installed package.
9
10The patch adds an awaited, copy-owning `onVerifiedMetadata` observer before the
11SDK writes each newly verified remote metadata file, plus an owned finite
12`referenceTime` for recovery replay. Default callers keep upstream verification rules and the current fixed start
13time. Persistence failure aborts rather than being interpreted as a missing
14remote root or a cache miss under `forceCache`. Discard failed updater instances;
15their in-memory trust set is not rolled back. It does not make cache writes
16transactional or implement a protected trust store. Public operational error
17exports retain causes and identify remote verification, persistence and download
18phases. Verification errors identify an attempted role, not permission to discard
19state. Classify persistence first, including when an observer throws a
20verification error; unknown causes require maintenance rather than an assumed
21invalid-input verdict.
22Wrapper messages never coerce caught values, so opaque observer rejections keep
23their persistence classification and exact cause under cache-preferred refresh.
24
25Bun applies the patch through root `patchedDependencies` and the lockfile.
26Always prepare with `bun patch tuf-js@6.0.0` before editing an installed package,
27then generate changes with `bun patch --commit tuf-js@6.0.0`. Do not edit shared
28package caches. Exclude empty generated `.bun-tag-*` entries from the patch;
29they are installer cache markers rather than package source. For upgrades, review the upstream change against every patch
30hunk and rerun the SDK observer cases, update/enrollment regressions, clean
31frozen installation and copied standalone CLI qualification. Never silently
32carry a patch onto another version.
33
34See `docs/runtime-updates.md` for the trust and clock limits. The extensions are
35maintained here and are not claimed to be unmodified upstream options.
patches/tuf-js@6.0.0.patchdeleted+0-255
Changes to patches/tuf-js@6.0.0.patch
@@ -1,255 +0,0 @@
1diff --git a/dist/error.d.ts b/dist/error.d.ts
2index 3a42f0441b68f961d6e367efee45b4f4158bbccd..727dd12095b649a8d6203e7eba6d9ebb59be68d6 100644
3--- a/dist/error.d.ts
4+++ b/dist/error.d.ts
5@@ -6,6 +6,10 @@ export declare class PersistError extends Error {
6 }
7 export declare class RepositoryError extends Error {
8 }
9+export declare class MetadataVerificationError extends Error {
10+ readonly role: string;
11+ constructor(role: string, message: string, options?: ErrorOptions);
12+}
13 export declare class BadVersionError extends RepositoryError {
14 }
15 export declare class EqualVersionError extends BadVersionError {
16diff --git a/dist/error.js b/dist/error.js
17index 3a3c26a068a953998e16e552faf96b6fbe956f90..e4c07310caa36177ce390e6e3d82dd76178432f4 100644
18--- a/dist/error.js
19+++ b/dist/error.js
20@@ -17,6 +17,15 @@ exports.PersistError = PersistError;
21 class RepositoryError extends Error {
22 }
23 exports.RepositoryError = RepositoryError;
24+// Repository-maintained public distinction for pure remote metadata validation.
25+class MetadataVerificationError extends Error {
26+ role;
27+ constructor(role, message, options) {
28+ super(message, options);
29+ this.role = role;
30+ }
31+}
32+exports.MetadataVerificationError = MetadataVerificationError;
33 // An error for metadata that contains an invalid version number.
34 class BadVersionError extends RepositoryError {
35 }
36diff --git a/dist/index.d.ts b/dist/index.d.ts
37index 895fdf4b57c27baf4571e8d06bb93de69166f574..aa1e170b7832c2df49cf2628f77d7895a5f0cbf1 100644
38--- a/dist/index.d.ts
39+++ b/dist/index.d.ts
40@@ -4,3 +4,4 @@ export { Updater } from './updater';
41 export type { Config } from './config';
42 export type { Fetcher } from './fetcher';
43 export type { UpdaterOptions } from './updater';
44+export { DownloadError, DownloadHTTPError, MetadataVerificationError, PersistError } from './error';
45diff --git a/dist/index.js b/dist/index.js
46index 5a83b91f355d88c27615d2bc07310958b18d0142..645728da0d96110d323b0f5e5b2c5f0e839c6fce 100644
47--- a/dist/index.js
48+++ b/dist/index.js
49@@ -7,3 +7,10 @@ var fetcher_1 = require("./fetcher");
50 Object.defineProperty(exports, "BaseFetcher", { enumerable: true, get: function () { return fetcher_1.BaseFetcher; } });
51 var updater_1 = require("./updater");
52 Object.defineProperty(exports, "Updater", { enumerable: true, get: function () { return updater_1.Updater; } });
53+
54+// Repository-maintained operational error exports; use causes rather than message matching.
55+var error_1 = require("./error");
56+Object.defineProperty(exports, "DownloadError", { enumerable: true, get: function () { return error_1.DownloadError; } });
57+Object.defineProperty(exports, "DownloadHTTPError", { enumerable: true, get: function () { return error_1.DownloadHTTPError; } });
58+Object.defineProperty(exports, "MetadataVerificationError", { enumerable: true, get: function () { return error_1.MetadataVerificationError; } });
59+Object.defineProperty(exports, "PersistError", { enumerable: true, get: function () { return error_1.PersistError; } });
60diff --git a/dist/store.d.ts b/dist/store.d.ts
61index 20f835c29dee82ea3e61c775c83d8a34fad787dc..3b9c2f88cbe9082b45927ccabccb44f1d56d8739 100644
62--- a/dist/store.d.ts
63+++ b/dist/store.d.ts
64@@ -2,7 +2,7 @@ import { Metadata, Root, Snapshot, Targets, Timestamp } from '@tufjs/models';
65 export declare class TrustedMetadataStore {
66 private trustedSet;
67 private referenceTime;
68- constructor(rootData: Buffer);
69+ constructor(rootData: Buffer, referenceTime?: Date);
70 get root(): Metadata<Root>;
71 get timestamp(): Metadata<Timestamp> | undefined;
72 get snapshot(): Metadata<Snapshot> | undefined;
73diff --git a/dist/store.js b/dist/store.js
74index 1b1669029a8db00b7cc594ef202984f3d243a0e4..0ef50b3d8b599b7d49392d7d8c5f63d3c6f0ecce 100644
75--- a/dist/store.js
76+++ b/dist/store.js
77@@ -6,9 +6,9 @@ const error_1 = require("./error");
78 class TrustedMetadataStore {
79 trustedSet = {};
80 referenceTime;
81- constructor(rootData) {
82+ constructor(rootData, referenceTime = new Date()) {
83 // Client workflow 5.1: record fixed update start time
84- this.referenceTime = new Date();
85+ this.referenceTime = new Date(referenceTime.getTime());
86 // Client workflow 5.2: load trusted root metadata
87 this.loadTrustedRoot(rootData);
88 }
89diff --git a/dist/updater.d.ts b/dist/updater.d.ts
90index 883df0fe6a6628ef5d16bdb0de17c0850d7d19ec..0fe93d96f9a7912c6708bfdcd6f8bcc7330c9e2e 100644
91--- a/dist/updater.d.ts
92+++ b/dist/updater.d.ts
93@@ -9,6 +9,10 @@ export interface UpdaterOptions {
94 fetcher?: Fetcher;
95 forceCache?: boolean;
96 config?: Partial<Config>;
97+ /** Repository-maintained extension: called after verification, awaited before cache persistence. */
98+ onVerifiedMetadata?: (role: string, bytes: Buffer) => void | Promise<void>;
99+ /** Repository-maintained extension: copied fixed start time for recovery replay, not a trusted clock source. */
100+ referenceTime?: Date;
101 }
102 export declare class Updater {
103 private dir;
104@@ -19,6 +23,7 @@ export declare class Updater {
105 private trustedSet;
106 private config;
107 private fetcher;
108+ private onVerifiedMetadata;
109 constructor(options: UpdaterOptions);
110 refresh(): Promise<void>;
111 getTargetInfo(targetPath: string): Promise<TargetFile | undefined>;
112@@ -31,5 +36,6 @@ export declare class Updater {
113 private loadTargets;
114 private preorderDepthFirstWalk;
115 private generateTargetPath;
116+ private verifyMetadata;
117 private persistMetadata;
118 }
119diff --git a/dist/updater.js b/dist/updater.js
120index 94a7658155740fee3f3b7b32937905cc7f791e90..ce62cfdc83eeec41479089aa301cbea250d8d116 100644
121--- a/dist/updater.js
122+++ b/dist/updater.js
123@@ -57,6 +57,7 @@ class Updater {
124 trustedSet;
125 config;
126 fetcher;
127+ onVerifiedMetadata;
128 constructor(options) {
129 const { metadataDir, metadataBaseUrl, targetDir, targetBaseUrl, fetcher, config, } = options;
130 this.dir = metadataDir;
131@@ -64,8 +65,16 @@ class Updater {
132 this.targetDir = targetDir;
133 this.targetBaseUrl = targetBaseUrl;
134 this.forceCache = options.forceCache ?? false;
135+ const referenceTime = options.referenceTime ?? new Date();
136+ if (!(referenceTime instanceof Date) || !Number.isFinite(referenceTime.getTime())) {
137+ throw new error_1.ValueError('Invalid fixed update reference time');
138+ }
139+ if (options.onVerifiedMetadata !== undefined && typeof options.onVerifiedMetadata !== 'function') {
140+ throw new error_1.ValueError('Invalid verified metadata observer');
141+ }
142+ this.onVerifiedMetadata = options.onVerifiedMetadata;
143 const data = this.loadLocalMetadata(models_1.MetadataKind.Root);
144- this.trustedSet = new store_1.TrustedMetadataStore(data);
145+ this.trustedSet = new store_1.TrustedMetadataStore(data, new Date(referenceTime.getTime()));
146 this.config = { ...config_1.defaultConfig, ...config };
147 const userAgent = config?.userAgent
148 ? `${config.userAgent} tuf-js/${package_json_1.version}`
149@@ -92,6 +101,10 @@ class Updater {
150 await this.loadTimestamp({ checkRemote: false });
151 }
152 catch (error) {
153+ // A failed persistence observer/cache write is not a cache miss.
154+ if (error instanceof error_1.PersistError) {
155+ throw error;
156+ }
157 await this.loadRoot();
158 await this.loadTimestamp();
159 }
160@@ -173,9 +186,9 @@ class Updater {
161 // Client workflow 5.3.3: download new root metadata file
162 const bytesData = await this.fetcher.downloadBytes(rootUrl, this.config.rootMaxLength);
163 // Client workflow 5.3.4 - 5.4.7
164- this.trustedSet.updateRoot(bytesData);
165+ this.verifyMetadata(models_1.MetadataKind.Root, () => this.trustedSet.updateRoot(bytesData));
166 // Client workflow 5.3.8: persist root metadata file
167- this.persistMetadata(models_1.MetadataKind.Root, bytesData);
168+ await this.persistMetadata(models_1.MetadataKind.Root, bytesData);
169 }
170 catch (error) {
171 if (error instanceof error_1.DownloadHTTPError) {
172@@ -210,7 +223,7 @@ class Updater {
173 const bytesData = await this.fetcher.downloadBytes(timestampUrl, this.config.timestampMaxLength);
174 try {
175 // Client workflow 5.4.2 - 5.4.4
176- this.trustedSet.updateTimestamp(bytesData);
177+ this.verifyMetadata(models_1.MetadataKind.Timestamp, () => this.trustedSet.updateTimestamp(bytesData));
178 }
179 catch (error) {
180 // If new timestamp version is same as current, discardd the new one.
181@@ -222,7 +235,7 @@ class Updater {
182 throw error;
183 }
184 // Client workflow 5.4.5: persist timestamp metadata
185- this.persistMetadata(models_1.MetadataKind.Timestamp, bytesData);
186+ await this.persistMetadata(models_1.MetadataKind.Timestamp, bytesData);
187 }
188 // Load local and remote snapshot metadata.
189 // Client workflow 5.5: update snapshot role
190@@ -246,12 +259,12 @@ class Updater {
191 // Client workflow 5.5.1: download snapshot metadata file
192 const bytesData = await this.fetcher.downloadBytes(snapshotUrl, maxLength);
193 // Client workflow 5.5.2 - 5.5.6
194- this.trustedSet.updateSnapshot(bytesData);
195+ this.verifyMetadata(models_1.MetadataKind.Snapshot, () => this.trustedSet.updateSnapshot(bytesData));
196 // Client workflow 5.5.7: persist snapshot metadata file
197- this.persistMetadata(models_1.MetadataKind.Snapshot, bytesData);
198+ await this.persistMetadata(models_1.MetadataKind.Snapshot, bytesData);
199 }
200 catch (error) {
201- throw new error_1.RuntimeError(`Unable to load snapshot metadata error ${error}`);
202+ throw new error_1.RuntimeError('Unable to load snapshot metadata', { cause: error });
203 }
204 }
205 }
206@@ -282,12 +295,12 @@ class Updater {
207 // Client workflow 5.6.1: download targets metadata file
208 const bytesData = await this.fetcher.downloadBytes(metadataUrl, maxLength);
209 // Client workflow 5.6.2 - 5.6.6
210- this.trustedSet.updateDelegatedTargets(bytesData, role, parentRole);
211+ this.verifyMetadata(role, () => this.trustedSet.updateDelegatedTargets(bytesData, role, parentRole));
212 // Client workflow 5.6.7: persist targets metadata file
213- this.persistMetadata(role, bytesData);
214+ await this.persistMetadata(role, bytesData);
215 }
216 catch (error) {
217- throw new error_1.RuntimeError(`Unable to load targets error ${error}`);
218+ throw new error_1.RuntimeError('Unable to load targets metadata', { cause: error });
219 }
220 }
221 return this.trustedSet.getRole(role);
222@@ -358,15 +371,31 @@ class Updater {
223 const filePath = encodeURIComponent(targetInfo.path);
224 return path.join(this.targetDir, filePath);
225 }
226- persistMetadata(metaDataName, bytesData) {
227+ verifyMetadata(role, operation) {
228+ try {
229+ return operation();
230+ }
231+ catch (error) {
232+ // Equal timestamp versions retain the existing normal no-new-metadata path.
233+ if (error instanceof error_1.EqualVersionError) {
234+ throw error;
235+ }
236+ throw new error_1.MetadataVerificationError(role, `Failed to verify metadata ${encodeURIComponent(role)}`, { cause: error });
237+ }
238+ }
239+ async persistMetadata(metaDataName, bytesData) {
240 const encodedName = encodeURIComponent(metaDataName);
241 try {
242+ // The observer owns its copy and must finish before this cache or the next role advances.
243+ if (this.onVerifiedMetadata) {
244+ await this.onVerifiedMetadata(metaDataName, Buffer.from(bytesData));
245+ }
246 const filePath = path.join(this.dir, `${encodedName}.json`);
247 log('WRITE %s', filePath);
248 fs.writeFileSync(filePath, bytesData.toString('utf8'));
249 }
250 catch (error) {
251- throw new error_1.PersistError(`Failed to persist metadata ${encodedName} error: ${error}`);
252+ throw new error_1.PersistError(`Failed to persist metadata ${encodedName}`, { cause: error });
253 }
254 }
255 }
resources/components/ApplicationWorkspace.stxmodified+9-9
Changes to resources/components/ApplicationWorkspace.stx
@@ -10,7 +10,7 @@ const scenario = state<ApplicationScenario>('nominal')
1010const mode = state('test')
1111const authorization = state('none')
1212const run = state<ApplicationRun | null>(null)
13const report = state<ApplicationReport | null>(null)
13const applicationReport = state<ApplicationReport | null>(null)
1414const busy = state(false)
1515const error = state('')
1616const epoch = state(600)
@@ -22,9 +22,9 @@ const validEpoch = derived(() => !run() || Number.isSafeInteger(Number(epoch()))
2222const input = derived(() => validEpoch() ? run()?.inputs.find(row => row.atMs === Number(epoch())) ?? null : null)
2323const decision = derived(() => validEpoch() ? run()?.decisions.find(row => row.atMs === Number(epoch())) ?? null : null)
2424const probe = derived(() => validEpoch() ? run()?.probes.find(row => row.atMs === Number(epoch())) ?? null : null)
25const cell = derived(() => Number.isInteger(Number(cellIndex())) && Number(cellIndex()) >= 0 ? report()?.cells[Number(cellIndex())] ?? null : null)
25const cell = derived(() => Number.isInteger(Number(cellIndex())) && Number(cellIndex()) >= 0 ? applicationReport()?.cells[Number(cellIndex())] ?? null : null)
2626const doseMaximum = derived(() => Math.max(1, ...config().prescription.map(zone => zone.ratePerHa)))
27const preview = derived(() => report() ? applicationReportPreview(report()!, doseMaximum()) : '')
27const preview = derived(() => applicationReport() ? applicationReportPreview(applicationReport()!, doseMaximum()) : '')
2828const rateMaximum = derived(() => Math.max(config().calibration.maxRatePerSecond, ...(run()?.decisions.map(row => Math.max(row.targetPerSecond, row.measuredPerSecond ?? 0)) ?? [])))
2929const ratePlot = derived(() => applicationRatePlot(run()?.decisions ?? [], rateMaximum()))
3030function quantity(value: number | null | undefined, digits = 6) { return value === null || value === undefined ? 'unavailable' : value.toFixed(digits).replace(/^-0(\.0+)$/, '0$1') }
renamed throughout · 16 hidden lines
Changes to resources/components/ApplicationWorkspace.stx
@@ -33,14 +33,14 @@ const pending = useTimeout(() => { renamed throughout
3333 try {
3434 const result = simulateApplication({ config: exampleApplicationConfig(implement()), scenario: scenario(), testMode: mode() === 'test', authorizeImplement: authorization() === 'grant' })
3535 const audited = processApplication(result)
36 run.set(result); report.set(audited)
36 run.set(result); applicationReport.set(audited)
3737 void nextTick(() => { if (run() === result) epoch.set(result.final.atMs) })
3838 }
3939 catch (failure) { error.set(failure instanceof Error ? failure.message : 'Application simulation failed') }
4040 finally { busy.set(false) }
4141}, 32)
4242pending.stop()
43function clear() { pending.stop(); run.set(null); report.set(null); error.set(''); busy.set(false); epoch.set(600); cellIndex.set(0) }
43function clear() { pending.stop(); run.set(null); applicationReport.set(null); error.set(''); busy.set(false); epoch.set(600); cellIndex.set(0) }
4444effect(() => { implement(); scenario(); mode(); authorization(); clear() })
4545onDestroy(() => pending.stop())
4646function execute() { clear(); busy.set(true); pending.start() }
Changes to resources/components/ApplicationWorkspace.stx
@@ -61,10 +61,10 @@ function execute() { clear(); busy.set(true); pending.start() }
6161 <p :if="busy()" role="status" class="mt-5 text-emerald-200 text-sm">Running sensor, estimator, mission, metering and exact coverage processing.</p>
6262 <p :if="error()" role="alert" class="mt-5 p-4 break-words text-red-200 border border-red-400 rounded">{{ error() }}</p>
6363 <p :if="!run() && !busy()" class="mt-7 py-5 text-neutral-400 border-neutral-700 border-t">No application recording. Dry test and no grant are the defaults. Metered discharge requires both the metered model and a separate grant.</p>
64 <div :if="run() && report()" class="mt-8 space-y-8">
64 <div :if="run() && applicationReport()" class="mt-8 space-y-8">
6565 <section aria-labelledby="application-result" class="py-5 border-neutral-700 border-t">
6666 <h2 id="application-result" class="font-semibold text-lg">{{ run()?.status }} / {{ run()?.reason }}</h2>
67 <dl class="grid gap-5 grid-cols-2 sm:grid-cols-4 mt-5 text-sm"><div><dt class="text-neutral-400">Modeled emitted</dt><dd class="mt-1 font-mono">{{ quantity(report()?.emittedAmount) }} {{ unit() }}</dd></div><div><dt class="text-neutral-400">Treatable field</dt><dd class="mt-1 font-mono">{{ quantity(report()?.treatableAreaM2, 2) }} m2</dd></div><div><dt class="text-neutral-400">Covered / missed</dt><dd class="mt-1 font-mono">{{ quantity(report()?.coveredAreaM2, 2) }} / {{ quantity(report()?.missedAreaM2, 2) }} m2</dd></div><div><dt class="text-neutral-400">Distinct-pass overlap</dt><dd class="mt-1 font-mono">{{ quantity(report()?.overlapAreaM2, 3) }} m2</dd></div></dl>
67 <dl class="grid gap-5 grid-cols-2 sm:grid-cols-4 mt-5 text-sm"><div><dt class="text-neutral-400">Modeled emitted</dt><dd class="mt-1 font-mono">{{ quantity(applicationReport()?.emittedAmount) }} {{ unit() }}</dd></div><div><dt class="text-neutral-400">Treatable field</dt><dd class="mt-1 font-mono">{{ quantity(applicationReport()?.treatableAreaM2, 2) }} m2</dd></div><div><dt class="text-neutral-400">Covered / missed</dt><dd class="mt-1 font-mono">{{ quantity(applicationReport()?.coveredAreaM2, 2) }} / {{ quantity(applicationReport()?.missedAreaM2, 2) }} m2</dd></div><div><dt class="text-neutral-400">Distinct-pass overlap</dt><dd class="mt-1 font-mono">{{ quantity(applicationReport()?.overlapAreaM2, 3) }} m2</dd></div></dl>
6868 <p class="mt-4 break-words text-neutral-400 text-sm">Material {{ config().material.id }}, batch {{ config().material.batch }}. Calibration {{ config().calibration.id }} is synthetic. Route completion does not establish complete field coverage.</p>
6969 </section>
7070 <section aria-labelledby="application-rate" class="min-w-0">
@@ -78,9 +78,9 @@ function execute() { clear(); busy.set(true); pending.start() }
7878 </section>
7979 <section aria-labelledby="application-dose" class="grid gap-6 md:grid-cols-2 pt-7 border-neutral-700 border-t">
8080 <div class="min-w-0"><h2 id="application-dose" class="font-semibold text-lg">Retained dose cells</h2><img :src="preview()" width="320" height="320" alt="North-up exact modeled deposited dose cells; red is zero, green reaches the declared maximum, grey is unavailable treatable support" class="block object-contain mt-4 h-80 w-full bg-neutral-900 border border-neutral-700 rounded [image-rendering:pixelated]"><p class="mt-3 text-neutral-400 text-xs">North is up. Fixed colour scale: 0 to {{ doseMaximum() }} {{ unit() }}/ha and above. Grey is unavailable treatable support. Uniform rectangular deposition is a declared model, not measured field dose.</p></div>
81 <div class="min-w-0"><label for="application-cell" class="block mb-2 text-sm">Cell index, north to south, west to east</label><input id="application-cell" type="number" min="0" :max="(report()?.cells.length || 1) - 1" step="1" x-model.number="cellIndex" class="px-3 py-2 w-32 bg-neutral-900 border border-neutral-600 rounded"><p :if="!cell()" role="alert" class="mt-3 text-red-200 text-sm">Choose a retained cell index.</p><dl :if="cell()" class="mt-5 space-y-3 break-words text-sm"><div><dt class="text-neutral-400">Cell / north-east bounds</dt><dd class="font-mono">{{ cell()?.id }} / {{ vector(cell()?.boundsM) }} m</dd></div><div><dt class="text-neutral-400">Dose / prescribed amount</dt><dd class="font-mono">{{ quantity(cell()?.dosePerHa, 2) }} {{ unit() }}/ha / {{ quantity(cell()?.prescribedAmount) }} {{ unit() }}</dd></div><div><dt class="text-neutral-400">Field support / excluded support</dt><dd class="font-mono">{{ quantity(cell()?.areaM2, 3) }} / {{ quantity(cell()?.excludedAreaM2, 3) }} m2</dd></div><div><dt class="text-neutral-400">Covered / distinct-pass overlap</dt><dd class="font-mono">{{ quantity(cell()?.coveredAreaM2, 3) }} / {{ quantity(cell()?.overlapAreaM2, 3) }} m2</dd></div></dl></div>
81 <div class="min-w-0"><label for="application-cell" class="block mb-2 text-sm">Cell index, north to south, west to east</label><input id="application-cell" type="number" min="0" :max="(applicationReport()?.cells.length || 1) - 1" step="1" x-model.number="cellIndex" class="px-3 py-2 w-32 bg-neutral-900 border border-neutral-600 rounded"><p :if="!cell()" role="alert" class="mt-3 text-red-200 text-sm">Choose a retained cell index.</p><dl :if="cell()" class="mt-5 space-y-3 break-words text-sm"><div><dt class="text-neutral-400">Cell / north-east bounds</dt><dd class="font-mono">{{ cell()?.id }} / {{ vector(cell()?.boundsM) }} m</dd></div><div><dt class="text-neutral-400">Dose / prescribed amount</dt><dd class="font-mono">{{ quantity(cell()?.dosePerHa, 2) }} {{ unit() }}/ha / {{ quantity(cell()?.prescribedAmount) }} {{ unit() }}</dd></div><div><dt class="text-neutral-400">Field support / excluded support</dt><dd class="font-mono">{{ quantity(cell()?.areaM2, 3) }} / {{ quantity(cell()?.excludedAreaM2, 3) }} m2</dd></div><div><dt class="text-neutral-400">Covered / distinct-pass overlap</dt><dd class="font-mono">{{ quantity(cell()?.coveredAreaM2, 3) }} / {{ quantity(cell()?.overlapAreaM2, 3) }} m2</dd></div></dl></div>
8282 </section>
83 <section aria-labelledby="application-closure" class="pt-7 border-neutral-700 border-t"><h2 id="application-closure" class="font-semibold text-lg">Shutdown and material balance</h2><p class="mt-3 text-sm">Modeled closure {{ run()?.shutdown.closureVerified ? 'verified in the independent tail' : 'unverified' }}. Request {{ run()?.shutdown.requestedAtMs }} ms, final probe {{ run()?.shutdown.finalAtMs }} ms. Emitted after request: {{ quantity(run()?.shutdown.emittedAfterRequest) }} {{ unit() }}.</p><dl class="grid gap-4 grid-cols-2 sm:grid-cols-4 mt-5 text-sm"><div><dt class="text-neutral-400">Treatable field</dt><dd class="font-mono">{{ quantity(report()?.inFieldAmount) }} {{ unit() }}</dd></div><div><dt class="text-neutral-400">Excluded support</dt><dd class="font-mono">{{ quantity(report()?.excludedAmount) }} {{ unit() }}</dd></div><div><dt class="text-neutral-400">Outside field</dt><dd class="font-mono">{{ quantity(report()?.outsideAmount) }} {{ unit() }}</dd></div><div><dt class="text-neutral-400">Still in flight</dt><dd class="font-mono">{{ quantity(report()?.inFlightAmount) }} {{ unit() }}</dd></div></dl><p class="mt-4 text-neutral-400 text-xs">Conservation residual {{ quantity(report()?.conservationError, 10) }} {{ unit() }}. Host measured totals and independent material truth are separate evidence.</p></section>
83 <section aria-labelledby="application-closure" class="pt-7 border-neutral-700 border-t"><h2 id="application-closure" class="font-semibold text-lg">Shutdown and material balance</h2><p class="mt-3 text-sm">Modeled closure {{ run()?.shutdown.closureVerified ? 'verified in the independent tail' : 'unverified' }}. Request {{ run()?.shutdown.requestedAtMs }} ms, final probe {{ run()?.shutdown.finalAtMs }} ms. Emitted after request: {{ quantity(run()?.shutdown.emittedAfterRequest) }} {{ unit() }}.</p><dl class="grid gap-4 grid-cols-2 sm:grid-cols-4 mt-5 text-sm"><div><dt class="text-neutral-400">Treatable field</dt><dd class="font-mono">{{ quantity(applicationReport()?.inFieldAmount) }} {{ unit() }}</dd></div><div><dt class="text-neutral-400">Excluded support</dt><dd class="font-mono">{{ quantity(applicationReport()?.excludedAmount) }} {{ unit() }}</dd></div><div><dt class="text-neutral-400">Outside field</dt><dd class="font-mono">{{ quantity(applicationReport()?.outsideAmount) }} {{ unit() }}</dd></div><div><dt class="text-neutral-400">Still in flight</dt><dd class="font-mono">{{ quantity(applicationReport()?.inFlightAmount) }} {{ unit() }}</dd></div></dl><p class="mt-4 text-neutral-400 text-xs">Conservation residual {{ quantity(applicationReport()?.conservationError, 10) }} {{ unit() }}. Host measured totals and independent material truth are separate evidence.</p></section>
8484 </div>
8585 <section aria-labelledby="application-requirements" class="mt-8 pt-7 border-neutral-700 border-t"><h2 id="application-requirements" class="font-semibold text-lg">Declared constraints</h2><p class="mt-3 break-words text-neutral-400 text-sm">Wind north/east {{ vector(config().weather.windMps) }} m/s, maximum {{ config().weatherLimits.maxWindMps }} m/s. Temperature {{ config().weather.temperatureC }} C within {{ config().weatherLimits.minTemperatureC }}..{{ config().weatherLimits.maxTemperatureC }} C; humidity {{ config().weather.humidityPercent }}%, minimum {{ config().weatherLimits.minHumidityPercent }}%. Inversion must be absent. Weather age limit {{ config().weatherLimits.maxAgeMs }} ms.</p><p class="mt-3 break-words text-neutral-400 text-sm">Operator {{ config().localRequirements.operatorId }}. Jurisdiction {{ config().localRequirements.jurisdiction ?? 'unspecified' }}. Local requirements remain {{ config().localRequirements.status }}; physical trial eligible: false. Material-specific distribution, independent catch/scale calibration, drift/weather and local operating requirements need physical qualification.</p></section>
8686 </section>
resources/components/DiagnosticsReplay.stxmodified+2-2
Changes to resources/components/DiagnosticsReplay.stx
@@ -10,7 +10,7 @@ const busy = state(false)
1010const error = state('')
1111const offset = state(0)
1212const replay = derived(() => current() ? createDiagnosticsReplay(current()!).advanceTo(offset()) : null)
13const report = derived(() => current() ? diagnoseDiagnostics(current()!) : null)
13const diagnosticReport = derived(() => current() ? diagnoseDiagnostics(current()!) : null)
1414const duration = derived(() => current()?.footer?.atMs ?? current()?.events.at(-1)?.atMs ?? 0)
1515const rows = derived(() => (current()?.events ?? []).filter(row => row.atMs <= offset()).slice(-24))
1616let revision = 0
@@ -63,7 +63,7 @@ onDestroy(() => { revision++ })
6363 <p class="mt-5 text-neutral-400 text-sm">Observed faults: <span class="font-mono text-neutral-100" data-replay-faults>{{ replay()?.health.join(', ') || 'none at this epoch' }}</span></p>
6464 <h3 class="mt-8 text-neutral-400 text-sm">Last 24 records at this epoch</h3><div class="overflow-x-auto mt-3"><table class="w-full text-left text-xs"><thead><tr class="text-neutral-400 border-b border-neutral-700"><th scope="col" class="pr-4 py-3">Seq</th><th scope="col" class="pr-4 py-3">Time / ms</th><th scope="col" class="py-3">Event</th></tr></thead><tbody><tr :for="row in rows()" class="border-b border-neutral-800"><td class="pr-4 py-3 font-mono">{{ row.sequence }}</td><td class="pr-4 py-3 font-mono">{{ row.atMs }}</td><td class="py-3 font-mono">{{ row.event.kind === 'runtime' ? row.event.direction + ' / ' + row.event.frame.kind : row.event.kind === 'health' ? row.event.code : 'board tick' }}</td></tr></tbody></table></div>
6565 </div>
66 <aside class="min-w-0"><h2 class="font-semibold text-lg">Recording checks</h2><p class="mt-2 text-neutral-400 text-sm">These checks diagnose the file. Hardware commissioning remains blocked.</p><dl class="mt-5 text-sm"><div :for="check in report()?.checks || []" class="py-3 border-b border-neutral-700"><dt class="flex gap-3 justify-between"><span>{{ check.id }}</span><span class="font-mono">{{ check.state }}</span></dt><dd class="mt-2 break-words text-neutral-400 text-xs">{{ check.detail }}</dd></div></dl>
66 <aside class="min-w-0"><h2 class="font-semibold text-lg">Recording checks</h2><p class="mt-2 text-neutral-400 text-sm">These checks diagnose the file. Hardware commissioning remains blocked.</p><dl class="mt-5 text-sm"><div :for="check in diagnosticReport()?.checks || []" class="py-3 border-b border-neutral-700"><dt class="flex gap-3 justify-between"><span>{{ check.id }}</span><span class="font-mono">{{ check.state }}</span></dt><dd class="mt-2 break-words text-neutral-400 text-xs">{{ check.detail }}</dd></div></dl>
6767 <details class="mt-6"><summary class="text-sm cursor-pointer">Firmware and configuration provenance</summary><dl class="mt-4 space-y-4 text-xs"><div><dt class="text-neutral-400">Firmware SHA-256</dt><dd class="mt-1 break-all font-mono">{{ current()?.header.provenance.firmwareSha256 }}</dd></div><div><dt class="text-neutral-400">Configuration SHA-256</dt><dd class="mt-1 break-all font-mono">{{ current()?.header.provenance.configurationSha256 }}</dd></div><div><dt class="text-neutral-400">Model SHA-256</dt><dd class="mt-1 break-all font-mono">{{ current()?.header.provenance.modelSha256 }}</dd></div><div><dt class="text-neutral-400">Calibration SHA-256</dt><dd class="mt-1 break-all font-mono">{{ current()?.header.provenance.calibrationSha256 }}</dd></div></dl></details>
6868 </aside>
6969 </div>
resources/components/VolumesWorkspace.stxmodified+29-29
renamed throughout · 14 hidden lines
Changes to resources/components/VolumesWorkspace.stx
@@ -10,10 +10,10 @@ const result = state<VolumeReport | null>(null) renamed throughout
1010const busy = state(false)
1111const error = state('')
1212const page = state(0)
13const report = derived(() => resultScenario() === scenario() ? result() : null)
14const source = derived(() => report() ? request() : null)
15const pages = derived(() => Math.max(1, Math.ceil((report()?.integrationPieces.length ?? 0) / 12)))
16const pieces = derived(() => report()?.integrationPieces.slice(page() * 12, page() * 12 + 12) ?? [])
13const volumeReport = derived(() => resultScenario() === scenario() ? result() : null)
14const source = derived(() => volumeReport() ? request() : null)
15const pages = derived(() => Math.max(1, Math.ceil((volumeReport()?.integrationPieces.length ?? 0) / 12)))
16const pieces = derived(() => volumeReport()?.integrationPieces.slice(page() * 12, page() * 12 + 12) ?? [])
1717const extent = derived(() => {
1818 const points = source()?.boundary.outer ?? [[0, 0], [1, 1]]
1919 const east = points.map(point => point[0]), north = points.map(point => point[1])
Changes to resources/components/VolumesWorkspace.stx
@@ -94,27 +94,27 @@ function execute() { clear(); busy.set(true); computation.start() }
9494 </div>
9595 <p :if="busy()" role="status" class="mt-5 text-emerald-200 text-sm">Clipping the known surface and integrating above and below the declared base.</p>
9696 <p :if="error()" role="alert" class="mt-5 p-4 break-words text-red-200 border border-red-400 rounded">{{ error() }}</p>
97 <p :if="!report() && !busy() && !error()" class="mt-7 py-5 text-neutral-400 border-neutral-700 border-t">No analysis yet. Choose an example to inspect exact surface integration and its evidence limits.</p>
98 <div :if="report()" class="mt-9">
97 <p :if="!volumeReport() && !busy() && !error()" class="mt-7 py-5 text-neutral-400 border-neutral-700 border-t">No analysis yet. Choose an example to inspect exact surface integration and its evidence limits.</p>
98 <div :if="volumeReport()" class="mt-9">
9999 <div class="grid gap-8 lg:grid-cols-[1fr_1.3fr]">
100100 <aside class="min-w-0">
101101 <h2 class="font-semibold text-lg">Covered-domain result</h2>
102 <p class="mt-3 break-words font-mono text-emerald-200 text-sm" data-volume-status>{{ report()?.status }} / {{ report()?.evidence }}</p>
102 <p class="mt-3 break-words font-mono text-emerald-200 text-sm" data-volume-status>{{ volumeReport()?.status }} / {{ volumeReport()?.evidence }}</p>
103103 <dl class="grid gap-5 grid-cols-2 mt-5 text-sm">
104 <div><dt class="text-neutral-400">Above base</dt><dd class="mt-2 font-mono" data-volume-above>{{ quantity(report()?.aboveBaseM3) }}</dd></div>
105 <div><dt class="text-neutral-400">Below base</dt><dd class="mt-2 font-mono" data-volume-below>{{ quantity(report()?.belowBaseM3) }}</dd></div>
106 <div><dt class="text-neutral-400">Signed top minus base</dt><dd class="mt-2 font-mono" data-volume-signed>{{ quantity(report()?.signedTopMinusBaseM3) }}</dd></div>
107 <div><dt class="text-neutral-400">Full boundary signed</dt><dd class="mt-2 font-mono" data-volume-full>{{ quantity(report()?.fullBoundarySignedM3) }}</dd></div>
104 <div><dt class="text-neutral-400">Above base</dt><dd class="mt-2 font-mono" data-volume-above>{{ quantity(volumeReport()?.aboveBaseM3) }}</dd></div>
105 <div><dt class="text-neutral-400">Below base</dt><dd class="mt-2 font-mono" data-volume-below>{{ quantity(volumeReport()?.belowBaseM3) }}</dd></div>
106 <div><dt class="text-neutral-400">Signed top minus base</dt><dd class="mt-2 font-mono" data-volume-signed>{{ quantity(volumeReport()?.signedTopMinusBaseM3) }}</dd></div>
107 <div><dt class="text-neutral-400">Full boundary signed</dt><dd class="mt-2 font-mono" data-volume-full>{{ quantity(volumeReport()?.fullBoundarySignedM3) }}</dd></div>
108108 </dl>
109109 <p class="mt-4 leading-relaxed text-neutral-400 text-xs">Above and below are positive magnitudes. Signed volume equals above minus below. Partial coverage does not establish the volume of the whole boundary.</p>
110110 <h3 class="mt-7 font-semibold text-sm">Boundary and domain</h3>
111111 <dl class="grid gap-4 grid-cols-2 mt-4 text-sm">
112 <div><dt class="text-neutral-400">Boundary area</dt><dd class="mt-1">{{ quantity(report()?.boundaryAreaM2, 'm²') }}</dd></div>
113 <div><dt class="text-neutral-400">Covered area</dt><dd class="mt-1">{{ quantity(report()?.coveredAreaM2, 'm²') }}</dd></div>
114 <div><dt class="text-neutral-400">Uncovered area</dt><dd class="mt-1" data-volume-gap>{{ quantity(report()?.uncoveredAreaM2, 'm²') }}</dd></div>
115 <div><dt class="text-neutral-400">Coverage</dt><dd class="mt-1">{{ ((report()?.coverageFraction ?? 0) * 100).toFixed(2) }}%</dd></div>
112 <div><dt class="text-neutral-400">Boundary area</dt><dd class="mt-1">{{ quantity(volumeReport()?.boundaryAreaM2, 'm²') }}</dd></div>
113 <div><dt class="text-neutral-400">Covered area</dt><dd class="mt-1">{{ quantity(volumeReport()?.coveredAreaM2, 'm²') }}</dd></div>
114 <div><dt class="text-neutral-400">Uncovered area</dt><dd class="mt-1" data-volume-gap>{{ quantity(volumeReport()?.uncoveredAreaM2, 'm²') }}</dd></div>
115 <div><dt class="text-neutral-400">Coverage</dt><dd class="mt-1">{{ ((volumeReport()?.coverageFraction ?? 0) * 100).toFixed(2) }}%</dd></div>
116116 <div><dt class="text-neutral-400">Boundary vertices / holes</dt><dd class="mt-1">{{ source()?.boundary.outer.length }} / {{ source()?.boundary.holes.length }}</dd></div>
117 <div><dt class="text-neutral-400">Excluded nodata cells</dt><dd class="mt-1">{{ report()?.excludedGridCells.length }}</dd></div>
117 <div><dt class="text-neutral-400">Excluded nodata cells</dt><dd class="mt-1">{{ volumeReport()?.excludedGridCells.length }}</dd></div>
118118 </dl>
119119 <h3 class="mt-7 font-semibold text-sm">Explicit base plane</h3>
120120 <p class="mt-3 leading-relaxed text-neutral-400 text-sm">{{ source()?.base.assumption }}</p>
@@ -136,20 +136,20 @@ function execute() { clear(); busy.set(true); computation.start() }
136136 <p class="mt-3 text-neutral-400 text-xs">Colour intensity spans 0 to {{ colorScale().toFixed(3) }} m in absolute mean height difference. Exact volume integration splits every contribution at the base crossing. Holes are excluded from the domain.</p>
137137 <h3 class="mt-7 font-semibold text-sm">Uncertainty and reference</h3>
138138 <dl class="grid gap-5 grid-cols-2 mt-4 text-sm">
139 <div><dt class="text-neutral-400">Conditional vertical bound</dt><dd class="mt-1 font-mono" data-volume-conditional>{{ quantity(report()?.uncertainty.conditionalVerticalBoundM3) }}</dd></div>
139 <div><dt class="text-neutral-400">Conditional vertical bound</dt><dd class="mt-1 font-mono" data-volume-conditional>{{ quantity(volumeReport()?.uncertainty.conditionalVerticalBoundM3) }}</dd></div>
140140 <div><dt class="text-neutral-400">Overall accuracy bound</dt><dd class="mt-1 font-mono">unavailable</dd></div>
141 <div><dt class="text-neutral-400">Declared horizontal bound</dt><dd class="mt-1">{{ quantity(report()?.uncertainty.horizontalBoundM, 'm') }}</dd></div>
142 <div><dt class="text-neutral-400">Reference comparison</dt><dd class="mt-1 break-words" data-volume-reference>{{ report()?.comparison.status }}</dd></div>
141 <div><dt class="text-neutral-400">Declared horizontal bound</dt><dd class="mt-1">{{ quantity(volumeReport()?.uncertainty.horizontalBoundM, 'm') }}</dd></div>
142 <div><dt class="text-neutral-400">Reference comparison</dt><dd class="mt-1 break-words" data-volume-reference>{{ volumeReport()?.comparison.status }}</dd></div>
143143 </dl>
144144 <p class="mt-4 leading-relaxed text-neutral-400 text-xs">A conditional bound adds the declared top and base vertical errors over covered area. It excludes boundary, horizontal, classification and field calibration errors. Unknown inputs remain unavailable.</p>
145145 <div :if="source()?.reference" class="mt-4 pl-4 border-l border-neutral-600">
146146 <p class="break-words text-sm">{{ source()?.reference?.id }} / {{ source()?.reference?.independence }}</p>
147147 <p class="mt-2 break-words text-neutral-400 text-xs">{{ source()?.reference?.method }} / {{ source()?.reference?.surveyedAt }}</p>
148148 <p class="mt-2 text-neutral-400 text-xs">Reference evidence: {{ source()?.reference?.evidence }}. Operator declarations are not authenticated survey validation.</p>
149 <p class="mt-3 text-sm">Computed minus reference: {{ quantity(report()?.comparison.signedDifferenceM3) }}</p>
150 <p class="mt-2 text-neutral-400 text-xs">Relative signed difference: {{ report()?.comparison.relativeSignedDifference === null ? 'unavailable' : ((report()?.comparison.relativeSignedDifference ?? 0) * 100).toFixed(2) + '%' }}</p>
151 <p class="mt-2 text-neutral-400 text-xs">Combined declared bound: {{ quantity(report()?.comparison.combinedDeclaredBoundM3) }}</p>
152 <p class="mt-2 text-neutral-400 text-xs">Within combined declared bound: {{ report()?.comparison.withinCombinedDeclaredBound === null ? 'unavailable' : report()?.comparison.withinCombinedDeclaredBound ? 'yes' : 'no' }}. This comparison does not establish overall accuracy.</p>
149 <p class="mt-3 text-sm">Computed minus reference: {{ quantity(volumeReport()?.comparison.signedDifferenceM3) }}</p>
150 <p class="mt-2 text-neutral-400 text-xs">Relative signed difference: {{ volumeReport()?.comparison.relativeSignedDifference === null ? 'unavailable' : ((volumeReport()?.comparison.relativeSignedDifference ?? 0) * 100).toFixed(2) + '%' }}</p>
151 <p class="mt-2 text-neutral-400 text-xs">Combined declared bound: {{ quantity(volumeReport()?.comparison.combinedDeclaredBoundM3) }}</p>
152 <p class="mt-2 text-neutral-400 text-xs">Within combined declared bound: {{ volumeReport()?.comparison.withinCombinedDeclaredBound === null ? 'unavailable' : volumeReport()?.comparison.withinCombinedDeclaredBound ? 'yes' : 'no' }}. This comparison does not establish overall accuracy.</p>
153153 </div>
154154 </div>
155155 </div>
@@ -158,9 +158,9 @@ function execute() { clear(); busy.set(true); computation.start() }
158158 <dl class="grid gap-5 sm:grid-cols-2 mt-5 text-sm">
159159 <div class="min-w-0"><dt class="text-neutral-400">Source identity / geometry</dt><dd class="mt-1 break-words">{{ source()?.surface.metadata.id }} / {{ source()?.surface.geometry.kind }}</dd></div>
160160 <div class="min-w-0"><dt class="text-neutral-400">Evidence / source kind</dt><dd class="mt-1 break-words">{{ source()?.surface.metadata.provenance.evidence }} / {{ source()?.surface.metadata.provenance.sourceKind }}</dd></div>
161 <div class="min-w-0"><dt class="text-neutral-400">Horizontal reference</dt><dd class="mt-1 break-words">{{ report()?.reference.horizontalCrs.id }} / {{ report()?.reference.horizontalCrs.definition }}</dd></div>
162 <div class="min-w-0"><dt class="text-neutral-400">Vertical datum</dt><dd class="mt-1 break-words">{{ report()?.reference.verticalDatum.id }} / {{ report()?.reference.verticalDatum.definition }}</dd></div>
163 <div><dt class="text-neutral-400">Coordinate epoch</dt><dd class="mt-1">{{ report()?.reference.horizontalCrs.coordinateEpoch ?? 'not declared' }}</dd></div>
161 <div class="min-w-0"><dt class="text-neutral-400">Horizontal reference</dt><dd class="mt-1 break-words">{{ volumeReport()?.reference.horizontalCrs.id }} / {{ volumeReport()?.reference.horizontalCrs.definition }}</dd></div>
162 <div class="min-w-0"><dt class="text-neutral-400">Vertical datum</dt><dd class="mt-1 break-words">{{ volumeReport()?.reference.verticalDatum.id }} / {{ volumeReport()?.reference.verticalDatum.definition }}</dd></div>
163 <div><dt class="text-neutral-400">Coordinate epoch</dt><dd class="mt-1">{{ volumeReport()?.reference.horizontalCrs.coordinateEpoch ?? 'not declared' }}</dd></div>
164164 <div><dt class="text-neutral-400">Axes and units</dt><dd class="mt-1">Easting / northing / elevation up, metres</dd></div>
165165 <div><dt class="text-neutral-400">Acquisition / meshing status</dt><dd class="mt-1">{{ source()?.surface.metadata.provenance.acquisition.status }} / {{ source()?.surface.metadata.provenance.reconstructionOrMeshing.status }}</dd></div>
166166 <div><dt class="text-neutral-400">Boresight / time calibration</dt><dd class="mt-1">{{ source()?.surface.metadata.provenance.boresightCalibration.status }} / {{ source()?.surface.metadata.provenance.timeCalibration.status }}</dd></div>
@@ -175,10 +175,10 @@ function execute() { clear(); busy.set(true); computation.start() }
175175 <div class="overflow-x-auto mt-5 max-w-full border border-neutral-700 rounded" tabindex="0" role="region" aria-label="Scrollable contribution audit table">
176176 <table class="w-full text-left text-xs whitespace-nowrap"><caption class="sr-only">Clipped surface contributions, unweighted area and volumes, with signed boundary weights</caption><thead class="text-neutral-300 bg-neutral-900"><tr><th scope="col" class="p-3">Source / part</th><th scope="col" class="p-3">Weight</th><th scope="col" class="p-3">Area m²</th><th scope="col" class="p-3">Above m³</th><th scope="col" class="p-3">Below m³</th><th scope="col" class="p-3">Signed m³</th></tr></thead><tbody><tr :for="piece in pieces()" class="border-neutral-800 border-t"><th scope="row" class="p-3 font-normal"><span class="block">{{ piece.sourceId }}</span><span class="block mt-1 text-neutral-400">{{ piece.boundaryPart }}</span></th><td class="p-3 font-mono">{{ piece.weight }}</td><td class="p-3 font-mono">{{ piece.areaM2.toFixed(3) }}</td><td class="p-3 font-mono">{{ piece.aboveBaseM3.toFixed(3) }}</td><td class="p-3 font-mono">{{ piece.belowBaseM3.toFixed(3) }}</td><td class="p-3 font-mono">{{ piece.signedTopMinusBaseM3.toFixed(3) }}</td></tr></tbody></table>
177177 </div>
178 <p :if="!report()?.integrationPieces.length" class="mt-4 text-neutral-400 text-sm">No covered surface contributions intersect this boundary.</p>
179 <div class="flex flex-wrap gap-3 items-center mt-4 text-sm"><button type="button" @click="page.update(value => Math.max(0, value - 1))" :disabled="page() === 0" class="px-3 py-2 border border-neutral-600 rounded focus-visible:outline-emerald-200 disabled:opacity-40">Previous pieces</button><span role="status">Page {{ page() + 1 }} / {{ pages() }}. {{ report()?.integrationPieces.length }} pieces.</span><button type="button" @click="page.update(value => Math.min(pages() - 1, value + 1))" :disabled="page() >= pages() - 1" class="px-3 py-2 border border-neutral-600 rounded focus-visible:outline-emerald-200 disabled:opacity-40">Next pieces</button></div>
178 <p :if="!volumeReport()?.integrationPieces.length" class="mt-4 text-neutral-400 text-sm">No covered surface contributions intersect this boundary.</p>
179 <div class="flex flex-wrap gap-3 items-center mt-4 text-sm"><button type="button" @click="page.update(value => Math.max(0, value - 1))" :disabled="page() === 0" class="px-3 py-2 border border-neutral-600 rounded focus-visible:outline-emerald-200 disabled:opacity-40">Previous pieces</button><span role="status">Page {{ page() + 1 }} / {{ pages() }}. {{ volumeReport()?.integrationPieces.length }} pieces.</span><button type="button" @click="page.update(value => Math.min(pages() - 1, value + 1))" :disabled="page() >= pages() - 1" class="px-3 py-2 border border-neutral-600 rounded focus-visible:outline-emerald-200 disabled:opacity-40">Next pieces</button></div>
180180 </section>
181 <details class="mt-8 pt-5 border-neutral-700 border-t"><summary class="text-sm focus-visible:outline-emerald-200 cursor-pointer">Warnings, uncertainty assumptions and evidence limits</summary><ul class="mt-4 space-y-3 text-neutral-400 text-sm"><li :for="warning in report()?.warnings ?? []" class="break-words text-amber-200">{{ warning }}</li><li :for="assumption in report()?.uncertainty.assumptions ?? []" class="break-words">{{ assumption }}</li><li :for="limitation in report()?.limitations ?? []" class="break-words">{{ limitation }}</li></ul></details>
181 <details class="mt-8 pt-5 border-neutral-700 border-t"><summary class="text-sm focus-visible:outline-emerald-200 cursor-pointer">Warnings, uncertainty assumptions and evidence limits</summary><ul class="mt-4 space-y-3 text-neutral-400 text-sm"><li :for="warning in volumeReport()?.warnings ?? []" class="break-words text-amber-200">{{ warning }}</li><li :for="assumption in volumeReport()?.uncertainty.assumptions ?? []" class="break-words">{{ assumption }}</li><li :for="limitation in volumeReport()?.limitations ?? []" class="break-words">{{ limitation }}</li></ul></details>
182182 <p class="mt-6 text-neutral-400 text-xs">For known-surface import and auditable dataset export, use the native Stacks volume commands. Binary LiDAR, raw-point meshing and surveyed field validation remain separate work.</p>
183183 <a href="https://github.com/stacksjs/drone/issues/19" class="inline-block mt-4 text-emerald-200 text-sm underline underline-offset-4">Volume processing scope and remaining evidence</a>
184184 </div>
resources/functions/drone/index.tsmodified+1-0
Changes to resources/functions/drone/index.ts
@@ -26,6 +26,7 @@ export * from './volumes/engine'
2626export * from './volumes/fixtures'
2727export * from './volumes/import'
2828export * from './volumes/geotiff'
29export * from './volumes/las'
2930export * from './inspection/types'
3031export * from './inspection/planner'
3132export * from './inspection/geometry'
resources/functions/drone/volumes/bytes.tsadded+46-0
Changes to resources/functions/drone/volumes/bytes.ts
@@ -0,0 +1,46 @@
1export class SurfaceByteRejection extends Error {
2 constructor(readonly kind: 'input' | 'budget' | 'unavailable') { super(kind) }
3}
4function reject(kind: 'input' | 'budget' | 'unavailable' = 'input'): never { throw new SurfaceByteRejection(kind) }
5
6// Only trusted native functions are captured. Caller properties and prototypes are never read.
7const nativeBytes = (() => {
8 try {
9 const prototype = Object.getPrototypeOf(Uint8Array.prototype)
10 const length = Object.getOwnPropertyDescriptor(prototype, 'length')?.get
11 const byteLength = Object.getOwnPropertyDescriptor(prototype, 'byteLength')?.get
12 const byteOffset = Object.getOwnPropertyDescriptor(prototype, 'byteOffset')?.get
13 const buffer = Object.getOwnPropertyDescriptor(prototype, 'buffer')?.get
14 const tag = Object.getOwnPropertyDescriptor(prototype, Symbol.toStringTag)?.get
15 const set = Object.getOwnPropertyDescriptor(prototype, 'set')?.value
16 const backingLength = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'byteLength')?.get
17 const resizable = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'resizable')?.get
18 if (!length || !byteLength || !byteOffset || !buffer || !tag || typeof set !== 'function' || !backingLength || !resizable) return null
19 return { length, byteLength, byteOffset, buffer, tag, set, backingLength, resizable }
20 }
21 catch { return null }
22})()
23
24export function captureSurfaceBytes(input: unknown): Uint8Array<ArrayBuffer> {
25 if (!nativeBytes) return reject('unavailable')
26 let length: unknown, byteLength: unknown, offset: unknown, backing: unknown, backingLength: unknown, resizable: unknown
27 try {
28 if (Reflect.apply(nativeBytes.tag, input, []) !== 'Uint8Array') return reject()
29 length = Reflect.apply(nativeBytes.length, input, [])
30 byteLength = Reflect.apply(nativeBytes.byteLength, input, [])
31 offset = Reflect.apply(nativeBytes.byteOffset, input, [])
32 backing = Reflect.apply(nativeBytes.buffer, input, [])
33 // These getters reject SharedArrayBuffer and other non-ArrayBuffer backing.
34 backingLength = Reflect.apply(nativeBytes.backingLength, backing, [])
35 resizable = Reflect.apply(nativeBytes.resizable, backing, [])
36 }
37 catch (error) {
38 if (error instanceof TypeError || error instanceof SurfaceByteRejection) return reject()
39 throw error
40 }
41 if (typeof length !== 'number' || !Number.isSafeInteger(length) || length < 8 || byteLength !== length || typeof offset !== 'number' || !Number.isSafeInteger(offset) || offset < 0 || typeof backingLength !== 'number' || !Number.isSafeInteger(backingLength) || backingLength < length || offset > backingLength - length || resizable !== false) return reject()
42 if (length > 8388608) return reject('budget')
43 const owned = new Uint8Array(length)
44 Reflect.apply(nativeBytes.set, owned, [input, 0])
45 return owned
46}
resources/functions/drone/volumes/export.tsmodified+31-5
Changes to resources/functions/drone/volumes/export.ts
@@ -4,13 +4,30 @@ import { join } from 'node:path'
44import { writeCompletedArtifact } from '../../artifacts/completed-directory'
55import { processStockpile, validateVolumeRequest } from './engine'
66import { exportGridSurface, exportPlySurface } from './import'
7import { importLasSurface } from './las'
8import { captureSurfaceBytes } from './bytes'
9
10export interface LasVolumeImportSource { format: 'las', bytes: Uint8Array, declarationJson: string }
11function captureLasSource(input: LasVolumeImportSource) {
12 try {
13 if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).length !== 3) throw new TypeError()
14 const values: unknown[] = ['format', 'bytes', 'declarationJson'].map(key => {
15 const d = Object.getOwnPropertyDescriptor(input, key)
16 if (!d?.enumerable || !Object.hasOwn(d, 'value')) throw new TypeError()
17 return d.value
18 })
19 if (values[0] !== 'las' || typeof values[2] !== 'string' || values[2].length > 32768) throw new TypeError()
20 return { bytes: captureSurfaceBytes(values[1]), declarationJson: values[2] }
21 }
22 catch { throw new TypeError('las-dataset-source-input') }
23}
724
825function json(value: unknown): string {
926 return `${JSON.stringify(value, null, 2)}\n`
1027}
1128
12function digest(contents: string): string {
13 return createHash('sha256').update(contents, 'utf8').digest('hex')
29function digest(contents: string | Uint8Array): string {
30 return createHash('sha256').update(contents).digest('hex')
1431}
1532
1633function csvCell(value: string | number): string {
@@ -19,8 +36,11 @@ function csvCell(value: string | number): string {
1936}
2037
2138/** Recompute and publish an exclusive, completion-marked numerical dataset. Server only. */
22export async function writeVolumeDataset(directory: string, input: VolumeRequest): Promise<{ directory: string, manifest: string, report: string, contributions: string, manifestSha256: string }> {
39export async function writeVolumeDataset(directory: string, input: VolumeRequest, importSource?: LasVolumeImportSource): Promise<{ directory: string, manifest: string, report: string, contributions: string, manifestSha256: string }> {
2340 const request = validateVolumeRequest(input)
41 const capturedSource = importSource === undefined ? null : captureLasSource(importSource)
42 const proof = capturedSource ? await importLasSurface(capturedSource.bytes, capturedSource.declarationJson) : null
43 if (proof && JSON.stringify(proof.surface) !== JSON.stringify(request.surface)) throw new Error('las-dataset-source-mismatch')
2444 const report = processStockpile(request)
2545 const sourcePath = request.surface.geometry.kind === 'cell-grid' ? 'surface.asc' : 'surface.ply'
2646 const sourceText = request.surface.geometry.kind === 'cell-grid' ? exportGridSurface(request.surface) : exportPlySurface(request.surface)
@@ -29,13 +49,18 @@ export async function writeVolumeDataset(directory: string, input: VolumeRequest
2949 sidecar.provenance.sourceSha256 = regeneratedSourceSha256
3050 const columns = ['sourceId', 'boundaryPart', 'weight', 'areaM2', 'aboveBaseM3', 'belowBaseM3', 'signedTopMinusBaseM3', 'polygonEastNorthM']
3151 const contributions = `${[columns.join(','), ...report.integrationPieces.map(piece => [piece.sourceId, piece.boundaryPart, piece.weight, piece.areaM2, piece.aboveBaseM3, piece.belowBaseM3, piece.signedTopMinusBaseM3, JSON.stringify(piece.polygonM)].map(csvCell).join(','))].join('\n')}\n`
32 const entries = [
52 const entries: { path: string, contents: string | Uint8Array }[] = [
3353 { path: 'request.json', contents: json(request) },
3454 { path: 'report.json', contents: json(report) },
3555 { path: 'contributions.csv', contents: contributions },
3656 { path: sourcePath, contents: sourceText },
3757 { path: 'surface.metadata.json', contents: json(sidecar) },
3858 ]
59 if (capturedSource && proof) entries.push(
60 { path: 'source.las', contents: capturedSource.bytes },
61 { path: 'source.declaration.json', contents: capturedSource.declarationJson },
62 { path: 'source.gridding.json', contents: json(proof.processing) },
63 )
3964 const manifest = {
4065 version: 1,
4166 kind: 'stockpile-volume-dataset',
@@ -51,7 +76,8 @@ export async function writeVolumeDataset(directory: string, input: VolumeRequest
5176 regeneratedSourceSha256,
5277 sourcePath,
5378 metadataPath: 'surface.metadata.json',
54 files: entries.map(entry => ({ path: entry.path, bytes: new TextEncoder().encode(entry.contents).byteLength, sha256: digest(entry.contents) })),
79 ...(proof ? { sourceProcessing: { format: 'las', originalSourcePath: 'source.las', declarationPath: 'source.declaration.json', processingPath: 'source.gridding.json', method: proof.processing.method, representationErrorBoundM: null, classificationVerified: false } } : {}),
80 files: entries.map(entry => ({ path: entry.path, bytes: typeof entry.contents === 'string' ? new TextEncoder().encode(entry.contents).byteLength : entry.contents.byteLength, sha256: digest(entry.contents) })),
5581 policy: {
5682 coordinates: 'Easting, northing, elevation up in declared metre-based reference. No CRS or vertical datum conversion.',
5783 surfaceRepresentation: request.surface.geometry.kind === 'cell-grid' ? 'Piecewise-constant cell elevations; corner-origin, north-first AAIGrid; nodata remains missing.' : 'Indexed single-valued triangular terrain surface; ASCII PLY; no raw-point triangulation.',
resources/functions/drone/volumes/files.tsmodified+11-1
Changes to resources/functions/drone/volumes/files.ts
@@ -2,7 +2,7 @@ import { constants } from 'node:fs'
22import { open } from 'node:fs/promises'
33
44/** Bounded local bytes. Parent paths remain caller-owned; concurrent writes are not atomic. */
5export async function readGeoTiffFile(path: string): Promise<Uint8Array> {
5async function readBinarySurfaceFile(path: string): Promise<Uint8Array> {
66 try {
77 if (typeof path !== 'string' || !Number.isSafeInteger(constants.O_NOFOLLOW) || constants.O_NOFOLLOW <= 0 || !Number.isSafeInteger(constants.O_NONBLOCK) || constants.O_NONBLOCK <= 0) throw new Error()
88 const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
@@ -21,5 +21,15 @@ export async function readGeoTiffFile(path: string): Promise<Uint8Array> {
2121 }
2222 finally { await file.close() }
2323 }
24 catch { throw new Error('binary-surface-file-unavailable') }
25}
26
27export async function readGeoTiffFile(path: string): Promise<Uint8Array> {
28 try { return await readBinarySurfaceFile(path) }
2429 catch { throw new Error('geotiff-file-unavailable') }
2530}
31
32export async function readLasFile(path: string): Promise<Uint8Array> {
33 try { return await readBinarySurfaceFile(path) }
34 catch { throw new Error('las-file-unavailable') }
35}
resources/functions/drone/volumes/geotiff.tsmodified+3-44
Changes to resources/functions/drone/volumes/geotiff.ts
@@ -1,3 +1,4 @@
1import { captureSurfaceBytes, SurfaceByteRejection } from './bytes'
12import type { SurfaceGeometry, SurfaceMetadata } from './types'
23import { validateKnownSurface, validateSurfaceMetadata } from './validation'
34
@@ -20,48 +21,6 @@ class Rejection extends Error {
2021}
2122function reject(kind: FailureKind = 'input'): never { throw new Rejection(kind) }
2223
23// Only trusted native functions are captured. Caller properties and prototypes are never read.
24const nativeBytes = (() => {
25 try {
26 const prototype = Object.getPrototypeOf(Uint8Array.prototype)
27 const length = Object.getOwnPropertyDescriptor(prototype, 'length')?.get
28 const byteLength = Object.getOwnPropertyDescriptor(prototype, 'byteLength')?.get
29 const byteOffset = Object.getOwnPropertyDescriptor(prototype, 'byteOffset')?.get
30 const buffer = Object.getOwnPropertyDescriptor(prototype, 'buffer')?.get
31 const tag = Object.getOwnPropertyDescriptor(prototype, Symbol.toStringTag)?.get
32 const set = Object.getOwnPropertyDescriptor(prototype, 'set')?.value
33 const backingLength = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'byteLength')?.get
34 const resizable = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'resizable')?.get
35 if (!length || !byteLength || !byteOffset || !buffer || !tag || typeof set !== 'function' || !backingLength || !resizable) return null
36 return { length, byteLength, byteOffset, buffer, tag, set, backingLength, resizable }
37 }
38 catch { return null }
39})()
40
41function snapshot(input: Uint8Array): Uint8Array<ArrayBuffer> {
42 if (!nativeBytes) return reject('unavailable')
43 let length: unknown, byteLength: unknown, offset: unknown, backing: unknown, backingLength: unknown, resizable: unknown
44 try {
45 if (Reflect.apply(nativeBytes.tag, input, []) !== 'Uint8Array') return reject()
46 length = Reflect.apply(nativeBytes.length, input, [])
47 byteLength = Reflect.apply(nativeBytes.byteLength, input, [])
48 offset = Reflect.apply(nativeBytes.byteOffset, input, [])
49 backing = Reflect.apply(nativeBytes.buffer, input, [])
50 // These getters reject SharedArrayBuffer and other non-ArrayBuffer backing.
51 backingLength = Reflect.apply(nativeBytes.backingLength, backing, [])
52 resizable = Reflect.apply(nativeBytes.resizable, backing, [])
53 }
54 catch (error) {
55 if (error instanceof TypeError || error instanceof Rejection) return reject()
56 throw error
57 }
58 if (typeof length !== 'number' || !Number.isSafeInteger(length) || length < 8 || byteLength !== length || typeof offset !== 'number' || !Number.isSafeInteger(offset) || offset < 0 || typeof backingLength !== 'number' || !Number.isSafeInteger(backingLength) || backingLength < length || offset > backingLength - length || resizable !== false) return reject()
59 if (length > 8388608) return reject('budget')
60 const owned = new Uint8Array(length)
61 Reflect.apply(nativeBytes.set, owned, [input, 0])
62 return owned
63}
64
6524// Called only on JSON.parse-owned data, never an arbitrary caller object graph.
6625function closed(value: unknown, keys: readonly string[]): Record<string, unknown> {
6726 if (!value || typeof value !== 'object' || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) return reject()
@@ -273,7 +232,7 @@ function raster(owned: Uint8Array<ArrayBuffer>, metadata: SurfaceMetadata): Rast
273232/** Restrictive numeric elevation profile, without acquisition or accuracy authentication. */
274233export async function importGeoTiffSurface(bytes: Uint8Array, declarationJson: string): Promise<GeoTiffCellSurface> {
275234 try {
276 const owned = snapshot(bytes), metadata = declaration(declarationJson), grid = raster(owned, metadata)
235 const owned = captureSurfaceBytes(bytes), metadata = declaration(declarationJson), grid = raster(owned, metadata)
277236 const subtle = globalThis.crypto?.subtle
278237 if (!subtle || typeof subtle.digest !== 'function') return reject('unavailable')
279238 const digest = new Uint8Array(await subtle.digest('SHA-256', owned))
@@ -295,7 +254,7 @@ export async function importGeoTiffSurface(bytes: Uint8Array, declarationJson: s
295254 return { version: 1, metadata: surface.metadata, geometry: surface.geometry }
296255 }
297256 catch (error) {
298 if (error instanceof Rejection) {
257 if (error instanceof Rejection || error instanceof SurfaceByteRejection) {
299258 if (error.kind === 'input') throw new TypeError('geotiff-surface-input')
300259 if (error.kind === 'budget') throw new RangeError('geotiff-surface-budget')
301260 if (error.kind === 'digest') throw new Error('geotiff-source-sha256-mismatch')
resources/functions/drone/volumes/las.tsadded+139-0
Changes to resources/functions/drone/volumes/las.ts
@@ -0,0 +1,139 @@
1import type { SurfaceMetadata, SurfaceGeometry } from './types'
2import { captureSurfaceBytes, SurfaceByteRejection } from './bytes'
3import { validateKnownSurface, validateSurfaceMetadata, volumeLimits } from './validation'
4
5export interface LasGridDeclaration {
6 version: 1
7 profile: 'las12-utm-mean-grid-v1'
8 verticalReference: 'declared-sidecar'
9 metadata: SurfaceMetadata
10 selection: { classes: number[], returns: 'all' | 'first' | 'last', synthetic: 'exclude' | 'include' }
11 grid: { westM: number, southM: number, width: number, height: number, cellSizeM: number, minimumPoints: number, maximumSpreadM: number }
12}
13// LAS profile admission only: these EPSG vertical CRSs have metre/up axes.
14// PROJ 5fa31c29f0a806568ed7bc76c1a557604db4a804, vertical_crs.sql + axis.sql.
15const metreUpVerticalCrs = new Set([3855, 5701, 5703, 5714, 5773, 5783])
16
17class Rejection extends Error {
18 constructor(readonly kind: 'input' | 'budget' | 'digest' | 'unavailable' | 'empty' = 'input') { super(kind) }
19}
20function reject(kind: Rejection['kind'] = 'input'): never { throw new Rejection(kind) }
21const finite = (value: unknown, min: number, max: number): value is number => typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max
22const integer = (value: unknown, min: number, max: number): value is number => Number.isSafeInteger(value) && finite(value, min, max)
23function closed(input: unknown, keys: string[]): Record<string, unknown> {
24 if (!input || typeof input !== 'object' || Array.isArray(input) || Object.getPrototypeOf(input) !== Object.prototype || Object.keys(input).length !== keys.length || keys.some(key => !Object.hasOwn(input, key))) return reject()
25 return input as Record<string, unknown>
26}
27function declaration(text: string): LasGridDeclaration {
28 if (typeof text !== 'string') return reject()
29 if (text.length > 32768 || new TextEncoder().encode(text).length > 32768) return reject('budget')
30 const d = closed(JSON.parse(text), ['version', 'profile', 'verticalReference', 'metadata', 'selection', 'grid'])
31 if (d.version !== 1 || d.profile !== 'las12-utm-mean-grid-v1' || d.verticalReference !== 'declared-sidecar') return reject()
32 let metadata: SurfaceMetadata
33 try { metadata = validateSurfaceMetadata(d.metadata) }
34 catch { return reject() }
35 const selection = closed(d.selection, ['classes', 'returns', 'synthetic']), grid = closed(d.grid, ['westM', 'southM', 'width', 'height', 'cellSizeM', 'minimumPoints', 'maximumSpreadM'])
36 if (metadata.reference.horizontalCrs.kind !== 'wgs84-utm' || metadata.provenance.sourceKind !== 'lidar' && metadata.provenance.sourceKind !== 'analytic') return reject()
37 if (!Array.isArray(selection.classes) || selection.classes.length < 1 || selection.classes.length > 32 || selection.classes.some((n, i, values) => !integer(n, 0, 31) || i > 0 && n <= values[i - 1])
38 || !['all', 'first', 'last'].includes(selection.returns as string) || !['exclude', 'include'].includes(selection.synthetic as string)) return reject()
39 if (!finite(grid.westM, -1e8, 1e8) || !finite(grid.southM, -1e8, 1e8) || !integer(grid.width, 1, volumeLimits.gridCells) || !integer(grid.height, 1, volumeLimits.gridCells)
40 || grid.width * grid.height > volumeLimits.gridCells || !finite(grid.cellSizeM, 1e-5, 1e6) || !integer(grid.minimumPoints, 1, 200000) || !finite(grid.maximumSpreadM, 0, 1e8)
41 || !finite(grid.westM + grid.width * grid.cellSizeM, -1e8, 1e8) || !finite(grid.southM + grid.height * grid.cellSizeM, -1e8, 1e8)) return reject()
42 return { version: 1, profile: 'las12-utm-mean-grid-v1', verticalReference: 'declared-sidecar', metadata, selection: selection as unknown as LasGridDeclaration['selection'], grid: grid as unknown as LasGridDeclaration['grid'] }
43}
44async function digest(bytes: Uint8Array<ArrayBuffer>): Promise<string> {
45 const result = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))
46 return Array.from(result, n => n.toString(16).padStart(2, '0')).join('')
47}
48function projection(view: DataView, d: LasGridDeclaration): { end: number, verticalCrs: number | null } {
49 // This explicit profile permits one inline GeoKey directory and no hidden
50 // transform, custom CRS, waveform, extra-byte or compressed-payload metadata.
51 if (view.getUint16(94, true) !== 227 || view.getUint32(100, true) !== 1) return reject()
52 const start = 227, size = view.getUint16(start + 20, true), end = start + 54 + size
53 if (end > view.byteLength || size < 32 || view.getUint16(start, true) !== 0 || view.getUint16(start + 18, true) !== 34735) return reject()
54 const id = 'LASF_Projection'
55 for (let i = 0; i < 16; i++) if (view.getUint8(start + 2 + i) !== (i < id.length ? id.charCodeAt(i) : 0)) return reject()
56 const at = start + 54, count = view.getUint16(at + 6, true)
57 if (count < 3 || count > 8 || size !== 8 + count * 8 || view.getUint16(at, true) !== 1 || view.getUint16(at + 2, true) !== 1 || ![0, 1].includes(view.getUint16(at + 4, true))) return reject()
58 const values = new Map<number, number>()
59 for (let i = 0; i < count; i++) {
60 const here = at + 8 + 8 * i, key = view.getUint16(here, true), value = view.getUint16(here + 6, true)
61 if (values.has(key) || view.getUint16(here + 2, true) !== 0 || view.getUint16(here + 4, true) !== 1 || ![1024, 1025, 2048, 2054, 3072, 3076, 4096, 4099].includes(key)) return reject()
62 values.set(key, value)
63 }
64 if (values.get(1024) !== 1 || values.get(3072) !== Number(d.metadata.reference.horizontalCrs.id.slice(5)) || values.get(3076) !== 9001) return reject()
65 for (const [key, expected] of [[1025, 2], [2048, 4326], [2054, 9102], [4099, 9001]]) if (values.has(key!) && values.get(key!) !== expected) return reject()
66 const verticalCrs = values.get(4096) ?? null
67 if (verticalCrs !== null && (!metreUpVerticalCrs.has(verticalCrs) || d.metadata.reference.verticalDatum.id !== 'EPSG:' + verticalCrs)) return reject()
68 return { end, verticalCrs }
69}
70
71/** Original point bytes to an explicitly declared cell-mean representation. No survey acquisition or implicit meshing. */
72export async function importLasSurface(input: Uint8Array, declarationJson: string) {
73 try {
74 const bytes = captureSurfaceBytes(input), d = declaration(declarationJson), v = new DataView(bytes.buffer)
75 if (bytes.length < 313 || v.getUint32(0, false) !== 0x4c415346 || v.getUint8(24) !== 1 || v.getUint8(25) !== 2 || v.getUint16(6, true) > 1) return reject()
76 const format = v.getUint8(104), recordBytes = [20, 28, 26, 34][format], count = v.getUint32(107, true), offset = v.getUint32(96, true)
77 if (recordBytes === undefined || v.getUint16(105, true) !== recordBytes || !count) return reject()
78 if (count > 200000) return reject('budget')
79 const { end, verticalCrs } = projection(v, d)
80 if (offset < end || offset + count * recordBytes !== bytes.length) return reject()
81 for (let i = end; i < offset; i++) if (bytes[i] !== 0) return reject()
82 const scales = [0, 1, 2].map(i => v.getFloat64(131 + 8 * i, true)), offsets = [0, 1, 2].map(i => v.getFloat64(155 + 8 * i, true))
83 if (scales.some(n => !finite(n, Number.MIN_VALUE, 1e6)) || offsets.some(n => !finite(n, -1e8, 1e8))) return reject()
84 const sourceSha256 = await digest(bytes), declarationSha256 = await digest(new TextEncoder().encode(declarationJson))
85 if (sourceSha256 !== d.metadata.provenance.sourceSha256) return reject('digest')
86 const grid = d.grid, cells = grid.width * grid.height, counts = new Uint32Array(cells), sums = new Float64Array(cells), corrections = new Float64Array(cells), mins = new Float64Array(cells).fill(Infinity), maxs = new Float64Array(cells).fill(-Infinity)
87 const sourceMins = [Infinity, Infinity, Infinity], sourceMaxs = [-Infinity, -Infinity, -Infinity], byReturn = [0, 0, 0, 0, 0], byClass = new Array<number>(32).fill(0)
88 const excluded = { withheld: 0, synthetic: 0, classification: 0, returns: 0, outsideGrid: 0 }
89 const hasGps = format === 1 || format === 3, gpsType = hasGps ? v.getUint16(6, true) ? 'adjusted-standard-seconds' : 'week-seconds' : 'absent'
90 let selected = 0, gpsMin = Infinity, gpsMax = -Infinity
91 for (let i = 0; i < count; i++) {
92 const at = offset + i * recordBytes, xyz = [0, 1, 2].map(axis => v.getInt32(at + axis * 4, true) * scales[axis]! + offsets[axis]!)
93 if (xyz.some(n => !finite(n, -1e8, 1e8))) return reject()
94 for (let axis = 0; axis < 3; axis++) { sourceMins[axis] = Math.min(sourceMins[axis]!, xyz[axis]!); sourceMaxs[axis] = Math.max(sourceMaxs[axis]!, xyz[axis]!) }
95 const returns = v.getUint8(at + 14), returned = returns & 7, totalReturns = (returns >> 3) & 7, classification = v.getUint8(at + 15), classId = classification & 31
96 if (returned < 1 || returned > totalReturns || totalReturns > 5 || Math.abs(v.getInt8(at + 16)) > 90) return reject()
97 byReturn[returned - 1] = byReturn[returned - 1]! + 1; byClass[classId] = byClass[classId]! + 1
98 if (hasGps) {
99 const time = v.getFloat64(at + 20, true)
100 if (!finite(time, gpsType === 'week-seconds' ? 0 : -1e9, gpsType === 'week-seconds' ? 604800 : 1e10) || gpsType === 'week-seconds' && time === 604800) return reject()
101 gpsMin = Math.min(gpsMin, time); gpsMax = Math.max(gpsMax, time)
102 }
103 if (classification & 128) { excluded.withheld++; continue }
104 if (classification & 32 && d.selection.synthetic === 'exclude') { excluded.synthetic++; continue }
105 if (!d.selection.classes.includes(classId)) { excluded.classification++; continue }
106 if (d.selection.returns === 'first' && returned !== 1 || d.selection.returns === 'last' && returned !== totalReturns) { excluded.returns++; continue }
107 const column = Math.floor((xyz[0]! - grid.westM) / grid.cellSizeM), southRow = Math.floor((xyz[1]! - grid.southM) / grid.cellSizeM)
108 if (column < 0 || southRow < 0 || column >= grid.width || southRow >= grid.height) { excluded.outsideGrid++; continue }
109 const cell = (grid.height - 1 - southRow) * grid.width + column, z = xyz[2]!, adjusted = z - corrections[cell]!, next = sums[cell]! + adjusted
110 corrections[cell] = (next - sums[cell]!) - adjusted; sums[cell] = next; counts[cell] = counts[cell]! + 1; mins[cell] = Math.min(mins[cell]!, z); maxs[cell] = Math.max(maxs[cell]!, z); selected++
111 }
112 for (let i = 0; i < 5; i++) if (byReturn[i] !== v.getUint32(111 + i * 4, true)) return reject()
113 for (let axis = 0; axis < 3; axis++) {
114 const maximum = v.getFloat64(179 + axis * 16, true), minimum = v.getFloat64(187 + axis * 16, true), tolerance = 8 * Number.EPSILON * Math.max(1, Math.abs(sourceMins[axis]!), Math.abs(sourceMaxs[axis]!))
115 if (!finite(maximum, -1e8, 1e8) || !finite(minimum, -1e8, 1e8) || Math.abs(maximum - sourceMaxs[axis]!) > tolerance || Math.abs(minimum - sourceMins[axis]!) > tolerance) return reject()
116 }
117 const statistics = Array.from(counts, (n, i) => ({ row: Math.floor(i / grid.width), column: i % grid.width, count: n, minimumM: n ? mins[i]! : null, maximumM: n ? maxs[i]! : null, meanM: n ? sums[i]! / n : null,
118 reason: n < grid.minimumPoints ? 'insufficient-points' as const : maxs[i]! - mins[i]! > grid.maximumSpreadM ? 'excessive-spread' as const : 'accepted' as const }))
119 if (!statistics.some(cell => cell.reason === 'accepted')) return reject('empty')
120 const rows = Array.from({ length: grid.height }, (_, row) => statistics.slice(row * grid.width, (row + 1) * grid.width).map(cell => cell.reason === 'accepted' ? cell.meanM : null))
121 const surface = validateKnownSurface({ version: 1, metadata: d.metadata, geometry: { kind: 'cell-grid', width: grid.width, height: grid.height, westM: grid.westM, southM: grid.southM, cellSizeM: grid.cellSizeM, rows, rowOrder: 'north-to-south', interpretation: 'piecewise-constant-cell-elevation' } })
122 return { surface: { ...surface, geometry: surface.geometry as Extract<SurfaceGeometry, { kind: 'cell-grid' }> }, processing: {
123 version: 1, method: 'las12-cell-mean-v1', griddingPerformed: true, supportedAreaEvidence: 'declared-cell-model', pointSamplingCoverageVerified: false, sourceSha256, declarationSha256, format, sourcePointCount: count, selectedPointCount: selected, excluded,
124 pointsByReturn: byReturn, pointsByClass: byClass, gps: { type: gpsType, minimum: hasGps ? gpsMin : null, maximum: hasGps ? gpsMax : null }, grid: d.grid, selection: d.selection,
125 verticalReference: { mode: 'declared-sidecar', declaredDatumId: d.metadata.reference.verticalDatum.id, embeddedCrs: verticalCrs === null ? null : 'EPSG:' + verticalCrs, unit: 'metre', positive: 'up', coordinateTransformPerformed: false, datumVerified: false },
126 coveredCells: statistics.filter(cell => cell.reason === 'accepted').length, representationErrorBoundM: null, physicalAccuracyVerified: false, classificationVerified: false, cells: statistics,
127 } }
128 }
129 catch (error) {
130 if (error instanceof Rejection || error instanceof SurfaceByteRejection) {
131 if (error.kind === 'input') throw new TypeError('las-surface-input')
132 if (error.kind === 'budget') throw new RangeError('las-surface-budget')
133 if (error.kind === 'digest') throw new Error('las-source-sha256-mismatch')
134 if (error.kind === 'empty') throw new Error('las-surface-no-supported-cells')
135 }
136 if (error instanceof SyntaxError || error instanceof RangeError) throw new TypeError('las-surface-input')
137 throw new Error('las-surface-unavailable')
138 }
139}
scripts/qualify-las-cli.tsadded+64-0
Changes to scripts/qualify-las-cli.ts
@@ -0,0 +1,64 @@
1import { chmod, copyFile, mkdir, readFile } from 'node:fs/promises'
2import { join, resolve } from 'node:path'
3import { runBoundedHostProcess } from '../resources/functions/host/process'
4import { primaryLasBytes, primaryLasDeclaration, primaryLasMeasurement, primaryLasRecordFormat, primaryLasSha256, primaryLasVerticalCrs } from '../tests/fixtures/volumes/las'
5
6const args = process.argv.slice(2)
7if (args.length !== 2) throw new Error('Usage: qualify-las-cli <binary> <new-output-directory>')
8const source = resolve(args[0]!), output = resolve(args[1]!)
9await mkdir(output, { mode: 0o700 })
10const binary = join(output, 'drone'), sha = (bytes: Uint8Array | string) => new Bun.CryptoHasher('sha256').update(bytes).digest('hex')
11await copyFile(source, binary); await chmod(binary, 0o755)
12await Bun.write(join(output, 'source.las'), primaryLasBytes())
13await Bun.write(join(output, 'declaration.json'), primaryLasDeclaration())
14await Bun.write(join(output, 'measurement.json'), JSON.stringify(primaryLasMeasurement()))
15const rows: { name: string, exit: number, stdoutBytes: number, stdoutSha256: string, stderrBytes: number, stderrSha256: string }[] = []
16async function run(name: string, args: string[], exit = 0) {
17 const r = await runBoundedHostProcess([binary, ...args], { cwd: output, timeoutMs: 30000, stdoutBytes: 65536, stderrBytes: 2048, strictUtf8: true, label: 'Copied LAS ' + name })
18 await Bun.write(join(output, name + '.stdout'), r.stdout); await Bun.write(join(output, name + '.stderr'), r.stderr)
19 if (r.exitCode !== exit || exit === 0 && r.stderr !== '' || exit !== 0 && r.stdout !== '') throw new Error('Unqualified copied LAS exit: ' + name)
20 rows.push({ name, exit, stdoutBytes: Buffer.byteLength(r.stdout), stdoutSha256: sha(r.stdout), stderrBytes: Buffer.byteLength(r.stderr), stderrSha256: sha(r.stderr) })
21 return exit === 0 ? JSON.parse(r.stdout) : r
22}
23const common = ['--format', 'las', '--metadata', join(output, 'declaration.json'), '--measurement', join(output, 'measurement.json')]
24const first = await run('import', ['volumes:import', join(output, 'source.las'), ...common, '--output', join(output, 'dataset')])
25if (first.report.aboveBaseM3 !== 18 || first.report.coveredAreaM2 !== 3 || first.report.uncoveredAreaM2 !== 1 || first.report.fullBoundarySignedM3 !== null || first.report.physicalAccuracyVerified !== false
26 || first.report.uncertainty.overallAccuracyBoundM3 !== null || first.sourceProcessing.sourceSha256 !== primaryLasSha256 || first.sourceProcessing.selectedPointCount !== 5 || first.sourceProcessing.cells !== undefined) throw new Error('Unqualified copied LAS primary facts')
27const manifest = JSON.parse(await readFile(join(output, 'dataset/manifest.json'), 'utf8'))
28for (const file of manifest.files) {
29 const bytes = await readFile(join(output, 'dataset', file.path))
30 if (bytes.length !== file.bytes || sha(bytes) !== file.sha256) throw new Error('Unqualified copied LAS dataset hash')
31}
32const processing = JSON.parse(await readFile(join(output, 'dataset/source.gridding.json'), 'utf8'))
33if (manifest.originalSourceSha256 !== primaryLasSha256 || manifest.sourceProcessing.format !== 'las' || sha(await readFile(join(output, 'dataset/source.las'))) !== primaryLasSha256
34 || JSON.stringify(processing.cells.map((c: { meanM: number | null }) => c.meanM)) !== '[3,6,9,null]' || processing.excluded.withheld !== 1 || processing.excluded.classification !== 1 || processing.excluded.outsideGrid !== 1) throw new Error('Unqualified copied LAS processing evidence')
35const replay = await run('replay', ['volumes:process', join(output, 'dataset/request.json')])
36const asc = await run('asc', ['volumes:import', join(output, 'dataset/surface.asc'), '--format', 'asc', '--metadata', join(output, 'dataset/surface.metadata.json'), '--measurement', join(output, 'measurement.json')])
37if (JSON.stringify(replay.report) !== JSON.stringify(first.report) || JSON.stringify(asc.report) !== JSON.stringify(first.report)) throw new Error('Unqualified copied LAS replay')
38for (const format of [1, 2, 3] as const) {
39 const bytes = primaryLasRecordFormat(format, 1), d = JSON.parse(primaryLasDeclaration())
40 d.metadata.provenance.sourceSha256 = sha(bytes)
41 await Bun.write(join(output, 'format.las'), bytes); await Bun.write(join(output, 'format.json'), JSON.stringify(d))
42 const report = await run('format-' + format, ['volumes:import', join(output, 'format.las'), '--format', 'las', '--metadata', join(output, 'format.json'), '--measurement', join(output, 'measurement.json')])
43 if (report.report.aboveBaseM3 !== 18 || report.sourceProcessing.format !== format || report.sourceProcessing.gps.type !== (format === 2 ? 'absent' : 'adjusted-standard-seconds')) throw new Error('Unqualified copied LAS record format')
44}
45const wrong = JSON.parse(primaryLasDeclaration()); wrong.metadata.provenance.sourceSha256 = '0'.repeat(64)
46await Bun.write(join(output, 'wrong.json'), JSON.stringify(wrong))
47const refused = await run('wrong-digest', ['volumes:import', join(output, 'source.las'), '--format', 'las', '--metadata', join(output, 'wrong.json'), '--measurement', join(output, 'measurement.json')], 1)
48if (refused.stderr.trim() !== 'las-source-sha256-mismatch') throw new Error('Unqualified copied LAS digest refusal')
49for (const [code, accepted] of [[5703, true], [5702, false], [5715, false]] as const) {
50 const bytes = primaryLasVerticalCrs(code, true), d = JSON.parse(primaryLasDeclaration())
51 d.metadata.provenance.sourceSha256 = sha(bytes); d.metadata.reference.verticalDatum.id = 'EPSG:' + code
52 await Bun.write(join(output, 'vertical.las'), bytes); await Bun.write(join(output, 'vertical.json'), JSON.stringify(d))
53 const result = await run('vertical-' + code, ['volumes:import', join(output, 'vertical.las'), '--format', 'las', '--metadata', join(output, 'vertical.json'), '--measurement', join(output, 'measurement.json')], accepted ? 0 : 1)
54 if (accepted ? result.report.aboveBaseM3 !== 18 || result.sourceProcessing.verticalReference.embeddedCrs !== 'EPSG:5703' || result.sourceProcessing.verticalReference.coordinateTransformPerformed !== false || result.sourceProcessing.verticalReference.datumVerified !== false : result.stderr !== 'las-surface-input\n') throw new Error('Unqualified copied LAS vertical interpretation')
55}
56for (const kind of ['declaration', 'measurement', 'output']) {
57 const unavailable = join(output, 'PRIVATE_' + kind.toUpperCase())
58 if (kind === 'output') await Bun.write(unavailable, 'PRIVATE_EXISTING_FILE')
59 const result = await run('private-' + kind, ['volumes:import', join(output, 'source.las'), '--format', 'las', '--metadata', kind === 'declaration' ? unavailable : join(output, 'declaration.json'), '--measurement', kind === 'measurement' ? unavailable : join(output, 'measurement.json'), '--output', kind === 'output' ? unavailable : join(output, 'PRIVATE_UNPUBLISHED_DATASET')], 1)
60 if (result.stderr !== 'las-import-unavailable\n' || result.stderr.includes('PRIVATE') || await Bun.file(join(output, 'PRIVATE_UNPUBLISHED_DATASET/manifest.json')).exists()) throw new Error('Unqualified copied LAS filesystem privacy')
61 if (kind === 'output' && await Bun.file(unavailable).text() !== 'PRIVATE_EXISTING_FILE') throw new Error('Copied LAS import changed an existing output')
62}
63await Bun.write(join(output, 'receipt.json'), JSON.stringify({ evidence: 'copied-las-stockpile-cli-v1', binarySha256: sha(new Uint8Array(await Bun.file(binary).arrayBuffer())), sourceSha256: primaryLasSha256, formats: [0, 1, 2, 3], physicalAccuracyVerified: false, acquisitionPerformed: false, classificationVerified: false, rows }, null, 2))
64console.log('PASS copied LAS source, four record formats, grid audit, volume replay, vertical interpretation and private refusals')
tests/fixtures/updates/qualify-candidates.tsmodified+9-3
Changes to tests/fixtures/updates/qualify-candidates.ts
@@ -7,7 +7,7 @@ import { platform, repository, target } from './repository'
77
88async function hash(path: string) { return createHash('sha256').update(await readFile(path)).digest('hex') }
99
10/** Real copied commands, signed synthetic bytes and optional retained older binary. Never executes a candidate. */
10/** Real copied commands, signed synthetic bytes and optional retained format-1-only binary. Never executes a candidate. */
1111export async function qualifyPortableCandidates(directory: string, source: string, olderBinary?: string) {
1212 await mkdir(directory, { mode: 0o700 })
1313 const binary = join(directory, 'drone'), sourceSha256 = await hash(source)
@@ -38,7 +38,13 @@ export async function qualifyPortableCandidates(directory: string, source: strin
3838 const empty = await run(['updates:candidates', store, ...pinned])
3939 assert.equal(empty.stateFormat, 1); assert.equal(empty.candidate, null)
4040 assert.equal(await hash(join(store, 'state.sqlite')), initialHash)
41 if (olderBinary) assert.deepEqual(await run(['updates:store', store, ...pinned], 0, olderBinary), created)
41 if (olderBinary) {
42 // A format-1 predecessor cannot emit the later, empty maintenance status field.
43 const { maintenanceHandoffs, ...format1Status } = created
44 assert.deepEqual(maintenanceHandoffs, [])
45 assert.deepEqual(await run(['updates:store', store, ...pinned], 0, olderBinary), format1Status)
46 assert.equal(await hash(join(store, 'state.sqlite')), initialHash)
47 }
4248 await run(['updates:reserve', store, ...pinned, '--repository', repo], 1)
4349 const prepared = await run(['updates:reserve', store, ...pinned, ...repositoryFlags])
4450 assert.equal(prepared.stateFormat, 2); assert.equal(prepared.store.releaseFloor, 9)
@@ -85,6 +91,6 @@ export async function qualifyPortableCandidates(directory: string, source: strin
8591 return { evidence: 'copied-standalone-owned-candidates-v1', operations: results.length, sourceSha256, olderSha256, physicalDeploymentAllowed: false }
8692}
8793if (import.meta.main) {
88 if (process.argv.length !== 4 && process.argv.length !== 5) throw new Error('Use qualify-candidates.ts NEW_DIRECTORY COMPILED_BINARY [OLDER_BINARY]')
94 if (process.argv.length !== 4 && process.argv.length !== 5) throw new Error('Use qualify-candidates.ts NEW_DIRECTORY COMPILED_BINARY [FORMAT1_BINARY]')
8995 console.log(JSON.stringify(await qualifyPortableCandidates(resolve(process.argv[2]!), resolve(process.argv[3]!), process.argv[4] ? resolve(process.argv[4]) : undefined)))
9096}
tests/fixtures/volumes/las.tsadded+44-0
Changes to tests/fixtures/volumes/las.ts
@@ -0,0 +1,44 @@
1import type { SurfaceMetadata } from '../../../resources/functions/drone/volumes/types'
2// Literal binary worksheet, independently encoded from ASPRS LAS1.2 tables.
3// Five selected samples mean north-first cells3,6,9; one missing cell stays unknown.
4export const primaryLasSha256 = '17efc5a7cfe3a40ea8ba8ddb10a1d5330e5621b95866fc3df47eb1c69a24f702'
5const hex = '4c415346000000000000000000000000000000000000000001020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e30039010000010000000014000800000007000000010000000000000000000000000000007b14ae47e17a843f7b14ae47e17a843f7b14ae47e17a843f0000000080841e4100000000d0474f4100000000000000000000000088841e410000000081841e41000000e0d0474f4100000020d0474f410000000000005940000000000000004000004c4153465f50726f6a656374696f6e00af872000000000000000000000000000000000000000000000000000000000000000000001000100000003000004000001000100000c000001006a7f040c000001002923190000007d000000c800000000000901000000004b000000af0000009001000000001201000000007d0000007d00000058020000000009010000000019000000190000002003000000000901000000004b0000004b000000e80300000000090100000000af000000190000001027000000000981000000007d0000004b000000ac2600000000090700000000c800000064000000f40100000000090100000000'
6export function primaryLasBytes(): Uint8Array { return Uint8Array.from(hex.match(/../g)!, byte => Number.parseInt(byte, 16)) }
7export function primaryLasDeclaration(): string {
8 const metadata: SurfaceMetadata = {
9 version: 1, id: 'synthetic-las-cell-means',
10 reference: { horizontalCrs: { kind: 'wgs84-utm', id: 'EPSG:32618', definition: 'Synthetic metre UTM coordinate worksheet', coordinateEpoch: null }, axes: 'EAST_NORTH', horizontalUnit: 'metre', verticalDatum: { kind: 'engineering', id: 'synthetic-zero', definition: 'Declared synthetic zero plane; not a surveyed vertical datum' }, verticalUnit: 'metre', elevationPositive: 'up' },
11 provenance: { evidence: 'synthetic', sourceKind: 'analytic', sourceSha256: primaryLasSha256, acquisition: { status: 'synthetic', sourceId: 'independent-eight-point-worksheet' }, reconstructionOrMeshing: { status: 'unknown', sourceId: null }, boresightCalibration: { status: 'not-applicable', reportId: null }, timeCalibration: { status: 'not-applicable', reportId: null }, groundClassification: { status: 'unknown', description: 'Explicit class1 selection; labels do not verify classification or identify a stockpile surface', reportId: null }, georeferencing: { verticalBoundM: null, horizontalBoundM: null, reportId: null } },
12 }
13 return JSON.stringify({ version: 1, profile: 'las12-utm-mean-grid-v1', verticalReference: 'declared-sidecar', metadata,
14 selection: { classes: [1], returns: 'all', synthetic: 'exclude' },
15 grid: { westM: 500000, southM: 4100000, width: 2, height: 2, cellSizeM: 1, minimumPoints: 1, maximumSpreadM: 5 } })
16}
17export function primaryLasMeasurement() {
18 return { version: 1, id: 'synthetic-las-volume', boundary: { outer: [[500000,4100000],[500002,4100000],[500002,4100002],[500000,4100002]], holes: [] }, base: { kind: 'plane', anchorM: [500000,4100000,0], gradientEast: 0, gradientNorth: 0, assumption: 'Explicit synthetic zero base, not fitted to point elevations', evidence: 'synthetic', verticalBoundM: null }, reference: null }
19}
20
21// Literal table extensions for the three optional GPS/RGB record layouts.
22export function primaryLasRecordFormat(format: 0 | 1 | 2 | 3, encoding: 0 | 1 = 0): Uint8Array {
23 const original = primaryLasBytes(), length = [20, 28, 26, 34][format]!
24 const bytes = new Uint8Array(313 + 8 * length); bytes.set(original.subarray(0, 313))
25 const v = new DataView(bytes.buffer); v.setUint16(6, encoding, true); v.setUint8(104, format); v.setUint16(105, length, true)
26 for (let i = 0; i < 8; i++) {
27 bytes.set(original.subarray(313 + 20 * i, 333 + 20 * i), 313 + i * length)
28 if (format === 1 || format === 3) v.setFloat64(333 + i * length, encoding ? -20 + i : 100 + i, true)
29 if (format === 2 || format === 3) for (let color = 0; color < 3; color++) v.setUint16(313 + i * length + (format === 2 ? 20 : 28) + 2 * color, 1234, true)
30 }
31 return bytes
32}
33
34// Extend only the literal GeoKey table; the independently encoded points stay exact.
35export function primaryLasVerticalCrs(code: number, explicitMetreUnit: boolean): Uint8Array {
36 const original = primaryLasBytes(), extra = explicitMetreUnit ? 16 : 8
37 const bytes = new Uint8Array(original.length + extra)
38 bytes.set(original.subarray(0, 313)); bytes.set(original.subarray(313), 313 + extra)
39 const v = new DataView(bytes.buffer)
40 v.setUint32(96, 313 + extra, true); v.setUint16(247, 32 + extra, true); v.setUint16(287, explicitMetreUnit ? 5 : 4, true)
41 v.setUint16(313, 4096, true); v.setUint16(317, 1, true); v.setUint16(319, code, true)
42 if (explicitMetreUnit) { v.setUint16(321, 4099, true); v.setUint16(325, 1, true); v.setUint16(327, 9001, true) }
43 return bytes
44}
tests/tsconfig.runtime.jsonmodified+2-0
Changes to tests/tsconfig.runtime.json
@@ -1,6 +1,7 @@
11{
22 "extends": "../tsconfig.json",
33 "include": [
4 "./unit/workspace-server-render.test.ts",
45 "../storage/framework/types/env.d.ts",
56 "../bin/drone.ts",
67 "../resources/functions/drone/drivers/**/*.ts",
@@ -120,6 +121,7 @@
120121 "./unit/mapping-*.test.ts",
121122 "./unit/survey-observations.test.ts",
122123 "../resources/functions/drone/volumes/**/*.ts",
124 "../scripts/qualify-las-cli.ts",
123125 "../resources/functions/artifacts/**/*.ts",
124126 "./unit/volumes-*.test.ts",
125127 "./unit/completed-artifact.test.ts",
tests/unit/docs-search.test.tsadded+39-0
Changes to tests/unit/docs-search.test.ts
@@ -0,0 +1,39 @@
1import { afterAll, beforeAll, describe, expect, it } from 'bun:test'
2import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
3import { tmpdir } from 'node:os'
4import { join } from 'node:path'
5import { buildSearchIndex } from '@stacksjs/bunpress'
6import type { BunPressConfig } from '@stacksjs/bunpress'
7
8let root: string
9beforeAll(async () => {
10 root = await mkdtemp(join(tmpdir(), 'bunpress-search-base-'))
11 await mkdir(join(root, 'docs'))
12 await writeFile(join(root, 'index.md'), '# Home\n\nStart here.\n')
13 await writeFile(join(root, 'guide.md'), '# Guide\n\nLead.\n\n## Details\n\nDetails.\n')
14 await writeFile(join(root, 'docs/repeat.md'), '# Repeat\n\nA real nested docs directory.\n')
15})
16afterAll(async () => { if (root) await rm(root, { recursive: true, force: true }) })
17
18describe('search result deployment paths', () => {
19 const urls = async (config: Partial<BunPressConfig>) => (await buildSearchIndex(root, { verbose: false, ...config } as BunPressConfig)).map(record => record.url)
20 it('keeps page, home and anchored results under an explicit base path', async () => {
21 expect(await urls({ basePath: '/docs' })).toEqual(['/docs/docs/repeat', '/docs/guide', '/docs/guide#details', '/docs/'])
22 })
23 it('normalizes a nested deployment path using the renderer contract', async () => {
24 expect(await urls({ basePath: ' manual/reference/// ' })).toEqual(['/manual/reference/docs/repeat', '/manual/reference/guide', '/manual/reference/guide#details', '/manual/reference/'])
25 })
26 it('uses the configured site URL path when no explicit base path exists', async () => {
27 expect(await urls({ sitemap: { baseUrl: 'https://example.test/help/docs/' } })).toEqual(['/help/docs/docs/repeat', '/help/docs/guide', '/help/docs/guide#details', '/help/docs/'])
28 })
29 it('prefixes locale fallback only after translation selection', async () => {
30 const index = await buildSearchIndex(root, { verbose: false, basePath: '/docs', i18n: { locales: ['en', 'es'], defaultLocale: 'en' } } as BunPressConfig)
31 expect(index.filter(record => record.locale === 'en').map(record => record.url)).toEqual(['/docs/docs/repeat', '/docs/guide', '/docs/guide#details', '/docs/'])
32 expect(index.filter(record => record.locale === 'es').map(record => record.url)).toEqual(['/docs/es/docs/repeat', '/docs/es/guide', '/docs/es/guide#details', '/docs/es'])
33 })
34 it('preserves root deployments and an explicit root override', async () => {
35 const expected = ['/docs/repeat', '/guide', '/guide#details', '/']
36 expect(await urls({})).toEqual(expected)
37 expect(await urls({ basePath: '/', sitemap: { baseUrl: 'https://example.test/help' } })).toEqual(expected)
38 })
39})
tests/unit/volumes-las-cli.test.tsadded+119-0
Changes to tests/unit/volumes-las-cli.test.ts
@@ -0,0 +1,119 @@
1import { expect, test } from 'bun:test'
2import { command } from '@stacksjs/testing'
3import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
4import { tmpdir } from 'node:os'
5import { join } from 'node:path'
6import { createHash } from 'node:crypto'
7import { primaryLasBytes, primaryLasDeclaration, primaryLasMeasurement, primaryLasSha256 } from '../fixtures/volumes/las'
8import { importLasSurface } from '../../resources/functions/drone/volumes/las'
9import { validateVolumeRequest } from '../../resources/functions/drone/volumes/engine'
10import { writeVolumeDataset } from '../../resources/functions/drone/volumes/export'
11
12test('native LAS import retains exact source, declaration and grid evidence with a replayable volume dataset', async () => {
13 const root = await mkdtemp(join(tmpdir(), 'drone-las-import-'))
14 try {
15 const raw = primaryLasBytes(), declaration = primaryLasDeclaration(), source = join(root, 'points.las'), metadata = join(root, 'declaration.json'), measurement = join(root, 'measurement.json'), output = join(root, 'dataset')
16 await writeFile(source, raw); await writeFile(metadata, declaration); await writeFile(measurement, JSON.stringify(primaryLasMeasurement()))
17 const result = await command(['volumes:import', source, '--format', 'las', '--metadata', metadata, '--measurement', measurement, '--output', output]).withTimeout(30000)
18 expect(result.exitCode).toBe(0)
19 const imported = JSON.parse(result.stdout)
20 expect(imported.report.aboveBaseM3).toBe(18)
21 expect(imported.report.coveredAreaM2).toBe(3)
22 expect(imported.report.fullBoundarySignedM3).toBeNull()
23 expect(imported.sourceProcessing.selectedPointCount).toBe(5)
24 expect(imported.sourceProcessing.cells).toBeUndefined()
25 expect(await readFile(join(output, 'source.las'))).toEqual(Buffer.from(raw))
26 expect(await readFile(join(output, 'source.declaration.json'), 'utf8')).toBe(declaration)
27 const processing = JSON.parse(await readFile(join(output, 'source.gridding.json'), 'utf8'))
28 expect(processing.cells.map((c: { meanM: number | null }) => c.meanM)).toEqual([3, 6, 9, null])
29 const manifest = JSON.parse(await readFile(join(output, 'manifest.json'), 'utf8'))
30 expect(manifest.originalSourceSha256).toBe(primaryLasSha256)
31 expect(manifest.sourceProcessing.format).toBe('las')
32 for (const f of manifest.files) {
33 const bytes = await readFile(join(output, f.path))
34 expect(bytes.length).toBe(f.bytes)
35 expect(createHash('sha256').update(bytes).digest('hex')).toBe(f.sha256)
36 }
37 const replay = await command(['volumes:process', join(output, 'request.json')]).withTimeout(30000)
38 expect(replay.exitCode).toBe(0)
39 expect(JSON.parse(replay.stdout).report).toEqual(imported.report)
40 const again = await command(['volumes:import', join(output, 'source.las'), '--format', 'las', '--metadata', join(output, 'source.declaration.json'), '--measurement', measurement]).withTimeout(30000)
41 expect(again.exitCode).toBe(0)
42 expect(JSON.parse(again.stdout).report).toEqual(imported.report)
43 }
44 finally { await rm(root, { recursive: true, force: true }) }
45}, 100000)
46
47test('publication snapshots exact LAS bytes and refuses mismatched geometry or accessor evidence before creating a dataset', async () => {
48 const root = await mkdtemp(join(tmpdir(), 'drone-las-publication-'))
49 try {
50 const bytes = primaryLasBytes(), declarationJson = primaryLasDeclaration(), imported = await importLasSurface(bytes, declarationJson)
51 const request = validateVolumeRequest({ ...primaryLasMeasurement(), surface: imported.surface })
52 const pending = writeVolumeDataset(join(root, 'valid'), request, { format: 'las', bytes, declarationJson })
53 bytes.fill(0)
54 await pending
55 expect(await readFile(join(root, 'valid/source.las'))).toEqual(Buffer.from(primaryLasBytes()))
56 const changed = structuredClone(request)
57 if (changed.surface.geometry.kind === 'cell-grid') changed.surface.geometry.rows[0]![0] = 4
58 await expect(writeVolumeDataset(join(root, 'changed'), changed, { format: 'las', bytes: primaryLasBytes(), declarationJson })).rejects.toThrow('las-dataset-source-mismatch')
59 expect(await Bun.file(join(root, 'changed/manifest.json')).exists()).toBe(false)
60 let invoked = 0
61 const unsafe = { format: 'las' as const, declarationJson, get bytes() { invoked++; return primaryLasBytes() } }
62 await expect(writeVolumeDataset(join(root, 'accessor'), request, unsafe)).rejects.toThrow('las-dataset-source-input')
63 expect(invoked).toBe(0)
64 expect(await Bun.file(join(root, 'accessor/manifest.json')).exists()).toBe(false)
65 }
66 finally { await rm(root, { recursive: true, force: true }) }
67})
68
69test('native LAS failures do not expose private file or declaration contents or publish a completion marker', async () => {
70 const root = await mkdtemp(join(tmpdir(), 'drone-las-private-'))
71 try {
72 const source = join(root, 'PRIVATE_POINTS.las'), metadata = join(root, 'PRIVATE_DECLARATION.json'), measurement = join(root, 'measurement.json'), output = join(root, 'failed')
73 await writeFile(metadata, 'PRIVATE_INPUT_BODY'); await writeFile(measurement, JSON.stringify(primaryLasMeasurement()))
74 const result = await command(['volumes:import', source, '--format', 'las', '--metadata', metadata, '--measurement', measurement, '--output', output]).withTimeout(30000)
75 expect(result.exitCode).toBe(1)
76 expect(result.stdout).toBe('')
77 expect(result.stderr).toContain('las-file-unavailable')
78 expect(result.stderr).not.toContain('PRIVATE')
79 await writeFile(source, primaryLasBytes())
80 const malformed = await command(['volumes:import', source, '--format', 'las', '--metadata', metadata, '--measurement', measurement, '--output', output]).withTimeout(30000)
81 expect(malformed.exitCode).toBe(1)
82 expect(malformed.stdout).toBe('')
83 expect(malformed.stderr).toContain('las-surface-input')
84 expect(malformed.stderr).not.toContain('PRIVATE')
85 expect(await Bun.file(join(output, 'manifest.json')).exists()).toBe(false)
86 }
87 finally { await rm(root, { recursive: true, force: true }) }
88}, 70000)
89
90// Real command/filesystem seam: missing sidecars and failed publication must not
91// disclose operator paths. This does not establish filesystem power-loss durability.
92test('native LAS metadata, measurement and output filesystem failures retain fixed private diagnostics', async () => {
93 const root = await mkdtemp(join(tmpdir(), 'drone-las-private-files-'))
94 try {
95 const source = join(root, 'PRIVATE_POINTS.las'), metadata = join(root, 'PRIVATE_DECLARATION.json'), measurement = join(root, 'PRIVATE_MEASUREMENT.json'), output = join(root, 'PRIVATE_OUTPUT')
96 await writeFile(source, primaryLasBytes()); await writeFile(measurement, JSON.stringify(primaryLasMeasurement()))
97 const run = () => command(['volumes:import', source, '--format', 'las', '--metadata', metadata, '--measurement', measurement, '--output', output]).withTimeout(30000)
98 const missingDeclaration = await run()
99 expect(missingDeclaration.exitCode).toBe(1)
100 expect(missingDeclaration.stdout).toBe('')
101 expect(missingDeclaration.stderr).toBe('las-import-unavailable\n')
102 expect(missingDeclaration.stderr).not.toContain('PRIVATE')
103 await writeFile(metadata, primaryLasDeclaration()); await rm(measurement)
104 const missingMeasurement = await run()
105 expect(missingMeasurement.exitCode).toBe(1)
106 expect(missingMeasurement.stdout).toBe('')
107 expect(missingMeasurement.stderr).toBe('las-import-unavailable\n')
108 expect(missingMeasurement.stderr).not.toContain('PRIVATE')
109 await writeFile(measurement, JSON.stringify(primaryLasMeasurement())); await writeFile(output, 'PRIVATE_EXISTING_FILE')
110 const blockedOutput = await run()
111 expect(blockedOutput.exitCode).toBe(1)
112 expect(blockedOutput.stdout).toBe('')
113 expect(blockedOutput.stderr).toBe('las-import-unavailable\n')
114 expect(blockedOutput.stderr).not.toContain('PRIVATE')
115 expect(await readFile(output, 'utf8')).toBe('PRIVATE_EXISTING_FILE')
116 expect(await Bun.file(join(output, 'manifest.json')).exists()).toBe(false)
117 }
118 finally { await rm(root, { recursive: true, force: true }) }
119}, 100000)
tests/unit/volumes-las.test.tsadded+168-0
Changes to tests/unit/volumes-las.test.ts
@@ -0,0 +1,168 @@
1import { expect, test } from 'bun:test'
2import { importLasSurface } from '../../resources/functions/drone/volumes/las'
3import { processStockpile } from '../../resources/functions/drone/volumes/engine'
4import { primaryLasBytes, primaryLasDeclaration, primaryLasMeasurement, primaryLasSha256, primaryLasVerticalCrs } from '../fixtures/volumes/las'
5import type { LasGridDeclaration } from '../../resources/functions/drone/volumes/las'
6import { createHash } from 'node:crypto'
7
8function sidecar(bytes: Uint8Array, update?: (d: LasGridDeclaration) => void) {
9 const d = JSON.parse(primaryLasDeclaration()) as LasGridDeclaration
10 d.metadata.provenance.sourceSha256 = createHash('sha256').update(bytes).digest('hex')
11 update?.(d)
12 return JSON.stringify(d)
13}
14
15// Public computation seam: real binary bytes to declared cells and independent
16// 18m3 worksheet. It does not establish acquisition/classification/survey accuracy.
17test('a LAS point cloud produces only explicitly supported mean cells and a covered-domain volume', async () => {
18 const imported = await importLasSurface(primaryLasBytes(), primaryLasDeclaration())
19 expect(imported.surface.geometry.rows).toEqual([[3, 6], [9, null]])
20 expect(imported.surface.metadata.provenance.sourceSha256).toBe(primaryLasSha256)
21 expect(imported.processing.sourcePointCount).toBe(8)
22 expect(imported.processing.selectedPointCount).toBe(5)
23 expect(imported.processing.excluded).toEqual({ withheld: 1, synthetic: 0, classification: 1, returns: 0, outsideGrid: 1 })
24 expect(imported.processing.coveredCells).toBe(3)
25 expect(imported.processing.representationErrorBoundM).toBeNull()
26 const report = processStockpile({ ...primaryLasMeasurement(), surface: imported.surface })
27 expect(report.aboveBaseM3).toBe(18)
28 expect(report.coveredAreaM2).toBe(3)
29 expect(report.uncoveredAreaM2).toBe(1)
30 expect(report.status).toBe('partial-coverage')
31 expect(report.fullBoundarySignedM3).toBeNull()
32 expect(report.uncertainty.overallAccuracyBoundM3).toBeNull()
33 expect(report.physicalAccuracyVerified).toBe(false)
34})
35
36test('point-count, spread, return and class choices retain unknown cells without inventing a ground surface', async () => {
37 const bytes = primaryLasBytes()
38 const counted = await importLasSurface(bytes, sidecar(bytes, d => { d.grid.minimumPoints = 2 }))
39 expect(counted.surface.geometry.rows).toEqual([[3, null], [9, null]])
40 expect(counted.processing.cells[1]!.reason).toBe('insufficient-points')
41 const spread = await importLasSurface(bytes, sidecar(bytes, d => { d.grid.maximumSpreadM = 1 }))
42 expect(spread.surface.geometry.rows).toEqual([[null, 6], [null, null]])
43 expect(spread.processing.cells[0]!.reason).toBe('excessive-spread')
44 const first = await importLasSurface(bytes, sidecar(bytes, d => { d.selection.returns = 'first' }))
45 expect(first.surface.geometry.rows).toEqual([[2, 6], [9, null]])
46 expect(first.processing.excluded.returns).toBe(1)
47 const classes = await importLasSurface(bytes, sidecar(bytes, d => { d.selection.classes = [7] }))
48 expect(classes.surface.geometry.rows).toEqual([[null, null], [null, 99]])
49 expect(classes.processing.classificationVerified).toBe(false)
50 await expect(importLasSurface(bytes, sidecar(bytes, d => { d.selection.classes = [2] }))).rejects.toThrow('las-surface-no-supported-cells')
51})
52
53test('synthetic flags require an explicit choice while withheld samples are always excluded', async () => {
54 const bytes = primaryLasBytes(); bytes[328] = 33
55 const excluded = await importLasSurface(bytes, sidecar(bytes))
56 expect(excluded.surface.geometry.rows).toEqual([[4, 6], [9, null]])
57 expect(excluded.processing.excluded.synthetic).toBe(1)
58 const included = await importLasSurface(bytes, sidecar(bytes, d => { d.selection.synthetic = 'include' }))
59 expect(included.surface.geometry.rows).toEqual([[3, 6], [9, null]])
60 expect(included.processing.excluded.withheld).toBe(1)
61})
62
63test('signed LAS elevations retain independent cut and fill while declared outer cell edges stay half-open', async () => {
64 const negative = primaryLasBytes(), v = new DataView(negative.buffer)
65 v.setInt32(321, -200, true); v.setInt32(341, -400, true); v.setFloat64(219, -4, true)
66 const imported = await importLasSurface(negative, sidecar(negative))
67 expect(imported.surface.geometry.rows).toEqual([[-3, 6], [9, null]])
68 const report = processStockpile({ ...primaryLasMeasurement(), surface: imported.surface })
69 expect(report.aboveBaseM3).toBe(15)
70 expect(report.belowBaseM3).toBe(3)
71 expect(report.signedTopMinusBaseM3).toBe(12)
72 const edges = primaryLasBytes(), edge = new DataView(edges.buffer)
73 edge.setInt32(313, 0, true); edge.setInt32(377, 0, true)
74 edge.setInt32(453, 100, true); edge.setInt32(457, 200, true)
75 edge.setFloat64(179, 500001.75, true); edge.setFloat64(187, 500000, true)
76 edge.setFloat64(195, 4100002, true); edge.setFloat64(203, 4100000, true)
77 const bounded = await importLasSurface(edges, sidecar(edges))
78 expect(bounded.surface.geometry.rows).toEqual([[3, 6], [9, null]])
79 expect(bounded.processing.excluded.outsideGrid).toBe(1)
80})
81
82// Independent record-layout extensions to the literal worksheet, not production encoders.
83test('LAS1.2 formats1,2,3 preserve coordinate means and explicitly reported GPS time conventions', async () => {
84 const original = primaryLasBytes()
85 for (const [format, length] of [[1, 28], [2, 26], [3, 34]] as const) {
86 for (const globalEncoding of [0, 1]) {
87 const bytes = new Uint8Array(313 + 8 * length); bytes.set(original.subarray(0, 313))
88 const v = new DataView(bytes.buffer); v.setUint16(6, globalEncoding, true); v.setUint8(104, format); v.setUint16(105, length, true)
89 for (let i = 0; i < 8; i++) {
90 bytes.set(original.subarray(313 + 20 * i, 333 + 20 * i), 313 + i * length)
91 if (format !== 2) v.setFloat64(333 + i * length, globalEncoding ? -20 + i : 100 + i, true)
92 if (format !== 1) for (let color = 0; color < 3; color++) v.setUint16(313 + i * length + (format === 2 ? 20 : 28) + 2 * color, 1234, true)
93 }
94 const parsed = await importLasSurface(bytes, sidecar(bytes))
95 expect(parsed.surface.geometry.rows).toEqual([[3, 6], [9, null]])
96 expect(parsed.processing.format).toBe(format)
97 expect(parsed.processing.gps).toEqual(format === 2 ? { type: 'absent', minimum: null, maximum: null } : globalEncoding ? { type: 'adjusted-standard-seconds', minimum: -20, maximum: -13 } : { type: 'week-seconds', minimum: 100, maximum: 107 })
98 }
99 }
100})
101
102test('admission snapshots native visible bytes before await without invoking caller properties', async () => {
103 const owner = new Uint8Array(500); owner.set(primaryLasBytes(), 11)
104 const view = owner.subarray(11, 484)
105 for (const key of ['length', 'byteLength', 'byteOffset', 'buffer', Symbol.iterator]) Object.defineProperty(view, key, { get() { throw new Error('PRIVATE_ACCESSOR') } })
106 const pending = importLasSurface(view, primaryLasDeclaration())
107 owner.fill(0)
108 const first = await pending
109 expect(first.surface.geometry.rows).toEqual([[3, 6], [9, null]])
110 first.surface.geometry.rows[0]![0] = 999
111 const second = await importLasSurface(primaryLasBytes(), primaryLasDeclaration())
112 expect(second.surface.geometry.rows[0]![0]).toBe(3)
113 for (const bad of [null, [], new Proxy(primaryLasBytes(), {}), new Uint8Array(new SharedArrayBuffer(473)), new Uint8Array(8388609)]) await expect(importLasSurface(bad as Uint8Array, primaryLasDeclaration())).rejects.toThrow(/^las-surface-(input|budget)$/)
114})
115
116test('malformed or contradictory LAS records cannot be laundered through a matching source digest', async () => {
117 const changes: ((v: DataView, bytes: Uint8Array) => void)[] = [
118 v => v.setUint8(25, 4), v => v.setUint8(104, 128), v => v.setUint16(105, 21, true), v => v.setUint32(96, 312, true),
119 v => v.setUint32(107, 200001, true), v => v.setUint32(111, 8, true), v => v.setFloat64(131, 0, true),
120 v => v.setFloat64(155, NaN, true), v => v.setFloat64(179, 500001, true), v => v.setUint16(311, 9002, true),
121 v => v.setUint16(303, 32619, true), v => v.setUint16(299, 1, true), v => v.setUint8(327, 0), v => v.setInt8(329, -91),
122 ]
123 for (const change of changes) {
124 const bytes = primaryLasBytes(); change(new DataView(bytes.buffer), bytes)
125 await expect(importLasSurface(bytes, sidecar(bytes))).rejects.toThrow(/^las-surface-(input|budget)$/)
126 }
127 const good = primaryLasBytes()
128 await expect(importLasSurface(good.subarray(0, 472), sidecar(good.subarray(0, 472)))).rejects.toThrow('las-surface-input')
129 const trailer = new Uint8Array(474); trailer.set(good)
130 await expect(importLasSurface(trailer, sidecar(trailer))).rejects.toThrow('las-surface-input')
131 good[400] = good[400]! ^ 1
132 await expect(importLasSurface(good, primaryLasDeclaration())).rejects.toThrow('las-source-sha256-mismatch')
133 for (const declaration of ['PRIVATE_INVALID_JSON', '\uFEFF' + primaryLasDeclaration(), sidecar(primaryLasBytes(), d => { d.metadata.reference.horizontalCrs.id = 'EPSG:32619' }), sidecar(primaryLasBytes(), d => { d.selection.classes = [1, 1] })]) {
134 let message = ''
135 try { await importLasSurface(primaryLasBytes(), declaration) }
136 catch (error) { message = (error as Error).message }
137 expect(message).toBe('las-surface-input')
138 expect(message).not.toContain('PRIVATE')
139 }
140})
141
142// Matching labels cannot change the embedded vertical CRS's units or axis.
143test('a matching sidecar cannot interpret a feet or depth vertical CRS as metres-up', async () => {
144 for (const code of [5702, 5715]) {
145 for (const explicitMetreUnit of [false, true]) {
146 const bytes = primaryLasVerticalCrs(code, explicitMetreUnit)
147 await expect(importLasSurface(bytes, sidecar(bytes, d => { d.metadata.reference.verticalDatum.id = `EPSG:${code}` }))).rejects.toThrow('las-surface-input')
148 }
149 }
150})
151
152test('verified metre-up embedded vertical CRS labels preserve numeric elevations and explicit unverified datum evidence', async () => {
153 for (const code of [3855, 5701, 5703, 5714, 5773, 5783]) {
154 for (const explicitMetreUnit of [false, true]) {
155 const bytes = primaryLasVerticalCrs(code, explicitMetreUnit)
156 const imported = await importLasSurface(bytes, sidecar(bytes, d => { d.metadata.reference.verticalDatum.id = `EPSG:${code}` }))
157 expect(imported.surface.geometry.rows).toEqual([[3, 6], [9, null]])
158 expect(imported.processing.verticalReference).toEqual({ mode: 'declared-sidecar', declaredDatumId: `EPSG:${code}`, embeddedCrs: `EPSG:${code}`, unit: 'metre', positive: 'up', coordinateTransformPerformed: false, datumVerified: false })
159 }
160 }
161 const unknown = primaryLasVerticalCrs(65534, true)
162 await expect(importLasSurface(unknown, sidecar(unknown, d => { d.metadata.reference.verticalDatum.id = 'EPSG:65534' }))).rejects.toThrow('las-surface-input')
163 const different = primaryLasVerticalCrs(5703, true)
164 await expect(importLasSurface(different, sidecar(different, d => { d.metadata.reference.verticalDatum.id = 'EPSG:5773' }))).rejects.toThrow('las-surface-input')
165 const absent = await importLasSurface(primaryLasBytes(), primaryLasDeclaration())
166 expect(absent.processing.verticalReference.embeddedCrs).toBeNull()
167 expect(absent.processing.verticalReference.datumVerified).toBe(false)
168})
tests/unit/workspace-server-render.test.tsadded+51-0
Changes to tests/unit/workspace-server-render.test.ts
@@ -0,0 +1,51 @@
1import { expect, test } from 'bun:test'
2import { mkdtemp, rm } from 'node:fs/promises'
3import { tmpdir } from 'node:os'
4import { join, resolve } from 'node:path'
5import { log, registerTransport } from '@stacksjs/logging'
6import { report } from '@stacksjs/logging/runtime'
7import type { LogRecord } from '@stacksjs/logging'
8import { renderTemplate } from '@stacksjs/stx'
9
10for (const [page, heading] of [
11 ['diagnostics', 'Inspect what happened.'],
12 ['volumes', 'Stockpile volume analysis'],
13 ['application', 'Prescribed application'],
14] as const) {
15 test(`${page} server render keeps client analysis out of the native error reporter`, async () => {
16 const root = resolve(import.meta.dir, '../..')
17 const directory = await mkdtemp(join(tmpdir(), 'drone-workspace-render-'))
18 const previous = Object.getOwnPropertyDescriptor(globalThis, 'report')
19 const errors: LogRecord[] = []
20 const detach = registerTransport({ name: 'workspace-render-observer', level: 'error', log: record => { errors.push(record) } })
21 try {
22 // Use the actual production global, with an observing transport rather than a mock reporter.
23 Object.defineProperty(globalThis, 'report', { configurable: true, writable: true, value: report })
24 report(new Error('workspace-render-observer-control'))
25 await log.flush()
26 expect(errors).toHaveLength(1)
27 errors.length = 0
28 const html = await renderTemplate(join(root, 'resources/views', `${page}.stx`), {
29 injectCSS: true,
30 options: {
31 root: join(root, 'resources'),
32 componentsDir: join(root, 'resources/components'),
33 composablesDir: join(directory, 'composables'),
34 storesDir: join(directory, 'stores'),
35 stateDir: join(directory, 'stx'),
36 envFile: false,
37 strict: { enabled: true, bundlerFallback: 'error' },
38 },
39 })
40 await log.flush()
41 expect(html).toContain(heading)
42 expect(errors).toEqual([])
43 }
44 finally {
45 detach()
46 if (previous) Object.defineProperty(globalThis, 'report', previous)
47 else Reflect.deleteProperty(globalThis, 'report')
48 await rm(directory, { recursive: true, force: true })
49 }
50 }, 30_000)
51}