also looking at this
feat: import LAS surfaces and consume maintained source releases
#107
35 files
+1061
-416
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -389,6 +389,18 @@ jobs: | ||
| 389 | 389 | working-directory: ${{ runner.temp }} |
| 390 | 390 | - 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" |
| 391 | 391 | 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/** | |
| 392 | 404 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone inspection:plan "$GITHUB_WORKSPACE/examples/inspection.json" |
| 393 | 405 | working-directory: ${{ runner.temp }} |
| 394 | 406 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone inspection:simulate --visit before --output "$RUNNER_TEMP/inspection-before" |
| @@ -243,6 +243,10 @@ it keeps acquisition, reconstruction and measurement separate. | ||
| 243 | 243 | ./buddy volumes:import examples/stockpile/surface.asc --format asc --metadata examples/stockpile/metadata.json --measurement examples/stockpile/measurement.json |
| 244 | 244 | ``` |
| 245 | 245 | |
| 246 | A separate [LAS point-grid adapter](docs/volumes-las.md) constructs declared cell means | |
| 247 | from selected LAS1.2 UTM points and retains the original file and gridding evidence. | |
| 248 | Missing cells and unknown representation error remain explicit. | |
| 249 | ||
| 246 | 250 | Read the [numeric import, integration and audit contract](docs/volumes.md). Examples are synthetic; |
| 247 | 251 | physical surveyed accuracy, raw-point meshing and richer format adapters remain unverified. |
| 248 | 252 | |
| @@ -1,44 +1,63 @@ | ||
| 1 | 1 | import { defineCommand } from '@stacksjs/cli' |
| 2 | import { print, printError } from '@stacksjs/cli/output' | |
| 2 | import { printAsync, printErrorAsync } from '../../resources/functions/cli/output' | |
| 3 | 3 | import { importGridSurface, importPlySurface } from '../../resources/functions/drone/volumes/import' |
| 4 | 4 | import { importGeoTiffSurface } from '../../resources/functions/drone/volumes/geotiff' |
| 5 | import { readGeoTiffFile } from '../../resources/functions/drone/volumes/files' | |
| 5 | import { readGeoTiffFile, readLasFile } from '../../resources/functions/drone/volumes/files' | |
| 6 | import { importLasSurface } from '../../resources/functions/drone/volumes/las' | |
| 6 | 7 | import { processStockpile, validateSurfaceMetadata, validateVolumeRequest } from '../../resources/functions/drone/volumes/engine' |
| 7 | 8 | import { writeVolumeDataset } from '../../resources/functions/drone/volumes/export' |
| 8 | 9 | import { readMissionFile } from '../../resources/functions/drone/missions/files' |
| 9 | 10 | |
| 11 | const 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 | ]) | |
| 16 | function lasDiagnostic(error: unknown): string { | |
| 17 | const message = error instanceof Error ? error.message : '' | |
| 18 | return lasDiagnosticCodes.has(message) ? message : 'las-import-unavailable' | |
| 19 | } | |
| 20 | ||
| 10 | 21 | function parseDocument(text: string): unknown { |
| 11 | 22 | try { return JSON.parse(text) } |
| 12 | 23 | catch { throw new TypeError('Invalid surface metadata or measurement JSON') } |
| 13 | 24 | } |
| 14 | 25 | export default defineCommand({ |
| 15 | 26 | 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', | |
| 17 | 28 | 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', | |
| 20 | 31 | '--measurement <path>': 'Required JSON: version, id, boundary, base and reference', |
| 21 | 32 | '--output <directory>': 'New private audit dataset', |
| 22 | 33 | }, |
| 23 | 34 | async handle(options, path) { |
| 24 | 35 | try { |
| 25 | 36 | 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 | |
| 28 | 40 | ? await readMissionFile(options.metadata, { preserveBom: true, maxBytes: 32768 }) |
| 29 | 41 | : validateSurfaceMetadata(parseDocument(await readMissionFile(options.metadata))) |
| 30 | 42 | const measurement = parseDocument(await readMissionFile(options.measurement)) |
| 31 | 43 | 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' | |
| 33 | 47 | ? await importGeoTiffSurface(await readGeoTiffFile(path), metadata) |
| 34 | 48 | : options.format === 'asc' |
| 35 | 49 | ? await importGridSurface(await readMissionFile(path, { preserveBom: true }), metadata) |
| 36 | 50 | : await importPlySurface(await readMissionFile(path, { preserveBom: true }), metadata) |
| 37 | 51 | const request = validateVolumeRequest({ ...measurement, surface }) |
| 38 | 52 | 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. */ } | |
| 41 | 61 | } |
| 42 | catch (error) { printError(error instanceof Error ? error.message : String(error)); process.exitCode = 1 } | |
| 43 | 62 | }, |
| 44 | 63 | }) |
| @@ -102,6 +102,7 @@ const config: BunPressOptions = { | ||
| 102 | 102 | items: [ |
| 103 | 103 | { text: 'Aerial survey collection', link: '/mapping' }, |
| 104 | 104 | { text: 'Known-surface stockpile volumes', link: '/volumes' }, |
| 105 | { text: 'LAS point-grid processing', link: '/volumes-las' }, | |
| 105 | 106 | { text: 'Owned asset inspection', link: '/inspection' }, |
| 106 | 107 | { text: 'Owned agriculture sensing', link: '/agriculture' }, |
| 107 | 108 | { text: 'Open Farming integration', link: '/open-farming' }, |
| @@ -78,6 +78,12 @@ transport cancellation/recovery cases. The [operator contract](cargo-fleet.md#re | ||
| 78 | 78 | retains current authority, a single physical point, original receive deadlines and |
| 79 | 79 | attempted-intake uncertainty. It does not restore a failed vehicle session. |
| 80 | 80 | |
| 81 | `importLasSurface(bytes, declarationJson)` in `volumes/las.ts` produces a declared | |
| 82 | cell-mean surface and separate point-selection/gridding audit. The [LAS contract](volumes-las.md) | |
| 83 | describes profile limits, missing cells and unknown representation error. The server-only | |
| 84 | `writeVolumeDataset(directory, request, { format: 'las', bytes, declarationJson })` verifies | |
| 85 | that the retained original bytes/declaration reproduce the requested surface before publishing. | |
| 86 | ||
| 81 | 87 | ## Target sources |
| 82 | 88 | |
| 83 | 89 | `createGpsTargetSource({ targetId, origin, maxSpeedMps?, now? })` converts trusted phone or GNSS fixes |
| @@ -78,7 +78,7 @@ See the [scenario commands and expected outcomes](vehicles-vtol.md) before compa | ||
| 78 | 78 | |---|---|---| |
| 79 | 79 | | Camera tracking and follow | Explicit target selection, image-space loss handling and synthetic calibrated fusion | [Tracking](camera-tracking.md), [fusion](vision.md) | |
| 80 | 80 | | 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) | | |
| 82 | 82 | | Infrastructure inspection | Asset-relative modeled capture, explicit observations and repeat-visit comparison | [Inspection](inspection.md) | |
| 83 | 83 | | Farming | Synthetic crop sensing and declared planning previews; agronomy and authenticated app integration remain separate | [Agriculture](agriculture.md), [Open Farming](open-farming.md) | |
| 84 | 84 | | Prescribed application | Owned ground simulation for inert water or granules, with explicit implement authority | [Application](application.md) | |
| @@ -23,7 +23,7 @@ means there is no native implementation yet. A protocol-compatible transport is | ||
| 23 | 23 | | 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) | |
| 24 | 24 | | 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) | |
| 25 | 25 | | 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) | | |
| 27 | 27 | | 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) | |
| 28 | 28 | | 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) | |
| 29 | 29 | | 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) | |
| @@ -618,6 +618,28 @@ | ||
| 618 | 618 | "nativeDependencies": [ |
| 619 | 619 | 18, |
| 620 | 620 | 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" | |
| 621 | 643 | ] |
| 622 | 644 | }, |
| 623 | 645 | { |
| @@ -0,0 +1,151 @@ | ||
| 1 | # LAS point-grid stockpile processing | |
| 2 | ||
| 3 | `importLasSurface(bytes, declarationJson)` converts explicitly selected georeferenced | |
| 4 | LAS points into a declared cell-mean surface. The existing [volume engine](volumes.md) | |
| 5 | then integrates that representation against an operator boundary and plane base. This | |
| 6 | separates 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 | ||
| 12 | The same command compiles into the standalone CLI. It never acquires LiDAR, commands | |
| 13 | a vehicle, classifies a stockpile or verifies physical survey accuracy. LAS handler failures | |
| 14 | use bounded format-specific diagnostics without echoing private filenames or input bodies. | |
| 15 | ||
| 16 | LAS 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, | |
| 20 | measurement and output filesystem failures, emit `las-import-unavailable` with | |
| 21 | exit 1. Output writes are awaited; an error-pipe failure keeps exit 1. Partial | |
| 22 | output or partial dataset roots can remain after a delivery failure. Accept a | |
| 23 | successful result only after complete streams and exit 0. | |
| 24 | ||
| 25 | ## Explicit declaration | |
| 26 | ||
| 27 | The UTF-8 JSON declaration is limited to 32,768 bytes and code units. Closed objects | |
| 28 | require the following fields. JSON duplicate keys follow native parsing, so the final | |
| 29 | value 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 | ||
| 46 | The metadata requires WGS84 UTM and either synthetic analytic or operator-imported | |
| 47 | LiDAR provenance. The header projection must match its EPSG code and metre units. | |
| 48 | The vertical datum is an explicit sidecar declaration. An embedded vertical CRS must | |
| 49 | both match that declaration and belong to the verified metre-up profile below. A matching | |
| 50 | label or explicit metre-unit key cannot override a CRS's actual units or axis direction. | |
| 51 | No coordinate or geoid transformation is performed; datum accuracy remains unverified. | |
| 52 | Unknown classification, calibration and georeferencing status remain unknown. | |
| 53 | ||
| 54 | Points are assigned by `floor((east-west)/size)` and `floor((north-south)/size)`. | |
| 55 | Cells include their west/south boundaries and exclude east/north boundaries. Internal | |
| 56 | edges belong to the east/north cell. Output rows run north to south. No epsilon snap | |
| 57 | changes a point's cell. The importer uses compensated elevation sums and reports | |
| 58 | counts, minima, maxima and means. Cells below the count threshold or above the spread | |
| 59 | threshold remain null. A file with no supported cell is refused rather than reported | |
| 60 | as a zero-volume stockpile. | |
| 61 | ||
| 62 | Selection exclusions are disjoint in this order: withheld, synthetic, class, return, | |
| 63 | outside grid. The audit also retains the complete source class/return histograms. | |
| 64 | Class numbers are source labels, not verified material identities. Last returns do | |
| 65 | not automatically establish bare earth, and class 2 alone must not silently substitute | |
| 66 | the underlying ground for a stockpile top. | |
| 67 | ||
| 68 | ## Representation and uncertainty | |
| 69 | ||
| 70 | Each accepted sample mean represents its entire cell by an explicit modeling assumption. | |
| 71 | The volume engine's covered area refers to these modeled cells, not independently | |
| 72 | verified laser footprints or ground coverage. Duplicate points carry duplicate sample | |
| 73 | weight. Count and within-cell spread do not bound unsampled terrain, systematic bias, | |
| 74 | occlusion or representation error. `pointSamplingCoverageVerified` and | |
| 75 | `physicalAccuracyVerified` remain false; `representationErrorBoundM` remains null. | |
| 76 | No uncertainty is inferred from sample count or spread. | |
| 77 | ||
| 78 | Missing cells are not filled, and covered-domain volumes are not scaled to a full | |
| 79 | boundary total. Conditional declared vertical bounds retain the volume engine's | |
| 80 | assumptions; they do not become an overall physical accuracy bound. The volume report | |
| 81 | keeps acquisition/reconstruction flags false for the integration stage. The separate | |
| 82 | processing audit records `griddingPerformed: true` and its declared cell model. | |
| 83 | ||
| 84 | ## Supported binary profile | |
| 85 | ||
| 86 | This 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), | |
| 88 | particularly its public-header, GeoKey-directory and point-record tables. It accepts | |
| 89 | the 227-byte little-endian 1.2 header and uncompressed point formats 0, 1, 2 and 3 with | |
| 90 | their exact standard record lengths. Scale/offset coordinates, source bounds and | |
| 91 | per-return counts are checked against every point, including excluded points. | |
| 92 | Bounding-box comparison permits only eight binary64 epsilon-scaled rounding units; | |
| 93 | this numerical check is not a survey tolerance. Returns must be from 1 through 5, | |
| 94 | consistent with total returns; signed scan-angle ranks must lie within 90 degrees. | |
| 95 | ||
| 96 | The sole VLR must be an inline `LASF_Projection` GeoKey directory. Required keys are | |
| 97 | projected model, matching WGS84 UTM CRS and metre linear units. Optional point-raster, | |
| 98 | WGS84 geographic CRS and degree angular units have explicit checks. The optional | |
| 99 | vertical-unit key must declare metres. Embedded vertical CRS admission is limited to | |
| 100 | EPSG:3855 (EGM2008), 5701 (ODN), 5703 (NAVD88), 5714 (MSL height), 5773 (EGM96) and | |
| 101 | 5783 (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) | |
| 103 | and [axis definitions](https://github.com/OSGeo/PROJ/blob/5fa31c29f0a806568ed7bc76c1a557604db4a804/data/sql/axis.sql). | |
| 104 | Feet-based, depth-positive, unknown and other unqualified embedded CRSs are refused, | |
| 105 | even if their IDs match the sidecar and a separate key claims metres. The processing | |
| 106 | audit retains the actual embedded ID or its absence, declared datum, metre/up | |
| 107 | interpretation and explicit false transformation/datum-verification flags. Unsupported keys, extra VLRs, referenced parameter tables, custom transforms, | |
| 108 | compressed data, extended headers/records and arbitrary trailers are refused. Zero | |
| 109 | padding before the point table is permitted. Source files are limited to 8 MiB and | |
| 110 | 200,000 points. Coordinates and grid edges must fit within 100,000,000 metres. | |
| 111 | ||
| 112 | GPS fields in formats 1 and 3 retain week-seconds or adjusted standard GPS time with | |
| 113 | an observed range. They are not UTC or validated sensor-clock alignment. RGB, intensity, | |
| 114 | user data, source IDs and scan-direction/key-point flags do not determine the elevation | |
| 115 | mean; original bytes remain available in exported datasets. Colors are not interpreted | |
| 116 | as calibrated imagery. This is an explicit supported profile, not general LAS/LAZ support. | |
| 117 | ||
| 118 | The pure importer synchronously captures native unshared, fixed Uint8Array/Buffer | |
| 119 | visible bytes before its first await. It avoids caller accessors and iteration hooks; | |
| 120 | proxy, shared, resizable and detached storage are refused. The Node-only `readLasFile` | |
| 121 | uses the existing bounded regular-file reader and stays outside the pure drone barrel. | |
| 122 | ||
| 123 | ## Retained evidence and replay | |
| 124 | ||
| 125 | With `--output`, the private completion-marked dataset contains the usual request, | |
| 126 | report, 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 | ||
| 133 | The writer recomputes gridding and refuses a source/declaration that does not reproduce | |
| 134 | the requested surface before creating a dataset. The completion manifest hashes every | |
| 135 | file and distinguishes original point bytes from the regenerated ASC hash. Replaying | |
| 136 | `request.json`, reimporting the ASC, or importing the retained LAS/declaration preserves | |
| 137 | the numerical report. The normal console summary omits the per-cell table; it remains | |
| 138 | in the audit file and the pure importer result. | |
| 139 | ||
| 140 | The literal eight-point worksheet in `tests/fixtures/volumes/las.ts` was independently | |
| 141 | encoded 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 | |
| 143 | zero base, with the fourth cell unknown. It is synthetic evidence. Tests cover count, | |
| 144 | spread, class/return/flag selection, formats 0-3, malformed headers/records/projection, | |
| 145 | byte ownership, digest refusal, publication integrity and native replay. The copied | |
| 146 | CLI qualifier retains actual outputs and dataset hashes outside the checkout. | |
| 147 | ||
| 148 | Broader LAS/LAZ and VLR profiles, point triangulation, richer surfaces, streaming/tiled | |
| 149 | processing, measured calibration/classification and independent surveyed comparisons | |
| 150 | remain open under [#19](https://github.com/stacksjs/drone/issues/19). Physical survey | |
| 151 | and storage power-loss qualification are separate from these software checks. | |
| @@ -2,7 +2,9 @@ | ||
| 2 | 2 | |
| 3 | 3 | The owned volume engine processes an existing georeferenced surface. Acquisition, reconstruction |
| 4 | 4 | and volume measurement are separate stages. Survey PNG imagery is not a numerical elevation |
| 5 | surface. This engine neither reconstructs it nor invents terrain from unordered points. | |
| 5 | surface. The engine integrates declared surfaces. A separate [LAS point-grid adapter](volumes-las.md) | |
| 6 | can construct explicit cell means from selected georeferenced points, with representation | |
| 7 | error left unknown; it does not infer a continuous terrain mesh. | |
| 6 | 8 | |
| 7 | 9 | ```sh |
| 8 | 10 | ./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 | ||
| 42 | 44 | `volumes:import` requires a source file, its metadata JSON and a measurement JSON containing exactly |
| 43 | 45 | `version`, `id`, `boundary`, `base`, `reference`. For ASC/PLY, the metadata SHA256 must match the exact |
| 44 | 46 | original UTF-8 bytes, including line endings. UTF-8 decoding preserves a BOM so the ASCII parser |
| 45 | rejects it. GeoTIFF uses the separate wrapped declaration below and hashes the original binary bytes. | |
| 47 | rejects it. GeoTIFF and LAS use separate wrapped declarations and hash original binary bytes. | |
| 46 | 48 | |
| 47 | 49 | - AAIGrid `.asc`: square, corner-origin cells (`xllcorner`, `yllcorner`, `cellsize`), north-first |
| 48 | 50 | 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 | ||
| 55 | 57 | - Numeric GeoTIFF: the restrictive `classic-le-f32-utm-area-v1` profile below produces the same |
| 56 | 58 | constant-cell geometry, with the original binary digest retained. This is explicit |
| 57 | 59 | `--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. | |
| 58 | 63 | - Typed JSON retains either numerical representation directly. Imports are bounded at 8 MiB for |
| 59 | 64 | ASC/PLY; library JSON parsing allows 16 MiB, while the current CLI regular-file reader allows |
| 60 | 65 | 8 MiB. Caps include 32,768 vertices/cells, 16,384 triangles, 64 boundary vertices, eight holes, |
| 61 | 66 | and separate candidate/intersection/contribution budgets. Over-budget work fails explicitly. |
| 62 | 67 | |
| 63 | 68 | Numeric ASC/PLY exports include a matching regenerated sidecar. The original source hash remains |
| 64 | in the retained request and manifest. LAS/LAZ, broader GeoTIFF profiles, raw-point meshing, | |
| 69 | in the retained request and manifest. Broader LAS/LAZ, GeoTIFF profiles, raw-point triangulation, | |
| 65 | 70 | classification and photogrammetric reconstruction require separately qualified adapters. File extensions do not |
| 66 | 71 | establish CRS, vertical datum, ground classification or accuracy. |
| 67 | 72 | |
| @@ -26,6 +26,7 @@ export * from './volumes/engine' | ||
| 26 | 26 | export * from './volumes/fixtures' |
| 27 | 27 | export * from './volumes/import' |
| 28 | 28 | export * from './volumes/geotiff' |
| 29 | export * from './volumes/las' | |
| 29 | 30 | export * from './inspection/types' |
| 30 | 31 | export * from './inspection/planner' |
| 31 | 32 | export * from './inspection/geometry' |
| @@ -0,0 +1,46 @@ | ||
| 1 | export class SurfaceByteRejection extends Error { | |
| 2 | constructor(readonly kind: 'input' | 'budget' | 'unavailable') { super(kind) } | |
| 3 | } | |
| 4 | function 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. | |
| 7 | const 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 | ||
| 24 | export 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 | } | |
| @@ -4,13 +4,30 @@ import { join } from 'node:path' | ||
| 4 | 4 | import { writeCompletedArtifact } from '../../artifacts/completed-directory' |
| 5 | 5 | import { processStockpile, validateVolumeRequest } from './engine' |
| 6 | 6 | import { exportGridSurface, exportPlySurface } from './import' |
| 7 | import { importLasSurface } from './las' | |
| 8 | import { captureSurfaceBytes } from './bytes' | |
| 9 | ||
| 10 | export interface LasVolumeImportSource { format: 'las', bytes: Uint8Array, declarationJson: string } | |
| 11 | function 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 | } | |
| 7 | 24 | |
| 8 | 25 | function json(value: unknown): string { |
| 9 | 26 | return `${JSON.stringify(value, null, 2)}\n` |
| 10 | 27 | } |
| 11 | 28 | |
| 12 | function digest(contents: string): string { | |
| 13 | return createHash('sha256').update(contents, 'utf8').digest('hex') | |
| 29 | function digest(contents: string | Uint8Array): string { | |
| 30 | return createHash('sha256').update(contents).digest('hex') | |
| 14 | 31 | } |
| 15 | 32 | |
| 16 | 33 | function csvCell(value: string | number): string { |
| @@ -19,8 +36,11 @@ function csvCell(value: string | number): string { | ||
| 19 | 36 | } |
| 20 | 37 | |
| 21 | 38 | /** Recompute and publish an exclusive, completion-marked numerical dataset. Server only. */ |
| 22 | export async function writeVolumeDataset(directory: string, input: VolumeRequest): Promise<{ directory: string, manifest: string, report: string, contributions: string, manifestSha256: string }> { | |
| 39 | export async function writeVolumeDataset(directory: string, input: VolumeRequest, importSource?: LasVolumeImportSource): Promise<{ directory: string, manifest: string, report: string, contributions: string, manifestSha256: string }> { | |
| 23 | 40 | 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') | |
| 24 | 44 | const report = processStockpile(request) |
| 25 | 45 | const sourcePath = request.surface.geometry.kind === 'cell-grid' ? 'surface.asc' : 'surface.ply' |
| 26 | 46 | 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 | ||
| 29 | 49 | sidecar.provenance.sourceSha256 = regeneratedSourceSha256 |
| 30 | 50 | const columns = ['sourceId', 'boundaryPart', 'weight', 'areaM2', 'aboveBaseM3', 'belowBaseM3', 'signedTopMinusBaseM3', 'polygonEastNorthM'] |
| 31 | 51 | 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 }[] = [ | |
| 33 | 53 | { path: 'request.json', contents: json(request) }, |
| 34 | 54 | { path: 'report.json', contents: json(report) }, |
| 35 | 55 | { path: 'contributions.csv', contents: contributions }, |
| 36 | 56 | { path: sourcePath, contents: sourceText }, |
| 37 | 57 | { path: 'surface.metadata.json', contents: json(sidecar) }, |
| 38 | 58 | ] |
| 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 | ) | |
| 39 | 64 | const manifest = { |
| 40 | 65 | version: 1, |
| 41 | 66 | kind: 'stockpile-volume-dataset', |
| @@ -51,7 +76,8 @@ export async function writeVolumeDataset(directory: string, input: VolumeRequest | ||
| 51 | 76 | regeneratedSourceSha256, |
| 52 | 77 | sourcePath, |
| 53 | 78 | 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) })), | |
| 55 | 81 | policy: { |
| 56 | 82 | coordinates: 'Easting, northing, elevation up in declared metre-based reference. No CRS or vertical datum conversion.', |
| 57 | 83 | 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.', |
| @@ -2,7 +2,7 @@ import { constants } from 'node:fs' | ||
| 2 | 2 | import { open } from 'node:fs/promises' |
| 3 | 3 | |
| 4 | 4 | /** Bounded local bytes. Parent paths remain caller-owned; concurrent writes are not atomic. */ |
| 5 | export async function readGeoTiffFile(path: string): Promise<Uint8Array> { | |
| 5 | async function readBinarySurfaceFile(path: string): Promise<Uint8Array> { | |
| 6 | 6 | try { |
| 7 | 7 | 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() |
| 8 | 8 | 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> { | ||
| 21 | 21 | } |
| 22 | 22 | finally { await file.close() } |
| 23 | 23 | } |
| 24 | catch { throw new Error('binary-surface-file-unavailable') } | |
| 25 | } | |
| 26 | ||
| 27 | export async function readGeoTiffFile(path: string): Promise<Uint8Array> { | |
| 28 | try { return await readBinarySurfaceFile(path) } | |
| 24 | 29 | catch { throw new Error('geotiff-file-unavailable') } |
| 25 | 30 | } |
| 31 | ||
| 32 | export async function readLasFile(path: string): Promise<Uint8Array> { | |
| 33 | try { return await readBinarySurfaceFile(path) } | |
| 34 | catch { throw new Error('las-file-unavailable') } | |
| 35 | } | |
| @@ -1,3 +1,4 @@ | ||
| 1 | import { captureSurfaceBytes, SurfaceByteRejection } from './bytes' | |
| 1 | 2 | import type { SurfaceGeometry, SurfaceMetadata } from './types' |
| 2 | 3 | import { validateKnownSurface, validateSurfaceMetadata } from './validation' |
| 3 | 4 | |
| @@ -20,48 +21,6 @@ class Rejection extends Error { | ||
| 20 | 21 | } |
| 21 | 22 | function reject(kind: FailureKind = 'input'): never { throw new Rejection(kind) } |
| 22 | 23 | |
| 23 | // Only trusted native functions are captured. Caller properties and prototypes are never read. | |
| 24 | const 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 | ||
| 41 | function 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 | ||
| 65 | 24 | // Called only on JSON.parse-owned data, never an arbitrary caller object graph. |
| 66 | 25 | function closed(value: unknown, keys: readonly string[]): Record<string, unknown> { |
| 67 | 26 | 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 | ||
| 273 | 232 | /** Restrictive numeric elevation profile, without acquisition or accuracy authentication. */ |
| 274 | 233 | export async function importGeoTiffSurface(bytes: Uint8Array, declarationJson: string): Promise<GeoTiffCellSurface> { |
| 275 | 234 | 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) | |
| 277 | 236 | const subtle = globalThis.crypto?.subtle |
| 278 | 237 | if (!subtle || typeof subtle.digest !== 'function') return reject('unavailable') |
| 279 | 238 | const digest = new Uint8Array(await subtle.digest('SHA-256', owned)) |
| @@ -295,7 +254,7 @@ export async function importGeoTiffSurface(bytes: Uint8Array, declarationJson: s | ||
| 295 | 254 | return { version: 1, metadata: surface.metadata, geometry: surface.geometry } |
| 296 | 255 | } |
| 297 | 256 | catch (error) { |
| 298 | if (error instanceof Rejection) { | |
| 257 | if (error instanceof Rejection || error instanceof SurfaceByteRejection) { | |
| 299 | 258 | if (error.kind === 'input') throw new TypeError('geotiff-surface-input') |
| 300 | 259 | if (error.kind === 'budget') throw new RangeError('geotiff-surface-budget') |
| 301 | 260 | if (error.kind === 'digest') throw new Error('geotiff-source-sha256-mismatch') |
| @@ -0,0 +1,139 @@ | ||
| 1 | import type { SurfaceMetadata, SurfaceGeometry } from './types' | |
| 2 | import { captureSurfaceBytes, SurfaceByteRejection } from './bytes' | |
| 3 | import { validateKnownSurface, validateSurfaceMetadata, volumeLimits } from './validation' | |
| 4 | ||
| 5 | export 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. | |
| 15 | const metreUpVerticalCrs = new Set([3855, 5701, 5703, 5714, 5773, 5783]) | |
| 16 | ||
| 17 | class Rejection extends Error { | |
| 18 | constructor(readonly kind: 'input' | 'budget' | 'digest' | 'unavailable' | 'empty' = 'input') { super(kind) } | |
| 19 | } | |
| 20 | function reject(kind: Rejection['kind'] = 'input'): never { throw new Rejection(kind) } | |
| 21 | const finite = (value: unknown, min: number, max: number): value is number => typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max | |
| 22 | const integer = (value: unknown, min: number, max: number): value is number => Number.isSafeInteger(value) && finite(value, min, max) | |
| 23 | function 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 | } | |
| 27 | function 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 | } | |
| 44 | async 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 | } | |
| 48 | function 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. */ | |
| 72 | export 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 | } | |
| @@ -0,0 +1,64 @@ | ||
| 1 | import { chmod, copyFile, mkdir, readFile } from 'node:fs/promises' | |
| 2 | import { join, resolve } from 'node:path' | |
| 3 | import { runBoundedHostProcess } from '../resources/functions/host/process' | |
| 4 | import { primaryLasBytes, primaryLasDeclaration, primaryLasMeasurement, primaryLasRecordFormat, primaryLasSha256, primaryLasVerticalCrs } from '../tests/fixtures/volumes/las' | |
| 5 | ||
| 6 | const args = process.argv.slice(2) | |
| 7 | if (args.length !== 2) throw new Error('Usage: qualify-las-cli <binary> <new-output-directory>') | |
| 8 | const source = resolve(args[0]!), output = resolve(args[1]!) | |
| 9 | await mkdir(output, { mode: 0o700 }) | |
| 10 | const binary = join(output, 'drone'), sha = (bytes: Uint8Array | string) => new Bun.CryptoHasher('sha256').update(bytes).digest('hex') | |
| 11 | await copyFile(source, binary); await chmod(binary, 0o755) | |
| 12 | await Bun.write(join(output, 'source.las'), primaryLasBytes()) | |
| 13 | await Bun.write(join(output, 'declaration.json'), primaryLasDeclaration()) | |
| 14 | await Bun.write(join(output, 'measurement.json'), JSON.stringify(primaryLasMeasurement())) | |
| 15 | const rows: { name: string, exit: number, stdoutBytes: number, stdoutSha256: string, stderrBytes: number, stderrSha256: string }[] = [] | |
| 16 | async 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 | } | |
| 23 | const common = ['--format', 'las', '--metadata', join(output, 'declaration.json'), '--measurement', join(output, 'measurement.json')] | |
| 24 | const first = await run('import', ['volumes:import', join(output, 'source.las'), ...common, '--output', join(output, 'dataset')]) | |
| 25 | if (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') | |
| 27 | const manifest = JSON.parse(await readFile(join(output, 'dataset/manifest.json'), 'utf8')) | |
| 28 | for (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 | } | |
| 32 | const processing = JSON.parse(await readFile(join(output, 'dataset/source.gridding.json'), 'utf8')) | |
| 33 | if (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') | |
| 35 | const replay = await run('replay', ['volumes:process', join(output, 'dataset/request.json')]) | |
| 36 | const 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')]) | |
| 37 | if (JSON.stringify(replay.report) !== JSON.stringify(first.report) || JSON.stringify(asc.report) !== JSON.stringify(first.report)) throw new Error('Unqualified copied LAS replay') | |
| 38 | for (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 | } | |
| 45 | const wrong = JSON.parse(primaryLasDeclaration()); wrong.metadata.provenance.sourceSha256 = '0'.repeat(64) | |
| 46 | await Bun.write(join(output, 'wrong.json'), JSON.stringify(wrong)) | |
| 47 | const refused = await run('wrong-digest', ['volumes:import', join(output, 'source.las'), '--format', 'las', '--metadata', join(output, 'wrong.json'), '--measurement', join(output, 'measurement.json')], 1) | |
| 48 | if (refused.stderr.trim() !== 'las-source-sha256-mismatch') throw new Error('Unqualified copied LAS digest refusal') | |
| 49 | for (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 | } | |
| 56 | for (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 | } | |
| 63 | await 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)) | |
| 64 | console.log('PASS copied LAS source, four record formats, grid audit, volume replay, vertical interpretation and private refusals') | |
| @@ -0,0 +1,44 @@ | ||
| 1 | import 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. | |
| 4 | export const primaryLasSha256 = '17efc5a7cfe3a40ea8ba8ddb10a1d5330e5621b95866fc3df47eb1c69a24f702' | |
| 5 | const hex = '4c415346000000000000000000000000000000000000000001020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e30039010000010000000014000800000007000000010000000000000000000000000000007b14ae47e17a843f7b14ae47e17a843f7b14ae47e17a843f0000000080841e4100000000d0474f4100000000000000000000000088841e410000000081841e41000000e0d0474f4100000020d0474f410000000000005940000000000000004000004c4153465f50726f6a656374696f6e00af872000000000000000000000000000000000000000000000000000000000000000000001000100000003000004000001000100000c000001006a7f040c000001002923190000007d000000c800000000000901000000004b000000af0000009001000000001201000000007d0000007d00000058020000000009010000000019000000190000002003000000000901000000004b0000004b000000e80300000000090100000000af000000190000001027000000000981000000007d0000004b000000ac2600000000090700000000c800000064000000f40100000000090100000000' | |
| 6 | export function primaryLasBytes(): Uint8Array { return Uint8Array.from(hex.match(/../g)!, byte => Number.parseInt(byte, 16)) } | |
| 7 | export 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 | } | |
| 17 | export 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. | |
| 22 | export 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. | |
| 35 | export 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 | } | |
| @@ -120,6 +120,7 @@ | ||
| 120 | 120 | "./unit/mapping-*.test.ts", |
| 121 | 121 | "./unit/survey-observations.test.ts", |
| 122 | 122 | "../resources/functions/drone/volumes/**/*.ts", |
| 123 | "../scripts/qualify-las-cli.ts", | |
| 123 | 124 | "../resources/functions/artifacts/**/*.ts", |
| 124 | 125 | "./unit/volumes-*.test.ts", |
| 125 | 126 | "./unit/completed-artifact.test.ts", |
| @@ -0,0 +1,119 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { command } from '@stacksjs/testing' | |
| 3 | import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' | |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import { join } from 'node:path' | |
| 6 | import { createHash } from 'node:crypto' | |
| 7 | import { primaryLasBytes, primaryLasDeclaration, primaryLasMeasurement, primaryLasSha256 } from '../fixtures/volumes/las' | |
| 8 | import { importLasSurface } from '../../resources/functions/drone/volumes/las' | |
| 9 | import { validateVolumeRequest } from '../../resources/functions/drone/volumes/engine' | |
| 10 | import { writeVolumeDataset } from '../../resources/functions/drone/volumes/export' | |
| 11 | ||
| 12 | test('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 | ||
| 47 | test('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 | ||
| 69 | test('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. | |
| 92 | test('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) | |
| @@ -0,0 +1,168 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { importLasSurface } from '../../resources/functions/drone/volumes/las' | |
| 3 | import { processStockpile } from '../../resources/functions/drone/volumes/engine' | |
| 4 | import { primaryLasBytes, primaryLasDeclaration, primaryLasMeasurement, primaryLasSha256, primaryLasVerticalCrs } from '../fixtures/volumes/las' | |
| 5 | import type { LasGridDeclaration } from '../../resources/functions/drone/volumes/las' | |
| 6 | import { createHash } from 'node:crypto' | |
| 7 | ||
| 8 | function 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. | |
| 17 | test('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 | ||
| 36 | test('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 | ||
| 53 | test('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 | ||
| 63 | test('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. | |
| 83 | test('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 | ||
| 102 | test('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 | ||
| 116 | test('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. | |
| 143 | test('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 | ||
| 152 | test('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 | }) | |