From fbb068ec702338d609c1ca6eddbdb9f182d8f211 Mon Sep 17 00:00:00 2001
From: shuishen <1109946754@qq.com>
Date: Mon, 24 Aug 2026 11:37:16 +0800
Subject: [PATCH] feat: extend local GeoAI capability workflows

---
 capabilities/00-change-detection/scan_change_detection_parameters.py          |   16 
 capabilities/05-3d-pointcloud/generate_validation_inputs.py                   |   69 
 apps/workbench-console/README.md                                              |  105 
 capabilities/00-change-detection/tests/test_evaluate_change_detection.py      |  103 
 .gitignore                                                                    |   10 
 capabilities/05-3d-pointcloud/run_pointcloud_understanding.py                 |  683 ++++++
 capabilities/05-3d-pointcloud/tests/test_photo_pose_alignment.py              |   37 
 apps/workbench-console/src/components/TexturedMeshViewer.vue                  |  199 +
 capabilities/00-change-detection/tests/test_change_detection.py               |   12 
 apps/workbench-console/src/data/capabilities.ts                               |    9 
 apps/workbench-console/src/components/SparsePointCloudViewer.vue              |  241 ++
 apps/workbench-console/src/components/ChangeDetectionPanel.vue                |    6 
 capabilities/00-change-detection/run_change_detection.py                      |   61 
 apps/workbench-console/src/api/artifacts.ts                                   |   71 
 capabilities/05-3d-pointcloud/prepare_odx_reconstruction_inputs.py            |  261 ++
 capabilities/05-3d-pointcloud/tests/test_cpu_dense_reconstruction.py          |  111 +
 capabilities/00-change-detection/README.md                                    |   62 
 capabilities/05-3d-pointcloud/README.md                                       |  423 +++
 capabilities/05-3d-pointcloud/run_photo_reconstruction.py                     |  338 +++
 tests/test_serve_workbench_console.py                                         |  108 +
 apps/workbench-console/src/components/RiskRulePanel.vue                       |  110 +
 apps/workbench-console/src/components/PointCloudAnnotationViewer.vue          |   82 
 capabilities/05-3d-pointcloud/diagnose_photo_pose_alignment.py                |  279 ++
 capabilities/05-3d-pointcloud/tests/test_photo_reconstruction.py              |   45 
 apps/workbench-console/src/components/PointCloudPanel.vue                     |  333 +++
 apps/workbench-console/src/views/CapabilityView.vue                           |    4 
 capabilities/05-3d-pointcloud/run_cpu_dense_reconstruction.py                 |  417 +++
 capabilities/05-3d-pointcloud/tests/test_rtk_lrf_pose_triangulation.py        |   31 
 capabilities/07-risk-rule-engine/tests/test_risk_rule_engine.py               |   57 
 capabilities/07-risk-rule-engine/generate_validation_inputs.py                |   70 
 capabilities/07-risk-rule-engine/requirements.txt                             |    6 
 apps/workbench-console/package-lock.json                                      |   57 
 apps/workbench-console/package.json                                           |    2 
 capabilities/05-3d-pointcloud/tests/test_pointcloud_understanding.py          |   65 
 scripts/serve_workbench_console.py                                            |  692 ++++++
 capabilities/05-3d-pointcloud/apply_pointcloud_semantic_model.py              |  191 +
 capabilities/07-risk-rule-engine/README.md                                    |   69 
 capabilities/05-3d-pointcloud/tests/test_prepare_odx_reconstruction_inputs.py |   66 
 AGENTS.md                                                                     |    1 
 apps/workbench-console/src/stores/artifacts.ts                                |   24 
 PROJECT_CONTEXT.md                                                            |  107 
 capabilities/05-3d-pointcloud/train_pointcloud_semantic_model.py              |  191 +
 capabilities/05-3d-pointcloud/triangulate_with_rtk_lrf_poses.py               |  119 +
 capabilities/00-change-detection/benchmark_manifest.example.csv               |    2 
 capabilities/07-risk-rule-engine/run_risk_rule_engine.py                      |  240 ++
 capabilities/00-change-detection/evaluate_change_detection.py                 |  255 ++
 capabilities/05-3d-pointcloud/requirements.txt                                |    4 
 47 files changed, 6,400 insertions(+), 44 deletions(-)

diff --git a/.gitignore b/.gitignore
index baa1405..3662951 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,3 +26,13 @@
 shared/outputs/*
 !**/.gitkeep
 !shared/**/README.md
+
+# User-provided source data, local tools, experiment scratch space, and logs
+# are intentionally local-only. They are either large/binary or reproducible.
+/baseData/
+/reference_project/
+/shared/tools/
+/shared/scratch/
+/shared/logs/
+/*.log
+/shared/*.log
diff --git a/AGENTS.md b/AGENTS.md
index 11b613b..493b33d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -38,6 +38,7 @@
 ## Engineering Rules
 
 - Prefer existing project patterns and `geoai-py` APIs over new abstractions.
+- Do not start, control, or use a browser to inspect local-console visual output. For UI, map, image, or 3D-model visual acceptance, state the exact view or interaction that needs checking and ask the user to provide screenshots; use non-browser build, HTTP, artifact, and automated tests independently.
 - Preserve geospatial coordinates only when the input has a valid CRS and transform. Ordinary JPEG results remain pixel coordinates.
 - Report false positives, misses, runtime, and unsupported classes honestly. Do not hide limitations by lowering thresholds.
 - Do not batch-run large datasets until a representative validation indicates that the approach is suitable.
diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md
index d968f2a..1395b9d 100644
--- a/PROJECT_CONTEXT.md
+++ b/PROJECT_CONTEXT.md
@@ -1,6 +1,6 @@
 # GeoAI Workbench Current Context
 
-Last updated: 2026-08-19
+Last updated: 2026-08-24
 
 This file is the current project snapshot for new Codex tasks. Keep it concise and replace stale facts instead of appending a conversation diary.
 
@@ -136,13 +136,35 @@
 
 | Capability | Status | Current conclusion |
 | --- | --- | --- |
-| `00-change-detection` | Runnable CPU Demo verified | A capability: `geoai.ChangeStarDetection` plus `geoai.masks_to_vector`; ORB registration and JPG-to-GeoTIFF conversion are input preparation. Auto/image mode handles ordinary imagery in pixel coordinates; GeoTIFF mode preserves spatial reference for inputs with matching CRS. When two GeoTIFF grids differ, the second image is reprojected to the first grid only in the processing copy; original files are unchanged. Real pair `07-16.jpg` -> `07-19.jpg` completed in about 41.6 seconds at 1024 px, with 755/769 registration inliers, 98.52% valid area, 674 changed pixels and one GeoJSON polygon. CLI/API/console runs accept per-run thresholds and processing modes/resolution options, all recorded in `run_metadata.json`. |
+| `00-change-detection` | Runnable CPU Demo verified; benchmark evaluator ready | A capability: `geoai.ChangeStarDetection`; ORB registration and JPG-to-GeoTIFF conversion are input preparation, while final cleaned-mask vector export uses Rasterio to preserve one feature per retained region. Auto/image mode handles ordinary imagery in pixel coordinates; GeoTIFF mode preserves spatial reference for inputs with matching CRS. When two GeoTIFF grids differ, the second image is reprojected to the first grid only in the processing copy; original files are unchanged. Real pair `07-16.jpg` -> `07-19.jpg` completed in about 41.6 seconds at 1024 px, with 755/769 registration inliers, 98.52% valid area, 674 changed pixels and one GeoJSON polygon. CLI/API/console runs accept per-run thresholds and processing modes/resolution options, all recorded in `run_metadata.json`. An independent evaluator now rejects misaligned truth grids and reports pixel TP/FP/FN/TN, Precision, Recall, F1, IoU and threshold comparisons, but no reviewed real truth set exists yet. |
 | `01-object-detection` | In progress; runnable people and vehicle experiments | Tiled YOLO helps people; GeoAI NWPU substantially improves top-down vehicles; tree detection is not implemented. |
 | `02-semantic-mapping` | Runnable CPU baseline verified | B capability: deterministic RGB/HSV segmentation plus `geoai.masks_to_vector`; raster and vector outputs are inspectable, but this is not a trained GeoAI model. |
 | `04-spatial-measurement` | Runnable CPU Demo verified | B capability: `geoai.masks_to_vector` plus ecosystem measurement; counts labelled raster objects and reports area/perimeter in projected or explicitly non-metric pixel/coordinate units. |
+| `07-risk-rule-engine` | Runnable CPU Demo verified | C capability: deterministic spatial zone-intersection and attribute-threshold scoring, not a built-in `geoai-py` function. It consumes CRS-aware GeoJSON inputs and produces an inspectable risk GeoTIFF, preview PNG, per-object scoring GeoJSON/CSV, summary and full run metadata. |
 | `09-anomaly-detection` | Runnable CPU CLI Demo verified | B capability: interpretable image features plus robust rules and Isolation Forest score visual outliers; `geoai-py` supplies `masks_to_vector`. Results are review candidates, not business-event conclusions. |
 | `15-trajectory-analysis` | Runnable CPU Demo verified | C capability consuming timestamped tracks: spatial metrics, DBSCAN and explicit behavior rules; not a built-in `geoai-py` function. |
-| `03`, `05` through `08`, `10` through `14`, `16` through `18` | Directory and initial README only | No verified local Demo yet. Start each one through `$geoai-capability-builder`. |
+| `05-3d-pointcloud` | Runnable CPU Demo verified; semantic classification baseline added | B capability: pycolmap CPU SfM plus OpenMVS CPU MVS reconstructs dense point clouds, textured meshes and browser-viewable GLB from a coherent photo sequence; Open3D plus `geoai.masks_to_vector` produces DSM/elevated-surface GeoJSON. A separate explainable CPU RGB/local-geometry baseline produces semantic review candidates, not trained-model labels. |
+| `03`, `06`, `08`, `10` through `14`, `16` through `18` | Directory and initial README only | No verified local Demo yet. Start each one through `$geoai-capability-builder`. |
+
+## Three-dimensional Reconstruction And Point-cloud Snapshot
+
+- Scripts: `capabilities/05-3d-pointcloud/run_pointcloud_understanding.py`, `run_photo_reconstruction.py`, read-only DJI RTK/LRF diagnostic `diagnose_photo_pose_alignment.py`, and `prepare_odx_reconstruction_inputs.py`; fixture generator: `generate_validation_inputs.py`.
+- Boundary: B. `geoai-py 0.42.0` has no point-cloud/SfM/MVS API in the installed package. pycolmap 4.1.1 performs CPU SIFT matching and incremental sparse SfM. Open3D 0.19.0 performs existing-point-cloud I/O, voxel filtering, RANSAC dominant-ground fitting and alpha-shape mesh construction. `geoai.masks_to_vector` receives the 0/255 elevated-surface raster; Rasterio is used as a documented completeness repair when GeoAI contour output does not cover every retained raster region.
+- Input/output contract: an existing PLY/PCD/XYZ/LAS/LAZ produces classified/mesh PLY, DSM and height GeoTIFFs, elevated-surface raster/preview, elevated-footprint GeoJSON, CSV and metadata. A coherent JPG/JPEG sequence produces a COLMAP sparse model, coloured PLY, PCA preview PNG, local camera poses CSV, EXIF GPS manifest and metadata with input SHA-256; the verified OpenMVS CPU branch additionally produces dense PLY, mesh PLY, texture PNGs, source GLB, a reliable-texture preview GLB, a complete-geometry neutral GLB, and a point-colour complete-geometry GLB. The reliable preview vertically flips atlas pixels for browser sampling and removes faces whose four interior samples hit black atlas background or OpenMVS's orange `RGB(255,127,39)` no-coverage colour; its JSON report records every kept/rejected count. The geometry preview retains all reconstructed mesh faces without inventing texture; the point-colour preview uses nearest fused RGB values and is not a per-face photo-texture claim.
+- Environment: `.venvs/05-3d-pointcloud` is a dedicated Python 3.12 environment that exposes the verified package directory from `04-spatial-measurement` through a local `.pth` file and adds Open3D 0.19.0, Laspy 2.7.0, Trimesh 4.12.2, pycolmap 4.1.1 and Matplotlib 3.11.1. It uses CPU only; `pip check` passes.
+- Validation: normal known-geometry PLY (9,600 source points) completed in 4.177 seconds, retained 7,644 downsampled points, 2,901 elevated points, 52 footprint regions and a 15,110-triangle mesh. Difficult sparse/slope/noise PLY completed in 3.831 seconds with 73 fragmented elevated footprints, explicitly demonstrating isolated-high-point false positives. Both PNG previews and GeoJSON outputs were inspected.
+- Real input: PCL tutorial `table_scene_lms400.pcd` is staged unchanged at `shared/data/raw/05-3d-pointcloud/pcl-table-scene-20260821/`, SHA-256 `E285D415641E0D9DE695B611DB874CC8FE995E8089B77A50D6056D24D8CBCC58`. Its 460,400 input points completed in 4.833 seconds with processing-copy `--ground-up-axis auto`: 41,143 downsampled points, 19,281 elevated points, 193 footprints and 7,090 mesh triangles. Its source-local vertical was not Z, so a 61.1666-degree dominant-plane rotation is recorded. This validates PCD handling and axis alignment, not drone reconstruction accuracy or semantic class accuracy.
+- Photo validation: the user-provided 18 DJI H30T side-view JPGs were staged unchanged under `shared/data/raw/05-3d-pointcloud/h30t-sideview-20260820-v2/`. The preserved original 18-photo high-resolution baseline has 3,379,097 dense points and 799,632 faces but failed the LRF diagnostic (13.3814 m median 3D error; 11.9794 m horizontal; -4.8131 m signed vertical). The current ODX-aligned local configuration uses COLMAP `OPENCV` intrinsics, RTK/GPS spatial matching (four neighbours), 2,000 px feature extraction, 18,000 features and a 0.05 m position prior. Normal and difficult six-photo validations each registered 6/6 images with 0.7581 px and 0.7596 px mean reprojection error. The full 18-photo sparse model registered 18/18 images, has 23,084 sparse points and 0.8410 px mean reprojection error. Its 2,400 px OpenMVS CPU run completed in 2,390.086 seconds, yielding 3,756,551 dense points and a 799,645-face mesh. The reliable-texture preview retains 293,614 faces (36.72%) after black/orange-atlas rejection; the point-colour and neutral complete-geometry previews retain all 799,645 faces. The LRF diagnostic now reports 0.4038 m median 3D nearest-point distance, 0.3073 m horizontal error and 0.0709 m signed vertical error. These target observations are diagnostic only, not independent surveyed check points. A two-photo low-baseline trial failed cleanly, documenting the need for sufficient baseline.
+- WebODM reference: `reference_project/WebODM-master` confirms that its task/UI service delegates reconstruction to NodeODX and that the engine supports ODX `geo.txt`, GCPs, and trusted `cameras.json`/fixed-camera-parameter workflows. The local NodeODX submodule is not populated, Docker is absent, and neither NodeODX nor NodeODM is installed, so WebODM was not executed. The new read-only preparation run `shared/outputs/05-3d-pointcloud/odx-input-h30t-sideview-20260822-v2/` validated all 18 DJI images and writes an `EPSG:4326` ODX `geo.txt`, camera-position GeoJSON, source-hash manifest, parameter advice, and compatibility report. It mirrors ODX's DJI orientation normalization and per-photo RTK accuracy inflation; global ODX `gps-accuracy` must remain unset because it would overwrite those per-photo values. The package intentionally does not treat LRF targets as GCPs or fabricate a `cameras.json` calibration.
+- Console: `GET/POST /api/3d-pointcloud/runs` remains enabled on the independent local console at `http://127.0.0.1:6188`. The top workflow tabs separate `照片三维重建` from `既有点云理解`: photos accept 3-30 JPG/JPEG files through bounded binary uploads, preserve raw bytes in a new run directory, use an asynchronous fixed-script CPU SfM/MVS job and poll queued/sparse/dense/completed/failed status; the GPS/RTK prior toggle selects the verified spatial-neighbour configuration only for photos with valid position metadata. Existing point-cloud uploads accept at most two allowlisted PLY/PCD/XYZ/LAS/LAZ files and use only the fixed Open3D script to produce raster/vector outputs. The case library discovers `ODX 对齐 CPU 高分重建(H30T 18 图)` from output metadata. Dense CPU-MVS results have reliable-texture, point-colour and complete-geometry modes, a wireframe toggle, local-`+Z` top-down button, filter report, dense PLY and raw mesh downloads. The geometry mode is deliberately neutral and retains every mesh face; point-colour uses fused-point RGB; texture remains evidence-only. The console only needs `npm run build` after Vue/TypeScript source changes, not after user uploads, reconstruction runs or automatic case discovery.
+- Limitations and next decision: this is a dense/textured local model that materially improves the DJI LRF diagnostic, but it is not a survey-grade result or independent check-point validation. pycolmap dense PatchMatch still requires CUDA; the verified dense route is OpenMVS CPU. It makes no DSM, orthomosaic, GIS-coordinate, semantic-class or engineering-measurement claim. Further accuracy work needs DJI/H30T intrinsics plus GNSS antenna-to-camera lever-arm/boresight calibration, or surveyed GCPs and separate check points. A CUDA/cloud ODX/WebODM workflow is a separate option only when it consumes the prepared `geo.txt`, trusted calibration/control, and reports independent checkpoint residuals. Existing-point-cloud ground/elevated is geometric rather than building/tree/equipment classification; its alpha-shape mesh is not watertight or accuracy-validated. OpenMVS and WebODM are AGPL-3.0-only, so commercial use needs a separate license assessment; NodeODX/ODX, DJI imagery, and all source data licenses remain separate.
+
+### Point-cloud semantic classification update
+
+- The existing-point-cloud branch now writes a capped colour preview PLY, semantic LAS, class raster/PNG, candidate GeoJSON and CSV for ground, vegetation, building/structure, pole/tower, power-line and unknown. This is a transparent CPU local-geometry baseline, not a trained semantic model. It uses local shape rather than RGB vetoes for power facilities: horizontal linearity for conductors and vertical linearity for pole/tower members.
+- The unchanged `baseData/las/part_01.las` has 10,473,127 RGB/return points but every source classification value is `0`, so it is not truth data. `validation-las-part01-semantic-20260822-v11-original-rgb` completed in 73.586 CPU seconds after 0.20-unit voxel sampling: 356,170 ground, 714,122 vegetation, 1,031 structure, 3,183 pole/tower candidates, 8,162 power-line candidates and 288,316 unknown. Preview sampling retains all line/pole candidates before downsampling other categories. Candidate precision/recall require visual review and labelled truth before an asset, clearance, or inspection claim.
+- The local 6188 console has a semantic-classification tab with bounded binary PLY/PCD/XYZ/LAS/LAZ upload and a dense-MVS-case action that resolves only the selected fixed `dense.ply`.
+- The current H30T dense PLY route was checked as `validation-h30t-dense-semantic-20260822-v2`: 3,756,551 inputs became 1,205,115 sampled points in 38.673 CPU seconds, but its dominant plane was too steep and `ground_plane_available` is false. The local-low-point fallback produced 181,436 ground, 663,962 vegetation, 988 structure, 641 pole/tower candidates, zero power-line candidates and 358,088 unknown. This validates data-flow only, not semantic accuracy for the side-view reconstruction.
 
 ## Semantic Mapping Snapshot
 
@@ -163,14 +185,18 @@
 ## Change Detection Snapshot
 
 - Script: `capabilities/00-change-detection/run_change_detection.py`.
-- Boundary: A. The core inference and vector conversion call `geoai.ChangeStarDetection` and `geoai.masks_to_vector`; OpenCV ORB homography is only input registration for ordinary JPGs.
+- Benchmark evaluator: `capabilities/00-change-detection/evaluate_change_detection.py`; manifest template: `benchmark_manifest.example.csv`. It requires before/after/truth GeoTIFFs on one exact CRS/grid and truth encoding `0=unchanged`, `1=changed`, `255=ignore`. Existing formal outputs can be scored without rerunning or changing inference. Synthetic unit fixtures verify exact metrics, explicit pixel-coordinate opt-in and rejection of a shifted truth grid; they do not establish real accuracy.
+- Boundary: A. Core inference calls `geoai.ChangeStarDetection`; OpenCV ORB homography is input registration for ordinary JPGs, and Rasterio exports one vector feature per region retained in the cleaned mask.
 - Inputs: user-provided `baseData/07-16.jpg` and `baseData/07-19.jpg`, both 3664 x 2748 RGB with no usable CRS. Staged copies are under `shared/data/raw/00-change-detection/validation-20260817/`.
 - Outputs: `shared/outputs/00-change-detection/validation-real-20260817-v4/`, `validation-self-20260817-v3/` and `resolution-2048-urban-validation/` contain registered GeoTIFFs, probability/mask rasters, overlays, GeoJSON, feature stats and `run_metadata.json`.
+- Visual evidence layer: formal runs now keep ChangeStar structural-change pixels in red and add a separate conservative RGB-difference candidate mask in cyan. This supplementary layer can reveal moved vehicles or people missed by the building-change weights, but may include shadows, edges and registration residuals; it is excluded from ChangeStar pixel/vector metrics and cannot be interpreted as a semantic alert. The console exposes the registered second-period processing image and uses it as the overlay/vector base, so displayed coordinates match model outputs.
+- 2026-08-20 road-pair validation: `shared/outputs/00-change-detection/runs/change-20260820-evidence-optimized/` completed at 2048 x 1152 in 68.643 seconds. ChangeStar retained 135,522 pixels and 34 polygons; the separate visual-difference layer retained 106,509 candidate pixels in 136 components. Visual inspection confirmed cyan coverage around the moved vehicle and some person-sized differences, with remaining edge/shadow/registration false positives.
+- Parameter-scan promotion now preserves the source inference's generic-difference raster, combined overlay and registered-image previews instead of reverting to the red-only scan overlay. Formal case `change-20260820-032014-615889` verifies cyan coverage at the disappeared vehicle and person locations for the `V_1` to `V_2` road pair.
 - Measured result: real pair at 1024 px / tile 512 / overlap 64 took 41.556 seconds on CPU, found 755 inliers from 769 ORB matches, 98.52% valid area, 674 changed pixels (0.0857%) and one polygon. Self-comparison at 512 px took 25.902 seconds and found zero changed pixels and zero polygons. On the urban parking sample, increasing from 1024 to 2048 produced 16 polygons instead of one and covered more vehicle/parking changes, but took about 84 seconds CPU and still had misses/false positives.
 - GeoTIFF grid-alignment validation: uploaded `1.tif` (6217 x 5525) and `2.tif` (6218 x 5497) share `EPSG:32650` but have slightly different origins and pixel sizes. The second image was reprojected bilinearly to the first grid in the processing copy, then completed at 1024 px in 80.882 seconds with georeferenced outputs. SHA256 checks confirmed the source staging files and their raw run copies are byte-identical.
 - Honest limitation: ChangeStar weights are trained for Changen2/S1 building change and the close-up rock/concrete scene is out of distribution. No manual truth exists, so no precision, recall, IoU, change type, or engineering alert is claimed. Pixel GeoJSON has no CRS; the downloaded model is CC BY-NC-SA 4.0 and not cleared for commercial use.
 - Console: `GET/POST /api/change-detection/runs`, `GET/POST /api/change-detection/scans`, `GET /api/change-detection/scan-jobs/<job-id>`, `POST /api/change-detection/scans/<scan-id>/promote` and binary upload endpoints on local `6xxx` ports. Real uploads at `6176` verified thresholds and selectable processing resolution; scan submissions are validated for at most 24 combinations and execute asynchronously through fixed capability scripts. Scan inference outputs remain in the scan workspace until a user promotes one selected combination; promotion creates a new formal run directory with raw-input references, makes the case discoverable, and uses rule rectangles as the primary case vector while retaining original polygons and masks. GeoTIFF candidates include source-CRS and EPSG:4326 vectors; ordinary images remain in px coordinates. Minimum connected area is a processed-pixel filter for both modes; an empty console scan selection defaults to `256 px`. Vector export now uses `rasterio.features.shapes` after the pixel-area filter, rather than GeoAI's morphological/NMS vector helper: a GeoTIFF validation changed from 42 exported vectors to all 308 retained mask regions, with 308 rectangles and 308 WGS84 features; every original polygon was covered by its matching rectangle. A non-overwriting corrected scan `scan-20260819-063712-744f45-vector-fixed` is available for review. Binary upload filenames are percent-encoded in the browser and decoded/sanitized by the server, so Chinese filenames no longer fail Request header validation. Auto/image and GeoTIFF mode handling is covered by unit tests. Separate raw before/after directories and a separate processed directory are used; unsafe basename sanitization plus invalid/oversized request rejection were verified.
-- Next decision: obtain georeferenced same-GSD orthophotos and manually labelled change masks for the intended business class, then compare registration error, false positives, misses and IoU before any batch or product use.
+- Next decision: populate benchmark version `v1` with georeferenced same-GSD orthophotos and manually reviewed change masks for one intended business class. Include at least one no-change pair, one representative change pair and one difficult pseudo-change pair; then use the evaluator to compare registration error, false positives, misses, F1 and IoU before changing the model, selecting a production threshold, batch-running or integrating with a product.
 
 - Script: `capabilities/02-semantic-mapping/run_semantic_segmentation.py`.
 - Boundary: B. `geoai-py 0.42.0` supplies class-wise `masks_to_vector`; deterministic RGB/HSV rules supply the first offline segmentation mask because no general pretrained weight is bundled.
@@ -200,6 +226,16 @@
 - 目标检测、分割、分类、变化检测、水体/树冠/深度/嵌入等是算法产出可被 GeoAI 工作流承接的方向;电子围栏、告警、无人机控制、工单和调度仍属于项目产品能力。
 
 ## Spatial Measurement Snapshot
+
+## Spatial Rule And Risk Scoring Snapshot
+
+- Script: `capabilities/07-risk-rule-engine/run_risk_rule_engine.py`; reproducible validation inputs: `generate_validation_inputs.py` in the same directory.
+- Boundary: C. `geoai-py 0.42.0` has no risk/rule/score API; the Demo does not invoke it. GeoAI detection, segmentation or measurement outputs may be supplied upstream as CRS-aware observation or zone GeoJSON, but the rule and score logic is GeoPandas/Shapely/Rasterio product-service code.
+- Contract: a projected-CRS observation GeoJSON with unique `object_id` and numeric `confidence`, a zone GeoJSON with `zone_type`, and a configured JSON rule set. Outputs are `risk_score.tif`, `risk_preview.png`, `risk_scores.geojson`, `risk_scores.csv`, `risk_summary.json`, and `run_metadata.json`; every object records matched rules, score contributions, score capping, risk level and suggestions. The pipeline rejects missing CRS, geographic observation CRS, invalid confidence, invalid rule schemas and non-empty output reuse.
+- Validation: synthetic EPSG:3857 normal and difficult cases completed on CPU. The normal case took 2.540 seconds and returned scores 0, 25 and 90. The difficult case took 0.371 seconds: a boundary object hit both zone rules for 90, a low-confidence restricted object was capped at 100, and a low-confidence outside object scored 20. The risk PNG previews and GeoTIFF/GeoJSON artifacts were inspected; this is a logic/output verification, not a real-risk accuracy result.
+- Environment: `.venvs/07-risk-rule-engine` is a dedicated Python 3.12 interpreter that reuses the verified compatible geospatial package directory from `04-spatial-measurement` through a local `.pth` file after package download exceeded the setup timeout. GeoPandas 1.1.4, Rasterio 1.5.1 and Shapely 2.1.2 load successfully; `pip check` passes.
+- Console: `GET/POST /api/risk-rule-engine/runs` uses fixed `07` interpreter/script and only accepts the three named, allowlisted files. A real local POST at port 6187 created `risk-20260820-083944-224155`; an unsafe-path submission created `risk-20260820-084008-10c884` with sanitized names and separate raw/processed/output roots. The Vue page follows new run -> bounded searchable case library -> result workspace, and shows the raster/GeoJSON result side-by-side, summary, object details and downloads. Browser screenshot validation was unavailable because no browser instance was exposed; the Vue production build and HTTP/API checks passed.
+- Limitations: scores are configurable rules for human prioritization, not verified events, enforcement conclusions or automatic actions. The synthetic data does not establish precision, recall, location tolerance, score calibration, zone authority or commercial readiness. Obtain authoritative dated zones, real georeferenced upstream outputs, reviewed labels and acceptance thresholds before operational or batch use.
 
 - Script: `capabilities/04-spatial-measurement/run_spatial_measurement.py`.
 - Boundary: B. `geoai-py 0.42.0` supplies `masks_to_vector`; Rasterio, GeoPandas and Shapely supply CRS handling and count/area/perimeter/centroid/bounds measurements. It consumes labelled rasters and does not infer classes from RGB imagery.
@@ -288,6 +324,67 @@
 `$geoai-capability-builder` owns the capability workflow. `$geoai-workbench-console` owns the independent local console workflow, including run discovery, safe uploads, output presentation, UI layout, and visual verification.
 
 After completing verified work, update this snapshot and the capability README. Change `AGENTS.md` only when a long-lived rule changes.
+
+## 3D Point-cloud Annotation And Training
+
+- Boundary: B. `geoai-py` remains limited to elevated-raster vectorization;
+  Open3D, NumPy, scikit-learn and PyTorch implement annotation/training.
+- Environment: `.venvs/05-3d-pointcloud` has Python 3.12, `torch 2.13.0+cpu`
+  and `scikit-learn 1.9.0`; CUDA is unavailable locally. The shared-MLP model
+  supports `auto`, `cpu`, and `cuda`, making a future GPU server an acceleration
+  deployment rather than a different workflow.
+- Truth contract: generated annotation PLYs preserve observed RGB/XYZ. Rule
+  colours are never labels. Separate revisions record source SHA-256, artifact,
+  indices, class codes and class counts below `shared/outputs/05-3d-pointcloud/annotations/`.
+  A mistaken revision can be deleted after confirmation; the deletion endpoint
+  is constrained to that revision directory and never removes source clouds,
+  semantic outputs or training outputs.
+- Training gate/output: at least two classes and 500 user-confirmed points per
+  class; XY-block train/validation/test partition; `model.pt`, `metrics.json`,
+  predicted PLY, per-class precision/recall/F1 and confusion matrix. Metrics
+  apply only to labelled blocks.
+- Verification: CPU run `validation-las-part01-semantic-20260822-v11-original-rgb` processed
+  unchanged `baseData/las/part_01.las` in 73.586 seconds and generated a 400,000
+  point RGB annotation source. Its 3,183 pole/tower and 8,162 power-line rule
+  candidates are not truth.
+- Console: restarted at `http://127.0.0.1:6188`; root HTTP and annotation-source
+  API checks passed. The annotation viewer now uses sRGB-correct anti-aliased
+  circular points and separates browse left-drag rotation from brush/rectangle
+  left-drag labels; right-drag rotates, middle-drag pans and the wheel zooms.
+  Middle-button auto-scroll is suppressed and a rotate-centre tool places the
+  orbit target at a clicked visible point without writing a label.
+  A model-orientation popover provides independent local X/Y/Z display rotation
+  from 0-360 degrees about the model centre; it leaves source coordinates and
+  stored annotation indices unchanged while selection follows the display transform.
+  Console/unit/type checks passed. Visual annotation review awaits a user
+  screenshot under the no-browser-inspection rule.
+- Applied-model workflow: the supervised area now discovers only complete local
+  `training-runs/*/{model.pt,metrics.json}` records. A user selects a model,
+  uploads one RGB PLY/PCD/LAS/LAZ input, and the local-only server copies bytes
+  to a new raw/processed inference run before launching the fixed CPU script.
+  It exposes a polling job, in-page PLY prediction preview, classified LAS,
+  class-count CSV, summary JSON, metadata, and weight download. Browser paths,
+  arbitrary model paths, and XYZ-only inputs are rejected. Outputs are review
+  candidates, not assets or inspection conclusions.
+- Verified 2026-08-24: the real model
+  `semantic-model-20260824-023343-571983` applied to the 400,000-point RGB
+  annotation PLY through the CLI in 1.608 seconds and through the HTTP upload /
+  background-job route. The latter created
+  `semantic-inference-20260824-024901-b60fda` and served a 10,800,209-byte PLY
+  preview plus a 13,600,227-byte classified LAS. The model's labelled spatial
+  test F1 is vegetation 0.979, power line 0.815, pole/tower 0.348; pole/tower
+  predictions therefore have high false-positive risk. No browser visual check
+  was performed by the agent under the project rule.
+- 3D workbench information architecture: the former mixed page is now four
+  focused workspaces: photo 3D reconstruction; point-cloud geometry processing;
+  point-cloud semantic classification; and annotation, training, and model
+  application. Each has a separate filtered case library and result workspace.
+  A point-cloud run can be opened in both geometry and semantic workspaces when
+  it owns both output families, but no longer renders DSM, semantic review,
+  annotation, training, and model application as one continuous page. Photo
+  cases are isolated from LAS/PLY cases. This is a console UX change only; it
+  does not alter any reconstruction, geometry, semantic-rule, annotation, or
+  model-inference computation.
 ## 2026-08-14 工作台更新
 
 - 本地控制台已从只读结果页升级为独立实验工作台:能力页包含新建运行入口、服务端自动发现的案例库和结果工作区,仍不连接任何无人机产品。
diff --git a/apps/workbench-console/README.md b/apps/workbench-console/README.md
index d3e679c..97bd93d 100644
--- a/apps/workbench-console/README.md
+++ b/apps/workbench-console/README.md
@@ -74,6 +74,12 @@
 保留目录和边界状态,待其首个 Demo 产出可检查工件后再接入。
 ## 本地运行工作台
 
+### 空间规则与风险评分
+
+`07-risk-rule-engine` 已接入独立本地实验控制台。新建运行要求一份带投影 CRS 的观测对象 GeoJSON(唯一 `object_id` 和数值 `confidence`)、一份风险分区 GeoJSON(`zone_type`)和一份规则 JSON。服务端只调用固定的 `.venvs/07-risk-rule-engine/Scripts/python.exe` 及固定能力脚本,并为每次提交创建新的 raw、processed 和 output 目录。
+
+工作区展示风险 GeoTIFF 预览和评分 GeoJSON,并显示逐对象得分、等级、命中规则与建议;可下载 GeoTIFF、GeoJSON、CSV、汇总 JSON 和运行元数据。风险等级是规则计算出的人工复核优先级,不是事件事实、自动处置或产品能力。接口为 `GET/POST /api/risk-rule-engine/runs`,仅在本机 `6xxx` 端口提供服务。
+
 控制台现支持可操作的本地实验运行,并保持与无人机产品完全独立。能力页面提供新建运行入口和可搜索扩展的案例库,案例由本地服务从运行目录自动发现,不再依赖前端硬编码 Tabs。
 
 - 轨迹分析上传 `XLSX`、`KMZ`、禁飞区 `GeoJSON` 和可选适飞区 `Gzip`,原始、处理和输出分别归档在 `shared/data/raw`、`shared/data/processed`、`shared/outputs`。
@@ -84,3 +90,102 @@
 - 异常检测分别上传 1~6 张正常参考影像和 1~6 张待检测影像,支持 `JPG/JPEG/PNG/TIF/TIFF`。3 张以上固定机位且全部尺寸一致时自动启用同位置规则和通用局部外观/结构变化通道,否则回退为全局规则;局部通道不使用颜色或物品类别规则。结果页显示实际选择的模式。大文件使用二进制流,后台任务完成后进入案例库;结果展示参考输入、待检测原图、规则/Isolation Forest 对比、热力图、候选区字段与下载工件。案例列表右侧的“复用输入”会把该案例的原始参考图、待检测图和四项可编辑参数回填到新运行表单,不自动启动检测,也不修改历史案例。
 - 本地 API 还包括 `GET/POST /api/change-detection/runs` 和 `PUT /api/change-detection/uploads/<upload-id>`;变化检测前端使用二进制流上传,服务端按原字节暂存并复制到独立 raw、processed 和 output 目录,不修改原始 TIFF,也不覆盖既有运行。
 - 本地 API 为 `GET/POST /api/trajectory/runs`、`GET/POST /api/object-detection/runs`、`GET/POST /api/semantic-mapping/runs`、`GET/POST /api/spatial-measurement/runs`、`GET/POST /api/anomaly-detection/runs` 和只读的 `GET /api/semantic-mapping/tasks`;异常检测另有二进制上传和后台任务状态端点。服务仅监听回环地址,端口必须是 `6xxx`,只调用固定虚拟环境和能力脚本,不接受任意命令或任意路径。
+
+## 3D Point-cloud Workspace
+
+The `05-3d-pointcloud` console adapter provides `GET/POST /api/3d-pointcloud/runs`.
+It accepts two PLY/PCD/XYZ/LAS/LAZ files at most, copies source bytes into a new
+raw run directory, records SHA-256, and invokes only the fixed CPU interpreter
+and point-cloud script. The workspace shows DSM/elevated-surface raster and
+GeoAI footprint vector side by side, plus counts, mesh summary, artifacts, and
+run metadata. It remains independent of all drone products and is local-only on
+`6xxx` loopback ports.
+
+The same case library also discovers completed photo-SfM and CPU-MVS result
+directories. Sparse SfM cases use an interactive `THREE.Points` vertex viewer
+with registration statistics, local poses and input manifest; vertices are never
+triangulated by the viewer. CPU-MVS cases offer reliable-texture, point-colour,
+and complete-geometry views. The reliable-texture atlas pixels are vertically
+corrected for browser sampling and faces that reach black atlas background or
+the OpenMVS orange no-coverage colour are omitted; visible holes therefore
+indicate rejected texture coverage rather than a placeholder colour. Complete
+geometry retains all reconstructed mesh faces in a neutral material. Point-colour
+uses nearest fused RGB point values on the complete mesh; it does not claim
+per-face photo-texture evidence. All views support orbit, zoom, an explicit
+local-`+Z` top-down view, reset and wireframe controls, plus dense PLY, mesh
+PLY and texture downloads. Both use local SfM coordinates and are not
+rendered as DSM or GeoJSON without valid georeferencing.
+
+The top-level `05-3d-pointcloud` workflow switch keeps incompatible inputs
+separate. `照片三维重建` accepts 3-30 JPG/JPEG files from one coherent camera
+sequence through bounded binary uploads, preserves the raw bytes, then runs a
+background CPU SfM/MVS job and polls `queued`, `sparse_sfm`, `dense_mvs`,
+`complete`, or `failed` status. Its GPS/RTK-prior option uses the verified
+spatial-neighbour configuration only when every photo has suitable position
+metadata; turn it off for ordinary cameras. `既有点云理解` accepts up to two
+PLY/PCD/XYZ/LAS/LAZ inputs and produces the DSM/elevated-surface raster and
+GeoAI footprint vector workflow. Creating a run or discovering a new case
+does not require rebuilding the frontend; `npm run build` is only required
+after changing the Vue/TypeScript console source.
+
+The same workspace also exposes point-cloud semantic classification:
+PLY/PCD/XYZ/LAS/LAZ files are sent through bounded binary streaming, retained
+byte-for-byte in a new run, and classified on CPU into ground, vegetation,
+building/structure, pole/tower candidates, power-line candidates and unknown.
+Its case view renders a capped colour point preview and provides semantic LAS,
+GeoTIFF, GeoJSON and CSV downloads. The labels are review candidates from
+RGB/local-geometry rules, not a trained semantic-model claim. Dense MVS cases
+also expose an action that classifies only their fixed local `dense.ply`; the
+server never accepts a browser-supplied path.
+
+Semantic cases with a generated RGB annotation source provide a separate manual
+annotation and supervised-training area. Brush and rectangle selections become
+separate annotation revisions rather than altering source clouds or rule results.
+The annotation point viewer uses sRGB-correct anti-aliased circular points.
+Browse mode keeps left-drag rotation; brush/rectangle mode reserves left-drag
+for labels, with right-drag rotation, middle-drag panning and wheel zoom.
+Middle-button auto-scroll is suppressed inside the point canvas. Its rotate-
+centre tool lets a user click a visible point to move the orbit target without
+changing annotations.
+The model-orientation control provides independent 0-360 degree local X/Y/Z
+display rotations about the model centre. It never changes the original point
+cloud or stored annotation indices; picking and rectangle selection follow the
+currently displayed orientation.
+An incorrect revision can be removed after confirmation, but the endpoint only
+removes that revision directory and cannot remove LAS/PLY sources, semantic
+results, or training outputs. A local CPU training job requires two classes with
+at least 500 confirmed points each, then writes a portable model, metrics, and
+predicted PLY asynchronously. Sources, point indices, class codes, Python
+interpreter and command are all server-fixed; the same model pipeline can later
+use CUDA without data or code changes.
+
+The supervised area also exposes **Apply trained model**. It discovers only
+complete local training directories containing both `model.pt` and `metrics.json`.
+Select a discovered model, upload one new RGB PLY/PCD/LAS/LAZ point cloud, and
+the server copies its bytes into new `raw` and `processed` inference directories
+before starting a background CPU job. The API accepts neither a browser path nor
+an arbitrary model path. Completion shows the classified PLY preview directly in
+the workspace and provides downloads for classified LAS, class-count CSV,
+prediction JSON, metadata, and model weight. XYZ-only input fails explicitly:
+the supervised model requires observed RGB features. Output remains a review
+candidate, especially for the current pole/tower class, not asset inventory or
+an inspection conclusion.
+
+The 3D point-cloud capability is presented as four focused workspaces rather
+than one long mixed result page:
+
+1. **Photo 3D reconstruction**: JPG/JPEG input, sparse/dense reconstruction,
+   mesh, texture, and photo-reconstruction cases only.
+2. **Point-cloud geometry processing**: PLY/PCD/XYZ/LAS/LAZ input, DSM,
+   elevated-surface raster/vector, approximate mesh, and geometry downloads.
+3. **Point-cloud semantic classification**: classification cases, coloured
+   point preview, semantic LAS/CSV/GeoJSON and review-only rule outputs.
+4. **Annotation, training, and model application**: RGB annotation source,
+   annotation revisions, CPU training, training metrics, and applying a trained
+   model to a new RGB cloud.
+
+Each workspace filters the case library to its own purpose. A point-cloud run
+may appear in both geometry and semantic workspaces because it has both output
+families, but the pages never render the unrelated result bands together. The
+model workspace deliberately uses its generated annotation sources and trained
+models rather than a general case library.
diff --git a/apps/workbench-console/package-lock.json b/apps/workbench-console/package-lock.json
index 1f4a2b3..e329845 100644
--- a/apps/workbench-console/package-lock.json
+++ b/apps/workbench-console/package-lock.json
@@ -12,10 +12,12 @@
         "ant-design-vue": "^4.2.6",
         "cesium": "1.126.0",
         "pinia": "^3.0.1",
+        "three": "^0.180.0",
         "vue": "^3.5.13",
         "vue-router": "^4.5.0"
       },
       "devDependencies": {
+        "@types/three": "^0.185.4",
         "@vitejs/plugin-vue": "^5.2.1",
         "typescript": "^5.7.2",
         "vite": "^6.0.5",
@@ -146,6 +148,12 @@
       "engines": {
         "node": ">=10"
       }
+    },
+    "node_modules/@dimforge/rapier3d-compat": {
+      "version": "0.12.0",
+      "resolved": "https://registry.npmmirror.com/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
+      "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
+      "dev": true
     },
     "node_modules/@emotion/hash": {
       "version": "0.9.2",
@@ -1030,11 +1038,49 @@
         "undici-types": "~8.3.0"
       }
     },
+    "node_modules/@types/stats.js": {
+      "version": "0.17.4",
+      "resolved": "https://registry.npmmirror.com/@types/stats.js/-/stats.js-0.17.4.tgz",
+      "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
+      "dev": true
+    },
+    "node_modules/@types/three": {
+      "version": "0.185.4",
+      "resolved": "https://registry.npmmirror.com/@types/three/-/three-0.185.4.tgz",
+      "integrity": "sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA==",
+      "dev": true,
+      "dependencies": {
+        "@dimforge/rapier3d-compat": "~0.12.0",
+        "@tweenjs/tween.js": "~23.1.3",
+        "@types/stats.js": "*",
+        "@types/webxr": ">=0.5.17",
+        "fflate": "~0.8.2",
+        "meshoptimizer": "~1.1.1"
+      }
+    },
+    "node_modules/@types/three/node_modules/@tweenjs/tween.js": {
+      "version": "23.1.3",
+      "resolved": "https://registry.npmmirror.com/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
+      "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
+      "dev": true
+    },
+    "node_modules/@types/three/node_modules/meshoptimizer": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
+      "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
+      "dev": true
+    },
     "node_modules/@types/trusted-types": {
       "version": "2.0.7",
       "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
       "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
       "optional": true
+    },
+    "node_modules/@types/webxr": {
+      "version": "0.5.24",
+      "resolved": "https://registry.npmmirror.com/@types/webxr/-/webxr-0.5.24.tgz",
+      "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
+      "dev": true
     },
     "node_modules/@vitejs/plugin-vue": {
       "version": "5.2.4",
@@ -1600,6 +1646,12 @@
           "optional": true
         }
       }
+    },
+    "node_modules/fflate": {
+      "version": "0.8.3",
+      "resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.3.tgz",
+      "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
+      "dev": true
     },
     "node_modules/fill-range": {
       "version": "7.1.1",
@@ -2233,6 +2285,11 @@
         "node": ">=16"
       }
     },
+    "node_modules/three": {
+      "version": "0.180.0",
+      "resolved": "https://registry.npmmirror.com/three/-/three-0.180.0.tgz",
+      "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w=="
+    },
     "node_modules/throttle-debounce": {
       "version": "5.0.2",
       "resolved": "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
diff --git a/apps/workbench-console/package.json b/apps/workbench-console/package.json
index efdebd7..0ee9dae 100644
--- a/apps/workbench-console/package.json
+++ b/apps/workbench-console/package.json
@@ -13,10 +13,12 @@
     "ant-design-vue": "^4.2.6",
     "cesium": "1.126.0",
     "pinia": "^3.0.1",
+    "three": "^0.180.0",
     "vue": "^3.5.13",
     "vue-router": "^4.5.0"
   },
   "devDependencies": {
+    "@types/three": "^0.185.4",
     "@vitejs/plugin-vue": "^5.2.1",
     "typescript": "^5.7.2",
     "vite": "^6.0.5",
diff --git a/apps/workbench-console/src/api/artifacts.ts b/apps/workbench-console/src/api/artifacts.ts
index b845a79..f5a23b7 100644
--- a/apps/workbench-console/src/api/artifacts.ts
+++ b/apps/workbench-console/src/api/artifacts.ts
@@ -1,4 +1,5 @@
 export interface Detection { class_id: number; class_name: string; confidence: number; bbox_xyxy: number[]; }
+export interface GeoFeature { type?: string; properties?: Record<string, unknown>; geometry?: { type?: string; coordinates?: unknown }; }
 export interface DetectionImage { file: string; annotated_file: string; width: number; height: number; detections: Detection[]; }
 export interface DetectionRun { detection_count: number; processed_images: number; elapsed_seconds: number; device: string; model: string; confidence: number; input_dir: string; notes: string[]; created_at?: string; }
 export interface DetectionCase { id: string; label: string; note: string; artifactRoot: string; inputRoot: string; createdAt: string; run: DetectionRun; images: DetectionImage[]; }
@@ -9,13 +10,22 @@
 export interface MeasurementImage { file: string; width: number; height: number; raster_file: string; preview_file: string; vector_file: string; csv_file: string; object_count: number; class_counts: Record<string, number>; total_area: number; total_perimeter: number; area_unit: string; length_unit: string; measurement_basis: string; crs: string | null; georeferenced: boolean; vectorizer: string; elapsed_seconds: number; }
 export interface MeasurementRun { capability: string; classification: string; created_at?: string; geoai_version: string; method: string; model: string; device: string; input_count: number; processed_images: number; elapsed_seconds: number; images: MeasurementImage[]; limitations: string[]; input_dir?: string; raw_input_dir?: string; }
 export interface MeasurementCase { id: string; label: string; note: string; artifactRoot: string; createdAt: string; run: MeasurementRun; }
+export interface PointCloudImage { file: string; original_points: number; downsampled_points: number; ground_inliers: number; elevated_points: number; elevated_point_ratio: number; elevated_footprint_count: number; mesh_triangles: number; mesh_skipped_for_large_cloud?: boolean; raster_width: number; raster_height: number; coordinate_basis: string; crs: string | null; classified_point_cloud: string; semantic_preview_point_cloud?: string; semantic_annotation_source_point_cloud?: string; semantic_preview_points?: number; semantic_classified_las?: string; semantic_raster_file?: string; semantic_preview_file?: string; semantic_vector_file?: string; semantic_summary_file?: string; semantic_footprint_count?: number; semantic_class_counts?: Record<string, number>; semantic_source_has_rgb?: boolean; semantic_method?: string; mesh_file: string | null; dsm_file: string; height_file: string; label_raster_file: string; preview_file: string; vector_file: string; summary_file: string; vectorizer: string; elapsed_seconds: number; }
+export interface PhotoReconstruction { input_images: number; registered_images: number; unregistered_images: string[]; sparse_points: number; mean_reprojection_error_pixels: number; camera_count: number; coordinate_basis: string; point_cloud_file: string; preview_file: string; camera_pose_file: string; input_manifest_file: string; colmap_model_directory: string; elapsed_seconds: number; initial_min_triangulation_angle_degrees: number; }
+export interface DensePhotoReconstruction { input_images: number; registered_images: number; sparse_points: number; dense_points: number; mesh_vertices: number; mesh_faces: number; preview_mesh_faces?: number; preview_mesh_face_ratio?: number; geometry_preview_faces?: number; point_color_preview_faces?: number; coordinate_basis: string; dense_point_cloud_file: string; mesh_file: string; textured_model_file: string; geometry_preview_model_file?: string; point_color_preview_model_file?: string; texture_preview_filter_report?: string; texture_file: string; elapsed_seconds: number | null; }
+export interface PointCloudRun { capability: string; classification: string; created_at?: string; versions: Record<string, string>; method: string; model: string; device: string; thresholds: Record<string, number | string>; input_count?: number; processed_point_clouds?: number; elapsed_seconds: number; point_clouds?: PointCloudImage[]; photo_reconstruction?: PhotoReconstruction; dense_photo_reconstruction?: DensePhotoReconstruction; limitations: string[]; raw_input_dir?: string; }
+export interface PointCloudCase { id: string; label: string; note: string; artifactRoot: string; createdAt: string; run: PointCloudRun; features: GeoFeature[]; }
+export interface RiskScoreFeature { type: "Feature"; properties: { object_id: string; confidence: number; raw_score: number; risk_score: number; risk_level: "low" | "medium" | "high" | "critical"; score_capped: boolean; hit_rule_ids: string[]; suggestions: string[]; rule_contributions: Array<{ rule_id: string; score: number; suggestion: string }>; }; geometry: { type: string; coordinates: number[] }; }
+export interface RiskSummary { object_count: number; risk_level_counts: Record<"low" | "medium" | "high" | "critical", number>; maximum_score: number; mean_score: number; hit_rule_counts: Record<string, number>; }
+export interface RiskRuleRun { schema_version: number; capability: string; classification: string; created_at?: string; method: string; model: string; device: string; geoai_role: string; crs: string; zones_reprojected_to_observation_crs: boolean; parameters: { score_cap: number; levels: Record<string, number>; raster_resolution: number; rule_count: number; }; raster: { width: number; height: number; bounds: number[]; coordinate_basis: string; }; summary: RiskSummary; artifacts: { risk_raster: string; risk_preview: string; risk_vector: string; risk_scores_csv: string; summary: string; }; elapsed_seconds: number; limitations: string[]; }
+export interface RiskRuleCase { id: string; label: string; note: string; artifactRoot: string; createdAt: string; run: RiskRuleRun; features: RiskScoreFeature[]; }
 export interface AnomalyCandidate { type: "Feature"; properties: { feature_id?: number; mask_code?: number; method?: string; rule_flag?: boolean; isolation_flag?: boolean; agreement?: boolean; max_rule_score?: number; max_isolation_score?: number; reason_feature?: string; area_pixels?: number }; geometry: { type: string; coordinates: unknown }; }
 export interface AnomalyImage { file: string; width: number; height: number; tile_count: number; rule_heatmap_file: string; isolation_heatmap_file: string; overlay_file: string; mask_file: string; vector_file: string; tiles_file: string; candidate_count: number; rule_anomaly_pixels: number; local_change_pixels?: number; isolation_anomaly_pixels: number; agreement_pixels: number; rule_anomaly_coverage: number; local_change_coverage?: number; isolation_anomaly_coverage: number; agreement_coverage: number; georeferenced: boolean; crs: string | null; coordinate_basis: string; vectorizer: string; elapsed_seconds: number; }
 export interface AnomalyRun { capability: string; classification: string; created_at?: string; versions: Record<string, string>; method: string; model: string; device: string; reference_count: number; reference_tile_count: number; input_count: number; reference_images: Array<{ file: string; tile_count: number }>; feature_names: string[]; parameters: { tile_size: number; stride: number; threshold_quantile: number; random_state: number; spatial_mode_requested?: "auto" | "global" | "aligned"; rule_mode_selected?: "global" | "aligned" }; thresholds: { rule_score: number; isolation_score: number }; images: AnomalyImage[]; elapsed_seconds: number; limitations: string[]; }
 export interface AnomalyCase { id: string; label: string; note: string; artifactRoot: string; inputRoot: string; referenceRoot: string; createdAt: string; run: AnomalyRun; candidates: Record<string, AnomalyCandidate[]>; }
 export interface ChangeFeature { type: "Feature"; properties: { feature_id?: number; area_pixels?: number; mean_probability?: number; max_probability?: number; bounds_pixel?: number[]; confidence?: number; class?: number }; geometry: { type: string; coordinates: unknown }; }
-export interface ChangeRun { schema_version: number; capability: string; classification: string; geoai_version: string; method: string; model: string; device: string; processing_mode?: "image" | "geotiff"; requested_processing_mode?: "auto" | "image" | "geotiff"; thresholds: Record<string, number>; tile_size: number; overlap: number; max_dimension?: number; effective_max_dimension?: number; input_files: string[]; input_shape: number[]; processed_shape: number[]; registration: { method: string; matches: number; inliers: number; inlier_ratio: number; valid_ratio: number }; valid_pixel_ratio: number; raw_changed_pixels: number; changed_pixels: number; changed_pixel_ratio: number; vector_feature_count: number; rectangle_feature_count?: number; georeferenced: boolean; coordinate_basis: string; crs: string | null; elapsed_seconds: number; limitations: string[]; artifacts: { probability_raster: string; raw_mask_raster: string; mask_raster: string; overlay: string; vector: string; rectangle_vector?: string; rectangle_vector_wgs84?: string | null; features: string }; created_at?: string; }
-export interface ChangeCase { id: string; label: string; note: string; artifactRoot: string; beforeImage: string; afterImage: string; createdAt: string; run: ChangeRun; features: ChangeFeature[]; }
+export interface ChangeRun { schema_version: number; capability: string; classification: string; geoai_version: string; method: string; model: string; device: string; processing_mode?: "image" | "geotiff"; requested_processing_mode?: "auto" | "image" | "geotiff"; thresholds: Record<string, number>; tile_size: number; overlap: number; max_dimension?: number; effective_max_dimension?: number; input_files: string[]; input_shape: number[]; processed_shape: number[]; registration: { method: string; matches: number; inliers: number; inlier_ratio: number; valid_ratio: number }; valid_pixel_ratio: number; raw_changed_pixels: number; changed_pixels: number; changed_pixel_ratio: number; vector_feature_count: number; rectangle_feature_count?: number; generic_difference?: { candidate_pixels: number; candidate_pixel_ratio: number; components_after_filter: number }; georeferenced: boolean; coordinate_basis: string; crs: string | null; elapsed_seconds: number; limitations: string[]; artifacts: { probability_raster: string; raw_mask_raster: string; mask_raster: string; generic_difference_mask?: string; overlay: string; model_overlay?: string; before_processed_preview?: string; after_registered_preview?: string; vector: string; rectangle_vector?: string; rectangle_vector_wgs84?: string | null; features: string }; created_at?: string; }
+export interface ChangeCase { id: string; label: string; note: string; artifactRoot: string; beforeImage: string; afterImage: string; rawAfterImage?: string; registeredBeforeImage?: string; registeredAfterImage?: string; createdAt: string; run: ChangeRun; features: ChangeFeature[]; }
 export interface ChangeScanResult { id: string; label: string; threshold: number; minimumAreaPixels: number; cleanedComponents: number; changedPixels: number; changedPixelRatio: number; vectorFeatureCount: number; fullVectorFeatureCount?: number | null; rectangleFeatureCount?: number | null; overlay: string; mask: string; regions: string; vector?: string | null; rectangleVector?: string | null; rectangleVectorWgs84?: string | null; }
 export interface ChangeParameterScan { id: string; label: string; note: string; artifactRoot: string; sourceRun?: string; userSubmitted?: boolean; contactSheet?: string | null; results: ChangeScanResult[]; }
 export interface ChangeScanJob { id: string; status: "queued" | "running" | "completed" | "failed"; phase?: "inference" | "parameter-scan" | "vectorization" | "done" | "error"; scanId?: string; error?: string; thresholds?: number[]; minimumAreas?: number[]; }
@@ -28,12 +38,22 @@
 interface DetectionDefinition extends CaseDefinition { inputRoot: string; }
 interface SemanticDefinition extends CaseDefinition { inputRoot: string; rawInputRoot: string; }
 interface MeasurementDefinition extends CaseDefinition {}
+interface PointCloudDefinition extends CaseDefinition {}
+interface RiskRuleDefinition extends CaseDefinition {}
 interface ChangeDefinition extends CaseDefinition { beforeImage: string; afterImage: string; }
 interface AnomalyDefinition extends CaseDefinition { inputRoot: string; referenceRoot: string; }
 export interface UploadFilePayload { name: string; content: string; }
 export interface ChangeUploadRef { uploadId: string; role: "before" | "after"; name: string; size: number; }
 export interface AnomalyUploadRef { uploadId: string; role: "reference" | "input"; name: string; size: number; sha256: string; }
 export interface AnomalyJob { id: string; runId: string; status: "queued" | "running" | "complete" | "failed"; createdAt: string; finishedAt?: string; error?: string; run?: AnomalyDefinition; }
+export interface PhotoReconstructionUploadRef { uploadId: string; role: "photo"; name: string; size: number; sha256: string; }
+export interface PointCloudUploadRef { uploadId: string; role: "pointcloud"; name: string; size: number; sha256: string; }
+export interface PhotoReconstructionJob { id: string; runId: string; inputImages: number; usePositionPriors: boolean; status: "queued" | "running" | "complete" | "failed"; stage: "queued" | "sparse_sfm" | "dense_mvs" | "complete" | "failed"; createdAt: string; startedAt?: string; finishedAt?: string; error?: string; run?: PointCloudDefinition; }
+export interface PointCloudAnnotationSource { id: string; runId: string; label: string; artifactRoot: string; file: string; url: string; sha256: string; pointCount: number; sourceKind: string; }
+export interface PointCloudAnnotation { id: string; sourceId: string; createdAt: string; labelCount: number; classCounts: Record<string, number>; path: string; }
+export interface PointCloudTrainingJob { id: string; annotationId: string; status: "queued" | "running" | "complete" | "failed"; stage: string; device: string; createdAt: string; error?: string; artifactRoot?: string; metrics?: string; model?: string; preview?: string; }
+export interface PointCloudSemanticModel { id: string; label: string; artifactRoot: string; model: string; metrics: string; createdAt: string; classes: Record<string, { key: string; label: string; color: number[] }>; testF1: Record<string, number>; }
+export interface PointCloudInferenceJob { id: string; runId: string; modelId: string; inputName: string; status: "queued" | "running" | "complete" | "failed"; stage: string; device: string; createdAt: string; error?: string; artifactRoot?: string; metadata?: string; preview?: string; classifiedLas?: string; classCounts?: string; summary?: string; }
 
 export const artifactUrl = (path: string) => `/${path.replace(/\\/g, "/").split("/").map(encodeURIComponent).join("/")}`;
 
@@ -80,6 +100,29 @@
   return Object.fromEntries(cases.map((item) => [item.id, item]));
 }
 
+export async function loadPointCloudArtifacts(): Promise<Record<string, PointCloudCase>> {
+  const { runs } = await getJson<{ runs: PointCloudDefinition[] }>("api/3d-pointcloud/runs");
+  const cases = await Promise.all(runs.map(async (definition) => {
+    const run = await getJson<PointCloudRun>(`${definition.artifactRoot}/run_metadata.json`);
+    const features = (await Promise.all((run.point_clouds ?? []).map(async (item) => {
+      const payload = await getJson<{ features?: GeoFeature[] }>(`${definition.artifactRoot}/${item.vector_file}`);
+      return payload.features ?? [];
+    }))).flat();
+    return { ...definition, run, features } satisfies PointCloudCase;
+  }));
+  return Object.fromEntries(cases.map((item) => [item.id, item]));
+}
+
+export async function loadRiskRuleArtifacts(): Promise<Record<string, RiskRuleCase>> {
+  const { runs } = await getJson<{ runs: RiskRuleDefinition[] }>("api/risk-rule-engine/runs");
+  const cases = await Promise.all(runs.map(async (definition) => {
+    const run = await getJson<RiskRuleRun>(`${definition.artifactRoot}/run_metadata.json`);
+    const vector = await getJson<{ features?: RiskScoreFeature[] }>(`${definition.artifactRoot}/${run.artifacts.risk_vector}`);
+    return { ...definition, run, features: vector.features ?? [] } satisfies RiskRuleCase;
+  }));
+  return Object.fromEntries(cases.map((item) => [item.id, item]));
+}
+
 export async function loadChangeArtifacts(): Promise<Record<string, ChangeCase>> {
   const { runs } = await getJson<{ runs: ChangeDefinition[] }>("api/change-detection/runs");
   const cases = await Promise.all(runs.map(async (definition) => {
@@ -111,6 +154,30 @@
 export async function createDetectionRun(images: UploadFilePayload[]) { return postRun<{ run: DetectionDefinition }>("api/object-detection/runs", { images }); }
 export async function createSemanticRun(images: UploadFilePayload[], taskId: string) { return postRun<{ run: SemanticDefinition }>("api/semantic-mapping/runs", { images, taskId }); }
 export async function createMeasurementRun(rasters: UploadFilePayload[]) { return postRun<{ run: MeasurementDefinition }>("api/spatial-measurement/runs", { rasters }); }
+export async function createPointCloudRun(pointClouds: Array<UploadFilePayload | PointCloudUploadRef>) { return postRun<{ run: PointCloudDefinition }>("api/3d-pointcloud/runs", { pointClouds }); }
+export async function createDensePointCloudClassification(sourceDenseRunId: string) { return postRun<{ run: PointCloudDefinition }>("api/3d-pointcloud/runs", { sourceDenseRunId }); }
+export async function uploadPointCloudFile(file: File): Promise<PointCloudUploadRef> {
+  const uploadId = globalThis.crypto.randomUUID().replaceAll("-", "");
+  const response = await fetch(`/api/3d-pointcloud/pointcloud-uploads/${uploadId}?role=pointcloud`, { method: "PUT", headers: { "Content-Type": "application/octet-stream", "X-Upload-Name": encodeURIComponent(file.name) }, body: file });
+  return responseJson<PointCloudUploadRef>(response);
+}
+export async function uploadPhotoReconstructionFile(file: File): Promise<PhotoReconstructionUploadRef> {
+  const uploadId = globalThis.crypto.randomUUID().replaceAll("-", "");
+  const response = await fetch(`/api/3d-pointcloud/photo-uploads/${uploadId}?role=photo`, { method: "PUT", headers: { "Content-Type": "application/octet-stream", "X-Upload-Name": encodeURIComponent(file.name) }, body: file });
+  return responseJson<PhotoReconstructionUploadRef>(response);
+}
+export async function createPhotoReconstructionRun(photos: PhotoReconstructionUploadRef[], usePositionPriors: boolean) { return postRun<{ job: PhotoReconstructionJob }>("api/3d-pointcloud/photo-reconstruction-runs", { photos, usePositionPriors }); }
+export async function loadPhotoReconstructionJob(jobId: string) { return (await getJson<{ job: PhotoReconstructionJob }>(`api/3d-pointcloud/photo-reconstruction-jobs/${jobId}`)).job; }
+export async function loadPointCloudAnnotationSources() { return (await getJson<{ sources: PointCloudAnnotationSource[] }>("api/3d-pointcloud/annotation-sources")).sources; }
+export async function loadPointCloudAnnotations() { return (await getJson<{ annotations: PointCloudAnnotation[] }>("api/3d-pointcloud/annotations")).annotations; }
+export async function createPointCloudAnnotation(sourceId: string, labels: Array<[number, number]>) { return postRun<{ annotation: PointCloudAnnotation }>("api/3d-pointcloud/annotations", { sourceId, labels }); }
+export async function deletePointCloudAnnotation(annotationId: string) { return responseJson<{ deletedId: string }>(await fetch(artifactUrl(`api/3d-pointcloud/annotations/${encodeURIComponent(annotationId)}`), { method: "DELETE" })); }
+export async function createPointCloudTrainingRun(annotationId: string, device: "auto" | "cpu" | "cuda" = "cpu") { return postRun<{ job: PointCloudTrainingJob }>("api/3d-pointcloud/training-runs", { annotationId, device }); }
+export async function loadPointCloudTrainingJob(jobId: string) { return (await getJson<{ job: PointCloudTrainingJob }>(`api/3d-pointcloud/training-jobs/${jobId}`)).job; }
+export async function loadPointCloudSemanticModels() { return (await getJson<{ models: PointCloudSemanticModel[] }>("api/3d-pointcloud/semantic-models")).models; }
+export async function createPointCloudModelInference(modelId: string, pointCloud: PointCloudUploadRef) { return postRun<{ job: PointCloudInferenceJob }>("api/3d-pointcloud/model-inference-runs", { modelId, pointCloud }); }
+export async function loadPointCloudModelInferenceJob(jobId: string) { return (await getJson<{ job: PointCloudInferenceJob }>(`api/3d-pointcloud/model-inference-jobs/${jobId}`)).job; }
+export async function createRiskRuleRun(files: { observations: UploadFilePayload; zones: UploadFilePayload; rules: UploadFilePayload }) { return postRun<{ run: RiskRuleDefinition }>("api/risk-rule-engine/runs", { files }); }
 export async function createChangeRun(files: { before: UploadFilePayload; after: UploadFilePayload }, threshold = 0.5, maxDimension = 0, processingMode: "auto" | "image" | "geotiff" = "auto") { return postRun<{ run: ChangeDefinition }>("api/change-detection/runs", { files, threshold, maxDimension, processingMode }); }
 export async function uploadChangeFile(file: File, role: "before" | "after"): Promise<ChangeUploadRef> {
   const uploadId = globalThis.crypto.randomUUID().replaceAll("-", "");
diff --git a/apps/workbench-console/src/components/ChangeDetectionPanel.vue b/apps/workbench-console/src/components/ChangeDetectionPanel.vue
index 5bf45d2..836aadc 100644
--- a/apps/workbench-console/src/components/ChangeDetectionPanel.vue
+++ b/apps/workbench-console/src/components/ChangeDetectionPanel.vue
@@ -170,14 +170,14 @@
   <template v-if="currentCase">
     <a-row :gutter="[18, 18]" class="change-workspace">
       <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCaseOptions"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value">{{ item.label }}</a-list-item></template></a-list></section></a-col>
-      <a-col :xs="24" :xl="19"><section class="surface-section"><div class="section-heading"><div><h2>双期与栅格结果</h2><p>{{ currentCase.run.input_shape[1] }} x {{ currentCase.run.input_shape[0] }} 像素,处理尺寸 {{ currentCase.run.processed_shape[1] }} x {{ currentCase.run.processed_shape[0] }}</p></div><a-tag color="green">CPU {{ currentCase.run.elapsed_seconds }} 秒</a-tag></div><div class="change-comparison"><figure><figcaption>第一期</figcaption><a-image :src="artifactUrl(currentCase.beforeImage)" /></figure><figure><figcaption>第二期</figcaption><a-image :src="artifactUrl(currentCase.afterImage)" /></figure><figure><figcaption>变化栅格叠加</figcaption><a-image :src="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.overlay}`)" /></figure></div></section></a-col>
+      <a-col :xs="24" :xl="19"><section class="surface-section"><div class="section-heading"><div><h2>双期与栅格结果</h2><p>{{ currentCase.run.input_shape[1] }} x {{ currentCase.run.input_shape[0] }} 像素,处理尺寸 {{ currentCase.run.processed_shape[1] }} x {{ currentCase.run.processed_shape[0] }}</p></div><a-tag color="green">CPU {{ currentCase.run.elapsed_seconds }} 秒</a-tag></div><div class="change-comparison"><figure><figcaption>第一期原图</figcaption><a-image :src="artifactUrl(currentCase.beforeImage)" /></figure><figure><figcaption>第二期原图</figcaption><a-image :src="artifactUrl(currentCase.rawAfterImage || currentCase.afterImage)" /></figure><figure><figcaption>第二期配准图</figcaption><a-image :src="artifactUrl(currentCase.registeredAfterImage || currentCase.afterImage)" /></figure><figure><figcaption>变化叠加:红色 = ChangeStar,青色 = 视觉差异候选</figcaption><a-image :src="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.overlay}`)" /></figure></div></section></a-col>
     </a-row>
 
     <a-row :gutter="[18, 18]" class="metric-row change-metrics"><a-col :xs="12" :lg="6"><a-statistic title="变化像素" :value="currentCase.run.changed_pixels" /></a-col><a-col :xs="12" :lg="6"><a-statistic title="变化比例" :value="changePercent" suffix="%" /></a-col><a-col :xs="12" :lg="6"><a-statistic title="变化图斑" :value="currentCase.run.vector_feature_count" /></a-col><a-col :xs="12" :lg="6"><a-statistic title="配准有效区" :value="validPercent" suffix="%" /></a-col></a-row>
 
     <a-row :gutter="[18, 18]" class="result-band"><a-col :xs="24" :xl="14"><section class="surface-section"><div class="section-heading"><div><h2>矢量图斑</h2><p>像素坐标结果;红线叠加已换算为图像左上角显示坐标。</p></div><a-tag>{{ currentCase.run.vector_feature_count }} 个</a-tag></div><div class="change-vector"><img :src="artifactUrl(currentCase.afterImage)" alt="第二期影像" /><svg v-if="vectorPaths.length" :viewBox="`0 0 ${currentCase.run.input_shape[1]} ${currentCase.run.input_shape[0]}`" preserveAspectRatio="xMidYMid meet"><path v-for="(path, index) in vectorPaths" :key="index" :d="path" /></svg><a-empty v-else description="当前阈值下没有变化图斑" /></div></section></a-col><a-col :xs="24" :xl="10"><section class="surface-section"><h2>图斑明细</h2><a-table :data-source="currentCase.features.map((item) => item.properties)" :pagination="false" row-key="feature_id" size="small" :scroll="{ x: 480 }"><a-table-column title="编号" data-index="feature_id" key="feature_id" /><a-table-column title="面积 (px)" data-index="area_pixels" key="area_pixels" align="right" /><a-table-column title="平均概率" data-index="mean_probability" key="mean_probability" align="right" /><a-table-column title="最大概率" data-index="max_probability" key="max_probability" align="right" /></a-table><a-divider /><a-descriptions size="small" :column="1"><a-descriptions-item label="模型">{{ currentCase.run.model }}</a-descriptions-item><a-descriptions-item label="阈值">{{ currentCase.run.thresholds.change_probability }}</a-descriptions-item><a-descriptions-item label="模式">{{ currentCase.run.processing_mode === "geotiff" ? "GeoTIFF 地理参考" : "普通图片像素坐标" }}</a-descriptions-item><a-descriptions-item label="配准">{{ currentCase.run.registration.method }} / {{ currentCase.run.registration.inliers }} 内点</a-descriptions-item><a-descriptions-item label="坐标">{{ currentCase.run.georeferenced ? `${currentCase.run.crs} 地图坐标` : "无 CRS,像素坐标" }}</a-descriptions-item></a-descriptions></section></a-col></a-row>
 
-    <section class="surface-section result-files change-files"><a-space wrap><a-button :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.probability_raster}`)" download><DownloadOutlined />概率 GeoTIFF</a-button><a-button :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.mask_raster}`)" download><FileImageOutlined />变化栅格</a-button><a-button type="primary" v-if="currentCase.run.artifacts.rectangle_vector" :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.rectangle_vector}`)" download><DownloadOutlined />规则四边形</a-button><a-button v-if="currentCase.run.artifacts.rectangle_vector_wgs84" :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.rectangle_vector_wgs84}`)" download><DownloadOutlined />经纬度 GeoJSON</a-button><a-button :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.vector}`)" download><FileOutlined />原始图斑 GeoJSON</a-button><a-button :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" download><FileOutlined />运行元数据</a-button></a-space></section>
+    <section class="surface-section result-files change-files"><a-space wrap><a-button :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.probability_raster}`)" download><DownloadOutlined />概率 GeoTIFF</a-button><a-button :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.mask_raster}`)" download><FileImageOutlined />ChangeStar 变化栅格</a-button><a-button v-if="currentCase.run.artifacts.generic_difference_mask" :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.generic_difference_mask}`)" download><FileImageOutlined />视觉差异候选栅格</a-button><a-button type="primary" v-if="currentCase.run.artifacts.rectangle_vector" :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.rectangle_vector}`)" download><DownloadOutlined />规则四边形</a-button><a-button v-if="currentCase.run.artifacts.rectangle_vector_wgs84" :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.rectangle_vector_wgs84}`)" download><DownloadOutlined />经纬度 GeoJSON</a-button><a-button :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.vector}`)" download><FileOutlined />原始图斑 GeoJSON</a-button><a-button :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" download><FileOutlined />运行元数据</a-button></a-space></section>
     <a-alert class="change-limit" type="warning" show-icon message="当前近景边坡样本不在 ChangeStar 建筑变化权重的验证分布内;结果只能用于工作流与人工复核,不能直接形成工程结论。" />
   </template>
 </template>
@@ -208,7 +208,7 @@
 .change-workspace > :deep(.ant-col) { display: flex; }
 .change-workspace .surface-section { width: 100%; }
 .change-workspace .run-library { align-self: flex-start; max-height: clamp(420px, calc(100vh - 260px), 620px); }
-.change-comparison { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
+.change-comparison { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
 .change-comparison figure { min-width: 0; margin: 0; }
 .change-comparison figcaption { margin-bottom: 7px; color: #425148; font-size: 12px; font-weight: 700; }
 .change-comparison :deep(.ant-image), .change-comparison :deep(img) { width: 100%; }
diff --git a/apps/workbench-console/src/components/PointCloudAnnotationViewer.vue b/apps/workbench-console/src/components/PointCloudAnnotationViewer.vue
new file mode 100644
index 0000000..fbc9cab
--- /dev/null
+++ b/apps/workbench-console/src/components/PointCloudAnnotationViewer.vue
@@ -0,0 +1,82 @@
+<script setup lang="ts">
+import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
+import { AimOutlined, BorderTopOutlined, ReloadOutlined, RotateRightOutlined, UndoOutlined } from "@ant-design/icons-vue";
+import * as THREE from "three";
+import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
+
+const props = defineProps<{ source: string; disabled?: boolean }>();
+const emit = defineEmits<{ save: [labels: Array<[number, number]>] }>();
+type Reader = { name: string; bytes: number; read: (view: DataView, offset: number) => number };
+type InteractionMode = "navigate" | "brush" | "rectangle";
+
+const schema = [
+  { code: 2, label: "地面", rgb: [151, 111, 51] }, { code: 5, label: "植被", rgb: [59, 163, 87] },
+  { code: 6, label: "建筑物", rgb: [224, 115, 55] }, { code: 15, label: "杆塔", rgb: [149, 89, 210] },
+  { code: 16, label: "电线", rgb: [231, 196, 61] }, { code: 1, label: "其他/未知", rgb: [128, 128, 128] },
+];
+const readers: Record<string, Omit<Reader, "name">> = {
+  char: { bytes: 1, read: (view, offset) => view.getInt8(offset) }, int8: { bytes: 1, read: (view, offset) => view.getInt8(offset) }, uchar: { bytes: 1, read: (view, offset) => view.getUint8(offset) }, uint8: { bytes: 1, read: (view, offset) => view.getUint8(offset) },
+  short: { bytes: 2, read: (view, offset) => view.getInt16(offset, true) }, int16: { bytes: 2, read: (view, offset) => view.getInt16(offset, true) }, ushort: { bytes: 2, read: (view, offset) => view.getUint16(offset, true) }, uint16: { bytes: 2, read: (view, offset) => view.getUint16(offset, true) },
+  int: { bytes: 4, read: (view, offset) => view.getInt32(offset, true) }, int32: { bytes: 4, read: (view, offset) => view.getInt32(offset, true) }, uint: { bytes: 4, read: (view, offset) => view.getUint32(offset, true) }, uint32: { bytes: 4, read: (view, offset) => view.getUint32(offset, true) },
+  float: { bytes: 4, read: (view, offset) => view.getFloat32(offset, true) }, float32: { bytes: 4, read: (view, offset) => view.getFloat32(offset, true) }, double: { bytes: 8, read: (view, offset) => view.getFloat64(offset, true) }, float64: { bytes: 8, read: (view, offset) => view.getFloat64(offset, true) },
+};
+const host = ref<HTMLDivElement | null>(null); const loading = ref(true); const error = ref<string | null>(null); const pointCount = ref(0); const brushRadius = ref(1.5); const classCode = ref(16); const pointSize = ref(3); const labelledCount = ref(0); const interactionMode = ref<InteractionMode>("navigate"); const pivotPicking = ref(false); const orientationOpen = ref(false); const orientation = reactive({ x: 0, y: 0, z: 0 }); const selectionBox = ref<{ left: number; top: number; width: number; height: number } | null>(null);
+let renderer: THREE.WebGLRenderer | null = null; let scene: THREE.Scene | null = null; let camera: THREE.PerspectiveCamera | null = null; let controls: OrbitControls | null = null; let model: THREE.Group | null = null; let cloud: THREE.Points<THREE.BufferGeometry, THREE.PointsMaterial> | null = null; let circleTexture: THREE.CanvasTexture | null = null; let resizeObserver: ResizeObserver | null = null; let animation = 0; let requestId = 0; let activePointer: number | null = null; let rectangleStart: { x: number; y: number } | null = null; let lastBrushPosition: { x: number; y: number } | null = null;
+let positions = new Float32Array(); let baseColors = new Float32Array(); let shownColors = new Float32Array(); let labels = new Int16Array(); let history: Array<Array<[number, number]>> = [];
+const classOptions = computed(() => schema.map((item) => ({ value: item.code, label: item.label })));
+const interactionOptions = [{ label: "浏览", value: "navigate" }, { label: "笔刷", value: "brush" }, { label: "框选", value: "rectangle" }];
+const orientationAxes = [{ key: "x" as const, label: "X" }, { key: "y" as const, label: "Y" }, { key: "z" as const, label: "Z" }];
+const srgbToLinear = (value: number) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
+
+function headerEnd(bytes: Uint8Array) { const marker = [101, 110, 100, 95, 104, 101, 97, 100, 101, 114]; for (let start = 0; start <= bytes.length - marker.length; start += 1) if (marker.every((value, index) => bytes[start + index] === value)) { let end = start + marker.length; if (bytes[end] === 13) end += 1; if (bytes[end] === 10) return end + 1; } throw new Error("PLY 文件缺少 end_header。"); }
+function parse(buffer: ArrayBuffer) {
+  const headerLength = headerEnd(new Uint8Array(buffer, 0, Math.min(buffer.byteLength, 64 * 1024))); const header = new TextDecoder("ascii").decode(new Uint8Array(buffer, 0, headerLength)); const lines = header.split(/\r?\n/);
+  if (!lines.some((line) => line.trim() === "format binary_little_endian 1.0")) throw new Error("仅支持 binary_little_endian PLY。");
+  const count = Number(lines.find((line) => line.startsWith("element vertex "))?.split(/\s+/)[2]); if (!Number.isSafeInteger(count) || count < 1) throw new Error("PLY 顶点数无效。");
+  const properties: Reader[] = []; let vertex = false; for (const line of lines) { if (line.startsWith("element ")) { vertex = line.startsWith("element vertex "); continue; } if (vertex && line.startsWith("property ")) { const [, type, name] = line.trim().split(/\s+/); if (!readers[type] || !name) throw new Error("PLY 顶点属性不受支持。"); properties.push({ name, ...readers[type] }); } }
+  const offsets: Record<string, number> = {}; let stride = 0; for (const property of properties) { offsets[property.name] = stride; stride += property.bytes; } if (["x", "y", "z"].some((name) => offsets[name] === undefined) || headerLength + stride * count > buffer.byteLength) throw new Error("PLY 坐标数据不完整。");
+  const byName = Object.fromEntries(properties.map((item) => [item.name, item])); const view = new DataView(buffer, headerLength); const xyz = new Float32Array(count * 3); const rgb = new Float32Array(count * 3); const hasRgb = ["red", "green", "blue"].every((name) => offsets[name] !== undefined);
+  for (let index = 0; index < count; index += 1) { const base = index * stride; for (const [name, offset] of [["x", 0], ["y", 1], ["z", 2]] as const) xyz[index * 3 + offset] = byName[name].read(view, base + offsets[name]); for (const [name, offset] of [["red", 0], ["green", 1], ["blue", 2]] as const) { const value = hasRgb ? Math.min(Math.max(byName[name].read(view, base + offsets[name]) / 255, 0), 1) : 0.72; rgb[index * 3 + offset] = srgbToLinear(value); } }
+  return { xyz, rgb };
+}
+function createCircleTexture() { const canvas = document.createElement("canvas"); canvas.width = canvas.height = 64; const context = canvas.getContext("2d"); if (!context) throw new Error("无法创建点云渲染纹理。"); const gradient = context.createRadialGradient(32, 32, 0, 32, 32, 32); gradient.addColorStop(0, "rgba(255,255,255,1)"); gradient.addColorStop(0.72, "rgba(255,255,255,1)"); gradient.addColorStop(1, "rgba(255,255,255,0)"); context.fillStyle = gradient; context.fillRect(0, 0, 64, 64); const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; return texture; }
+function resize() { if (!host.value || !renderer || !camera) return; const width = Math.max(host.value.clientWidth, 1); const height = Math.max(host.value.clientHeight, 1); renderer.setSize(width, height, false); camera.aspect = width / height; camera.updateProjectionMatrix(); }
+function frame() { if (renderer && scene && camera && controls) { controls.update(); renderer.render(scene, camera); } animation = requestAnimationFrame(frame); }
+function modelCentre() { if (!cloud) return null; cloud.geometry.computeBoundingSphere(); const sphere = cloud.geometry.boundingSphere; return sphere ? cloud.localToWorld(sphere.center.clone()) : null; }
+function reset() { if (!cloud || !camera || !controls) return; cloud.geometry.computeBoundingSphere(); const sphere = cloud.geometry.boundingSphere; const centre = modelCentre(); if (!sphere || !centre) return; const radius = Math.max(sphere.radius, 0.01); controls.target.copy(centre); controls.minDistance = radius * 0.08; controls.maxDistance = radius * 8; camera.position.copy(centre).add(new THREE.Vector3(radius * 1.25, radius * 0.85, radius * 1.65)); camera.near = Math.max(radius / 1_000, 0.01); camera.far = radius * 20; camera.updateProjectionMatrix(); controls.update(); }
+function topDown() { if (!cloud || !camera || !controls) return; cloud.geometry.computeBoundingSphere(); const sphere = cloud.geometry.boundingSphere; const centre = modelCentre(); if (!sphere || !centre) return; const distance = Math.max(camera.position.distanceTo(controls.target), sphere.radius * 1.5); controls.target.copy(centre); camera.up.set(0, 1, 0); camera.position.copy(centre).add(new THREE.Vector3(0, 0, distance)); camera.updateProjectionMatrix(); controls.update(); }
+function applyOrientation() { if (model) model.rotation.set(THREE.MathUtils.degToRad(orientation.x), THREE.MathUtils.degToRad(orientation.y), THREE.MathUtils.degToRad(orientation.z), "XYZ"); }
+function resetOrientation() { orientation.x = 0; orientation.y = 0; orientation.z = 0; }
+function setPointSize() { if (cloud) cloud.material.size = pointSize.value; }
+function recolor(indices: number[]) { if (!cloud) return; for (const index of indices) { const item = schema.find((value) => value.code === labels[index]); for (let channel = 0; channel < 3; channel += 1) shownColors[index * 3 + channel] = item ? srgbToLinear(item.rgb[channel] / 255) : baseColors[index * 3 + channel]; } (cloud.geometry.getAttribute("color") as THREE.BufferAttribute).needsUpdate = true; labelledCount.value = labels.reduce((total, value) => total + (value ? 1 : 0), 0); }
+function applyClass(indices: number[]) { const changes: Array<[number, number]> = []; for (const index of indices) if (labels[index] !== classCode.value) { changes.push([index, labels[index]]); labels[index] = classCode.value; } if (changes.length) { history.push(changes); recolor(changes.map(([index]) => index)); } }
+function brush(event: PointerEvent) { if (props.disabled || !renderer || !camera || !cloud || loading.value) return; const rect = renderer.domElement.getBoundingClientRect(); const mouse = new THREE.Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1); const raycaster = new THREE.Raycaster(); raycaster.params.Points.threshold = Math.max(0.35, brushRadius.value * 0.5); raycaster.setFromCamera(mouse, camera); const hit = raycaster.intersectObject(cloud, false)[0]; if (hit?.index === undefined) return; const center = new THREE.Vector3().fromBufferAttribute(cloud.geometry.getAttribute("position") as THREE.BufferAttribute, hit.index); const radiusSquared = brushRadius.value ** 2; const selected: number[] = []; for (let index = 0; index < labels.length; index += 1) { const dx = positions[index * 3] - center.x; const dy = positions[index * 3 + 1] - center.y; const dz = positions[index * 3 + 2] - center.z; if (dx * dx + dy * dy + dz * dz <= radiusSquared) selected.push(index); } applyClass(selected); }
+function canvasPosition(event: PointerEvent) { const rect = renderer!.domElement.getBoundingClientRect(); return { x: event.clientX - rect.left, y: event.clientY - rect.top, width: rect.width, height: rect.height }; }
+function pickPivot(event: PointerEvent) { if (!renderer || !camera || !cloud || !controls) return; const rect = renderer.domElement.getBoundingClientRect(); const mouse = new THREE.Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1); const raycaster = new THREE.Raycaster(); raycaster.params.Points.threshold = Math.max(0.35, brushRadius.value * 0.5); raycaster.setFromCamera(mouse, camera); const hit = raycaster.intersectObject(cloud, false)[0]; if (hit) controls.target.copy(hit.point); pivotPicking.value = false; }
+function togglePivotPicking() { pivotPicking.value = !pivotPicking.value; }
+function preventMiddleAutoScroll(event: MouseEvent) { if (event.button === 1) event.preventDefault(); }
+function preventContextMenu(event: MouseEvent) { event.preventDefault(); }
+function finishPointer(event: PointerEvent) { if (!renderer || activePointer !== event.pointerId) return; activePointer = null; rectangleStart = null; lastBrushPosition = null; selectionBox.value = null; if (controls) controls.enabled = true; if (renderer.domElement.hasPointerCapture(event.pointerId)) renderer.domElement.releasePointerCapture(event.pointerId); }
+function selectionStart(event: PointerEvent) { if (pivotPicking.value && event.button === 0) { event.preventDefault(); event.stopImmediatePropagation(); pickPivot(event); return; } if (interactionMode.value === "navigate" || event.button !== 0 || props.disabled || !renderer || !controls) return; event.preventDefault(); event.stopImmediatePropagation(); activePointer = event.pointerId; controls.enabled = false; renderer.domElement.setPointerCapture(event.pointerId); const point = canvasPosition(event); if (interactionMode.value === "brush") { lastBrushPosition = { x: point.x, y: point.y }; brush(event); return; } rectangleStart = { x: point.x, y: point.y }; selectionBox.value = { left: point.x, top: point.y, width: 0, height: 0 }; }
+function selectionMove(event: PointerEvent) { if (!renderer || activePointer !== event.pointerId) return; event.preventDefault(); event.stopImmediatePropagation(); const point = canvasPosition(event); if (interactionMode.value === "brush") { if (!lastBrushPosition || Math.hypot(point.x - lastBrushPosition.x, point.y - lastBrushPosition.y) >= 10) { lastBrushPosition = { x: point.x, y: point.y }; brush(event); } return; } if (!rectangleStart) return; selectionBox.value = { left: Math.min(rectangleStart.x, point.x), top: Math.min(rectangleStart.y, point.y), width: Math.abs(point.x - rectangleStart.x), height: Math.abs(point.y - rectangleStart.y) }; }
+function selectionEnd(event: PointerEvent) { if (!renderer || !camera || activePointer !== event.pointerId) return; event.preventDefault(); event.stopImmediatePropagation(); if (interactionMode.value === "brush") { brush(event); finishPointer(event); return; } if (!rectangleStart) { finishPointer(event); return; } const point = canvasPosition(event); const left = Math.min(rectangleStart.x, point.x); const right = Math.max(rectangleStart.x, point.x); const top = Math.min(rectangleStart.y, point.y); const bottom = Math.max(rectangleStart.y, point.y); const selected: number[] = []; const position = new THREE.Vector3(); for (let index = 0; index < labels.length; index += 1) { position.set(positions[index * 3], positions[index * 3 + 1], positions[index * 3 + 2]); cloud!.localToWorld(position).project(camera); if (position.z < -1 || position.z > 1) continue; const x = (position.x + 1) * 0.5 * point.width; const y = (1 - position.y) * 0.5 * point.height; if (x >= left && x <= right && y >= top && y <= bottom) selected.push(index); } finishPointer(event); applyClass(selected); }
+function undo() { const changes = history.pop(); if (!changes) return; for (const [index, old] of changes) labels[index] = old; recolor(changes.map(([index]) => index)); }
+function clear() { const indices = Array.from({ length: labels.length }, (_, index) => index).filter((index) => labels[index]); labels.fill(0); history = []; recolor(indices); }
+function save() { emit("save", Array.from(labels.entries()).filter(([, code]) => code > 0).map(([index, code]) => [index, code])); }
+async function load(source: string) { const active = ++requestId; loading.value = true; error.value = null; try { const response = await fetch(source, { cache: "no-store" }); if (!response.ok) throw new Error(`点云读取失败 (${response.status})。`); const parsed = parse(await response.arrayBuffer()); if (active !== requestId || !scene) return; if (model) scene.remove(model); cloud?.geometry.dispose(); cloud?.material.dispose(); positions = parsed.xyz; baseColors = parsed.rgb; shownColors = parsed.rgb.slice(); labels = new Int16Array(positions.length / 3); history = []; const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); geometry.setAttribute("color", new THREE.BufferAttribute(shownColors, 3)); geometry.computeBoundingSphere(); const centre = geometry.boundingSphere?.center.clone() ?? new THREE.Vector3(); cloud = new THREE.Points(geometry, new THREE.PointsMaterial({ size: pointSize.value, vertexColors: true, sizeAttenuation: false, map: circleTexture ?? undefined, transparent: true, opacity: 0.9, alphaTest: 0.12, depthWrite: true })); model = new THREE.Group(); model.position.copy(centre); cloud.position.copy(centre).multiplyScalar(-1); model.add(cloud); scene.add(model); applyOrientation(); pointCount.value = labels.length; labelledCount.value = 0; reset(); } catch (caught) { if (active === requestId) error.value = caught instanceof Error ? caught.message : "点云无法读取。"; } finally { if (active === requestId) loading.value = false; } }
+onMounted(() => { if (!host.value) return; scene = new THREE.Scene(); scene.background = new THREE.Color("#101821"); camera = new THREE.PerspectiveCamera(42, 1, 0.01, 1_000); renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; circleTexture = createCircleTexture(); host.value.appendChild(renderer.domElement); renderer.domElement.addEventListener("mousedown", preventMiddleAutoScroll, { passive: false }); renderer.domElement.addEventListener("auxclick", preventMiddleAutoScroll, { passive: false }); renderer.domElement.addEventListener("pointerdown", selectionStart); renderer.domElement.addEventListener("pointermove", selectionMove); renderer.domElement.addEventListener("pointerup", selectionEnd); renderer.domElement.addEventListener("pointercancel", finishPointer); renderer.domElement.addEventListener("contextmenu", preventContextMenu); controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.12; controls.rotateSpeed = 0.55; controls.zoomSpeed = 0.8; controls.panSpeed = 0.75; controls.screenSpacePanning = true; controls.minPolarAngle = 0.04; controls.maxPolarAngle = Math.PI - 0.04; controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.PAN, RIGHT: THREE.MOUSE.ROTATE }; resizeObserver = new ResizeObserver(resize); resizeObserver.observe(host.value); resize(); frame(); void load(props.source); });
+watch(() => props.source, (source) => { void load(source); }); watch(pointSize, setPointSize); watch(orientation, applyOrientation, { deep: true });
+onBeforeUnmount(() => { cancelAnimationFrame(animation); resizeObserver?.disconnect(); renderer?.domElement.removeEventListener("mousedown", preventMiddleAutoScroll); renderer?.domElement.removeEventListener("auxclick", preventMiddleAutoScroll); renderer?.domElement.removeEventListener("pointerdown", selectionStart); renderer?.domElement.removeEventListener("pointermove", selectionMove); renderer?.domElement.removeEventListener("pointerup", selectionEnd); renderer?.domElement.removeEventListener("pointercancel", finishPointer); renderer?.domElement.removeEventListener("contextmenu", preventContextMenu); controls?.dispose(); cloud?.geometry.dispose(); cloud?.material.dispose(); circleTexture?.dispose(); renderer?.dispose(); renderer?.domElement.remove(); });
+</script>
+
+<template>
+  <div class="annotation-viewer"><div ref="host" class="canvas-host" :class="{ 'pivot-picking': pivotPicking }" /><div v-if="selectionBox" class="selection-box" :style="{ left: `${selectionBox.left}px`, top: `${selectionBox.top}px`, width: `${selectionBox.width}px`, height: `${selectionBox.height}px` }" /><div class="annotation-toolbar"><a-select v-model:value="classCode" :options="classOptions" :disabled="disabled" /><a-segmented v-model:value="interactionMode" :options="interactionOptions" :disabled="disabled" /><label v-if="interactionMode === 'brush'">刷选半径<a-slider v-model:value="brushRadius" :min="0.2" :max="8" :step="0.1" :disabled="disabled" /></label><label>点大小<a-slider v-model:value="pointSize" :min="1" :max="7" :step="0.2" /></label><span>{{ labelledCount.toLocaleString() }} / {{ pointCount.toLocaleString() }} 已标注</span><a-popover v-model:open="orientationOpen" title="模型朝向" trigger="click"><template #content><div class="orientation-controls"><label v-for="axis in orientationAxes" :key="axis.key">{{ axis.label }}<a-slider v-model:value="orientation[axis.key]" :min="0" :max="360" :step="1" /><a-input-number v-model:value="orientation[axis.key]" :min="0" :max="360" :precision="0" addon-after="°" /></label><a-button size="small" @click="resetOrientation">重置</a-button></div></template><a-tooltip title="模型朝向"><a-button type="text"><RotateRightOutlined /></a-button></a-tooltip></a-popover><a-tooltip title="设置旋转中心"><a-button :type="pivotPicking ? 'primary' : 'text'" @click="togglePivotPicking"><AimOutlined /></a-button></a-tooltip><a-tooltip title="俯视"><a-button type="text" @click="topDown"><BorderTopOutlined /></a-button></a-tooltip><a-tooltip title="复位视角"><a-button type="text" @click="reset"><ReloadOutlined /></a-button></a-tooltip><a-tooltip title="撤销上一次标注"><a-button type="text" :disabled="disabled || !history.length" @click="undo"><UndoOutlined /></a-button></a-tooltip><a-button size="small" :disabled="disabled || !labelledCount" @click="clear">清空</a-button><a-button size="small" type="primary" :loading="disabled" :disabled="disabled || !labelledCount" @click="save">保存标注版本</a-button></div><div v-if="loading || error" class="viewer-state"><a-spin v-if="loading" /><span v-else>{{ error }}</span></div></div>
+</template>
+
+<style scoped>
+.annotation-viewer { position: relative; height: 620px; overflow: hidden; background: #101821; border: 1px solid #273543; } .canvas-host { width: 100%; height: 100%; } .canvas-host.pivot-picking { cursor: crosshair; } .canvas-host :deep(canvas) { display: block; width: 100%; height: 100%; touch-action: none; }
+.selection-box { position: absolute; z-index: 2; pointer-events: none; border: 1px solid #62b7e6; background: rgba(98, 183, 230, .15); }
+.annotation-toolbar { position: absolute; right: 12px; bottom: 12px; display: flex; align-items: center; gap: 10px; padding: 8px 10px; color: #e8f1f5; background: rgba(12, 20, 29, .9); font-size: 13px; } .annotation-toolbar label { display: flex; align-items: center; gap: 6px; white-space: nowrap; } .annotation-toolbar :deep(.ant-slider) { width: 82px; margin: 0; } .annotation-toolbar :deep(.ant-btn) { color: #e8f1f5; } .annotation-toolbar :deep(.ant-select) { width: 110px; }
+.orientation-controls { display: grid; gap: 8px; min-width: 250px; } .orientation-controls label { display: grid; grid-template-columns: 16px 1fr 84px; align-items: center; gap: 8px; } .orientation-controls :deep(.ant-slider) { margin: 0; } .orientation-controls :deep(.ant-input-number-group-wrapper) { width: 84px; }
+.viewer-state { position: absolute; inset: 0; display: grid; place-items: center; color: #e8f1f5; background: rgba(12, 20, 29, .65); } @media (max-width: 767px) { .annotation-viewer { height: 480px; } .annotation-toolbar { left: 8px; right: 8px; bottom: 8px; flex-wrap: wrap; } }
+</style>
diff --git a/apps/workbench-console/src/components/PointCloudPanel.vue b/apps/workbench-console/src/components/PointCloudPanel.vue
new file mode 100644
index 0000000..ccbff85
--- /dev/null
+++ b/apps/workbench-console/src/components/PointCloudPanel.vue
@@ -0,0 +1,333 @@
+<script setup lang="ts">
+import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
+import { DeleteOutlined, DownloadOutlined, FileImageOutlined, FileOutlined, PlayCircleOutlined, UploadOutlined } from "@ant-design/icons-vue";
+
+import { artifactUrl, createDensePointCloudClassification, createPhotoReconstructionRun, createPointCloudAnnotation, createPointCloudModelInference, createPointCloudRun, createPointCloudTrainingRun, deletePointCloudAnnotation, loadPhotoReconstructionJob, loadPointCloudAnnotationSources, loadPointCloudAnnotations, loadPointCloudModelInferenceJob, loadPointCloudSemanticModels, loadPointCloudTrainingJob, uploadPhotoReconstructionFile, uploadPointCloudFile, type GeoFeature, type PhotoReconstructionJob, type PointCloudAnnotation, type PointCloudAnnotationSource, type PointCloudCase, type PointCloudInferenceJob, type PointCloudSemanticModel, type PointCloudTrainingJob } from "@/api/artifacts";
+import ArtifactState from "@/components/ArtifactState.vue";
+import SparsePointCloudViewer from "@/components/SparsePointCloudViewer.vue";
+import PointCloudAnnotationViewer from "@/components/PointCloudAnnotationViewer.vue";
+import TexturedMeshViewer from "@/components/TexturedMeshViewer.vue";
+import { useArtifactStore } from "@/stores/artifacts";
+
+const store = useArtifactStore();
+const caseId = ref("");
+const selectedName = ref("");
+const files = ref<File[]>([]);
+const photoFiles = ref<File[]>([]);
+const showRunForm = ref(false);
+const running = ref(false);
+const photoSubmitting = ref(false);
+const photoJob = ref<PhotoReconstructionJob | null>(null);
+const usePhotoPositionPriors = ref(false);
+const workflow = ref<"photo" | "geometry" | "semantic" | "model">("photo");
+const runError = ref<string | null>(null);
+const searchText = ref("");
+const vectorFeatures = ref<GeoFeature[]>([]);
+const densePreviewMode = ref<"textured" | "geometry" | "point_colors">("textured");
+let photoPollTimer: ReturnType<typeof setTimeout> | undefined;
+const annotationSources = ref<PointCloudAnnotationSource[]>([]);
+const annotations = ref<PointCloudAnnotation[]>([]);
+const annotationSourceId = ref("");
+const annotationSaving = ref(false);
+const annotationNotice = ref<{ type: "info" | "success" | "error"; message: string } | null>(null);
+const annotationDeletingId = ref("");
+const trainingJob = ref<PointCloudTrainingJob | null>(null);
+let trainingPollTimer: ReturnType<typeof setTimeout> | undefined;
+const semanticModels = ref<PointCloudSemanticModel[]>([]);
+const selectedSemanticModelId = ref("");
+const inferenceFile = ref<File | null>(null);
+const inferenceSubmitting = ref(false);
+const inferenceJob = ref<PointCloudInferenceJob | null>(null);
+const inferenceSummary = ref<{ class_counts?: Record<string, number>; input_points?: number; preview_points?: number } | null>(null);
+let inferencePollTimer: ReturnType<typeof setTimeout> | undefined;
+
+const workflowCases = computed(() => Object.values(store.pointCloudCases).filter((item) => {
+  const run = item.run;
+  if (workflow.value === "photo") return Boolean(run.photo_reconstruction || run.dense_photo_reconstruction);
+  if (workflow.value === "semantic" || workflow.value === "model") return (run.point_clouds ?? []).some((cloud) => Boolean(cloud.semantic_preview_point_cloud));
+  return Boolean(run.point_clouds?.length);
+}));
+const currentCase = computed<PointCloudCase | undefined>(() => workflowCases.value.find((item) => item.id === caseId.value) ?? workflowCases.value[0]);
+const selectedCloud = computed(() => (currentCase.value?.run.point_clouds ?? []).find((item) => item.file === selectedName.value) ?? currentCase.value?.run.point_clouds?.[0]);
+const photoRun = computed(() => currentCase.value?.run.photo_reconstruction);
+const densePhotoRun = computed(() => currentCase.value?.run.dense_photo_reconstruction);
+const densePreviewSource = computed(() => {
+  const dense = densePhotoRun.value;
+  if (!dense) return "";
+  if (densePreviewMode.value === "geometry" && dense.geometry_preview_model_file) return dense.geometry_preview_model_file;
+  if (densePreviewMode.value === "point_colors" && dense.point_color_preview_model_file) return dense.point_color_preview_model_file;
+  return dense.textured_model_file;
+});
+const caseOptions = computed(() => workflowCases.value.map((item) => ({ value: item.id, label: item.label })));
+const filteredCases = computed(() => caseOptions.value.filter((item) => item.label.toLowerCase().includes(searchText.value.trim().toLowerCase())));
+const photoJobMessage = computed(() => {
+  const job = photoJob.value;
+  if (!job) return "";
+  if (job.status === "queued") return `已排队:${job.inputImages} 张照片等待 CPU 资源。`;
+  if (job.stage === "sparse_sfm") return "正在进行 CPU 稀疏相机建模。";
+  if (job.stage === "dense_mvs") return "正在进行 CPU 稠密点云、网格与纹理重建,通常需要几十分钟。";
+  if (job.status === "complete") return "重建完成,结果已加入案例库。";
+  return job.error || "照片重建失败。";
+});
+
+function rings(feature: GeoFeature): number[][][] {
+  const geometry = feature.geometry;
+  if (!geometry?.coordinates) return [];
+  if (geometry.type === "Polygon") return geometry.coordinates as number[][][];
+  if (geometry.type === "MultiPolygon") return (geometry.coordinates as number[][][][]).flat();
+  return [];
+}
+const allRings = computed(() => vectorFeatures.value.flatMap(rings));
+const vectorViewBox = computed(() => {
+  const points = allRings.value.flat();
+  if (!points.length) return "0 0 1 1";
+  const xs = points.map((point) => point[0]); const ys = points.map((point) => point[1]);
+  const minX = Math.min(...xs); const maxX = Math.max(...xs); const minY = Math.min(...ys); const maxY = Math.max(...ys);
+  return `${minX} ${minY} ${Math.max(maxX - minX, 1)} ${Math.max(maxY - minY, 1)}`;
+});
+const vectorPaths = computed(() => allRings.value.map((ring) => ring.map((point, index) => `${index ? "L" : "M"}${point[0]},${point[1]}`).join(" ") + " Z"));
+const annotationSource = computed(() => annotationSources.value.find((item) => item.id === annotationSourceId.value));
+const selectedSemanticModel = computed(() => semanticModels.value.find((item) => item.id === selectedSemanticModelId.value));
+const semanticModelOptions = computed(() => semanticModels.value.map((model) => ({ value: model.id, label: `${model.label} | ${Object.values(model.classes).map((item) => item.label).join(" / ")}` })));
+function annotationSourceLabel(sourceId: string) { return annotationSources.value.find((item) => item.id === sourceId)?.label ?? sourceId; }
+
+function syncSelection() { selectedName.value = currentCase.value?.run.point_clouds?.[0]?.file ?? ""; }
+function selectWorkflowCase() { caseId.value = workflowCases.value[0]?.id ?? ""; syncSelection(); }
+function beforeUpload(file: File) { files.value = [...files.value, file]; return false; }
+function removeFile(file: { name: string }) { files.value = files.value.filter((item) => item.name !== file.name); }
+function beforePhotoUpload(file: File) { photoFiles.value = [...photoFiles.value, file]; return false; }
+function removePhotoFile(file: { name: string }) { photoFiles.value = photoFiles.value.filter((item) => item.name !== file.name); }
+function stopPhotoPolling() { if (photoPollTimer) clearTimeout(photoPollTimer); photoPollTimer = undefined; }
+async function loadVector() {
+  vectorFeatures.value = [];
+  if (!currentCase.value || !selectedCloud.value) return;
+  const response = await fetch(artifactUrl(`${currentCase.value.artifactRoot}/${selectedCloud.value.vector_file}`), { cache: "no-store" });
+  if (response.ok) vectorFeatures.value = ((await response.json()) as { features?: GeoFeature[] }).features ?? [];
+}
+async function refreshAnnotationData() {
+  annotationSources.value = await loadPointCloudAnnotationSources();
+  annotations.value = await loadPointCloudAnnotations();
+  if (!annotationSourceId.value || !annotationSources.value.some((item) => item.id === annotationSourceId.value)) annotationSourceId.value = annotationSources.value[0]?.id ?? "";
+}
+async function refreshSemanticModels() {
+  semanticModels.value = await loadPointCloudSemanticModels();
+  if (!selectedSemanticModelId.value || !semanticModels.value.some((item) => item.id === selectedSemanticModelId.value)) selectedSemanticModelId.value = semanticModels.value[0]?.id ?? "";
+}
+function stopTrainingPolling() { if (trainingPollTimer) clearTimeout(trainingPollTimer); trainingPollTimer = undefined; }
+async function pollTrainingJob() {
+  if (!trainingJob.value) return;
+  try {
+    trainingJob.value = await loadPointCloudTrainingJob(trainingJob.value.id);
+    if (!['complete', 'failed'].includes(trainingJob.value.status)) trainingPollTimer = setTimeout(() => { void pollTrainingJob(); }, 2_500);
+  } catch (error) { runError.value = error instanceof Error ? error.message : "无法读取训练任务状态。"; }
+}
+async function saveAnnotation(labels: Array<[number, number]>) {
+  if (!annotationSourceId.value) return;
+  annotationSaving.value = true; annotationNotice.value = { type: "info", message: `正在保存 ${labels.length.toLocaleString()} 个确认点...` };
+  try { const { annotation } = await createPointCloudAnnotation(annotationSourceId.value, labels); await refreshAnnotationData(); annotationNotice.value = { type: "success", message: `已保存 ${annotation.id}:${annotation.labelCount.toLocaleString()} 个确认点。` }; }
+  catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "保存标注失败。" }; }
+  finally { annotationSaving.value = false; }
+}
+async function deleteAnnotation(annotationId: string) {
+  annotationDeletingId.value = annotationId;
+  try { await deletePointCloudAnnotation(annotationId); await refreshAnnotationData(); annotationNotice.value = { type: "success", message: `已删除标注版本 ${annotationId}。` }; }
+  catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "删除标注版本失败。" }; }
+  finally { annotationDeletingId.value = ""; }
+}
+async function startTraining(annotationId: string) {
+  try { const { job } = await createPointCloudTrainingRun(annotationId, "cpu"); trainingJob.value = job; stopTrainingPolling(); void pollTrainingJob(); }
+  catch (error) { runError.value = error instanceof Error ? error.message : "启动训练失败。"; }
+}
+function beforeInferenceUpload(file: File) { inferenceFile.value = file; return false; }
+function removeInferenceUpload() { inferenceFile.value = null; }
+function stopInferencePolling() { if (inferencePollTimer) clearTimeout(inferencePollTimer); inferencePollTimer = undefined; }
+async function loadInferenceSummary(job: PointCloudInferenceJob) {
+  if (!job.summary) return;
+  const response = await fetch(artifactUrl(job.summary), { cache: "no-store" });
+  if (response.ok) inferenceSummary.value = await response.json() as { class_counts?: Record<string, number>; input_points?: number; preview_points?: number };
+}
+async function pollInferenceJob() {
+  if (!inferenceJob.value) return;
+  try {
+    inferenceJob.value = await loadPointCloudModelInferenceJob(inferenceJob.value.id);
+    if (inferenceJob.value.status === "complete") { await loadInferenceSummary(inferenceJob.value); return; }
+    if (inferenceJob.value.status !== "failed") inferencePollTimer = setTimeout(() => { void pollInferenceJob(); }, 2_500);
+  } catch (error) { runError.value = error instanceof Error ? error.message : "无法读取模型应用任务状态。"; }
+}
+async function applySemanticModel() {
+  if (!selectedSemanticModelId.value || !inferenceFile.value) return;
+  inferenceSubmitting.value = true; runError.value = null; inferenceJob.value = null; inferenceSummary.value = null;
+  try {
+    const upload = await uploadPointCloudFile(inferenceFile.value);
+    const { job } = await createPointCloudModelInference(selectedSemanticModelId.value, upload);
+    inferenceFile.value = null; inferenceJob.value = job; stopInferencePolling(); void pollInferenceJob();
+  } catch (error) { runError.value = error instanceof Error ? error.message : "应用训练模型失败。"; }
+  finally { inferenceSubmitting.value = false; }
+}
+async function submitRun() {
+  if (!files.value.length) { runError.value = "请选择至少一份 PLY、PCD、XYZ、LAS 或 LAZ 点云。"; return; }
+  running.value = true; runError.value = null;
+  try {
+    const pointClouds = [];
+    for (const file of files.value) pointClouds.push(await uploadPointCloudFile(file));
+    const { run } = await createPointCloudRun(pointClouds);
+    await store.loadPointCloud(true); caseId.value = run.id; syncSelection(); files.value = []; showRunForm.value = false;
+  } catch (error) { runError.value = error instanceof Error ? error.message : "三维点云运行失败"; }
+  finally { running.value = false; }
+}
+async function classifyDenseResult() {
+  if (!currentCase.value) return;
+  running.value = true; runError.value = null;
+  try {
+    const { run } = await createDensePointCloudClassification(currentCase.value.id);
+    await store.loadPointCloud(true); workflow.value = "semantic"; caseId.value = run.id; syncSelection();
+  } catch (error) { runError.value = error instanceof Error ? error.message : "稠密点云分类失败"; }
+  finally { running.value = false; }
+}
+async function pollPhotoReconstructionJob() {
+  const current = photoJob.value;
+  if (!current) return;
+  try {
+    const job = await loadPhotoReconstructionJob(current.id);
+    photoJob.value = job;
+    if (job.status === "complete") {
+      await store.loadPointCloud(true);
+      caseId.value = job.run?.id ?? job.runId;
+      syncSelection();
+      return;
+    }
+    if (job.status !== "failed") photoPollTimer = setTimeout(() => { void pollPhotoReconstructionJob(); }, 3_000);
+  } catch (error) {
+    runError.value = error instanceof Error ? error.message : "无法读取照片重建任务状态。";
+  }
+}
+async function submitPhotoReconstruction() {
+  if (photoFiles.value.length < 3) { runError.value = "请至少选择 3 张同一架次、同一相机的 JPG/JPEG 照片。"; return; }
+  photoSubmitting.value = true;
+  runError.value = null;
+  photoJob.value = null;
+  try {
+    const uploads = [];
+    for (const file of photoFiles.value) uploads.push(await uploadPhotoReconstructionFile(file));
+    const { job } = await createPhotoReconstructionRun(uploads, usePhotoPositionPriors.value);
+    photoFiles.value = [];
+    photoJob.value = job;
+    showRunForm.value = false;
+    stopPhotoPolling();
+    void pollPhotoReconstructionJob();
+  } catch (error) {
+    runError.value = error instanceof Error ? error.message : "照片重建任务提交失败。";
+  } finally { photoSubmitting.value = false; }
+}
+
+watch([caseId, selectedName], loadVector);
+watch(workflow, () => { searchText.value = ""; selectWorkflowCase(); });
+onMounted(async () => { await store.loadPointCloud(); selectWorkflowCase(); await loadVector(); await refreshAnnotationData(); await refreshSemanticModels(); });
+onBeforeUnmount(() => { stopPhotoPolling(); stopTrainingPolling(); stopInferencePolling(); });
+</script>
+
+<template>
+  <ArtifactState :loading="store.loading" :error="store.error" />
+  <a-tabs v-model:active-key="workflow" class="pointcloud-workflow-tabs">
+    <a-tab-pane key="photo" tab="照片三维重建" />
+    <a-tab-pane key="geometry" tab="点云几何处理" />
+    <a-tab-pane key="semantic" tab="点云语义分类" />
+    <a-tab-pane key="model" tab="标注、训练与模型应用" />
+  </a-tabs>
+  <section class="workspace-command pointcloud-command">
+    <div v-if="workflow === 'photo'"><h2>新建照片三维重建</h2><p>上传同一架次、同一相机的 JPG/JPEG 序列,在 CPU 上生成稠密点云、网格与纹理预览。</p></div>
+    <div v-else-if="workflow === 'geometry'"><h2>新建点云几何处理</h2><p>上传已有点云或重建导出物,生成 DSM、高出地物足迹、栅格和几何结果。</p></div>
+    <div v-else-if="workflow === 'semantic'"><h2>新建点云语义分类</h2><p>上传 LAS/LAZ/PLY/PCD/XYZ,输出地面、植被、构筑物、电线候选、杆塔候选和未知类别;每类均需人工核验。</p></div>
+    <div v-else><h2>标注、训练与模型应用</h2><p>从已有 RGB 点云标注真值,训练本机模型,并将训练模型应用到新的带 RGB 点云。</p></div>
+    <a-button v-if="workflow !== 'model'" type="primary" @click="showRunForm = !showRunForm"><PlayCircleOutlined />{{ showRunForm ? "收起运行表单" : "上传并运行" }}</a-button>
+  </section>
+  <section v-if="showRunForm && workflow !== 'model'" class="surface-section pointcloud-run-form">
+    <template v-if="workflow === 'photo'">
+      <a-alert type="info" show-icon message="单次选择 3 至 30 张同一架次、同一相机的 JPG/JPEG。原图按字节保存,后台依次执行稀疏 SfM、CPU 稠密点云、网格和纹理;完整任务通常需要几十分钟。有每张照片 GPS/RTK 时再打开下方先验。" />
+      <a-upload multiple accept=".jpg,.jpeg" :file-list="photoFiles.map((file) => ({ uid: file.name, name: file.name, status: 'done' as const }))" :before-upload="beforePhotoUpload" @remove="removePhotoFile"><a-button><UploadOutlined />选择重建照片</a-button></a-upload>
+      <a-switch v-model:checked="usePhotoPositionPriors" checked-children="GPS/RTK 先验" un-checked-children="通用配对" />
+    </template>
+    <template v-else>
+      <a-alert type="info" show-icon :message="workflow === 'semantic' ? '单次最多 2 份 PLY/PCD/XYZ/LAS/LAZ。大文件按二进制上传并保留原始字节;语义输出为可解释 CPU 规则候选,不是训练模型结论。' : '单次最多 2 份 PLY/PCD/XYZ/LAS/LAZ。源文件会按字节保存到独立运行目录,并输出 DSM、高出地物栅格和 GeoAI 足迹矢量。'" />
+      <a-upload multiple accept=".ply,.pcd,.xyz,.xyzn,.xyzrgb,.las,.laz" :file-list="files.map((file) => ({ uid: file.name, name: file.name, status: 'done' as const }))" :before-upload="beforeUpload" @remove="removeFile"><a-button><UploadOutlined />选择点云</a-button></a-upload>
+    </template>
+    <a-alert v-if="runError" type="error" show-icon :message="runError" />
+    <a-button v-if="workflow === 'photo'" type="primary" :loading="photoSubmitting" :disabled="photoFiles.length < 3" @click="submitPhotoReconstruction"><PlayCircleOutlined />开始照片重建</a-button>
+    <a-button v-else type="primary" :loading="running" :disabled="!files.length" @click="submitRun"><PlayCircleOutlined />{{ workflow === 'semantic' ? '开始语义分类' : '开始点云几何处理' }}</a-button>
+  </section>
+  <a-alert v-if="photoJob" class="pointcloud-photo-job" :type="photoJob.status === 'failed' ? 'error' : photoJob.status === 'complete' ? 'success' : 'info'" show-icon :message="photoJobMessage" />
+
+  <template v-if="currentCase && selectedCloud">
+    <a-row v-if="workflow === 'geometry'" :gutter="[18, 18]" class="pointcloud-workspace">
+      <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCases"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()">{{ item.label }}</a-list-item></template></a-list></section></a-col>
+      <a-col :xs="24" :xl="19"><section class="surface-section"><div class="section-toolbar"><a-select v-model:value="selectedName" :options="(currentCase.run.point_clouds ?? []).map((item) => ({ value: item.file, label: item.file }))" /><span>{{ selectedCloud.raster_width }} x {{ selectedCloud.raster_height }} 本地坐标栅格</span></div><div class="comparison-grid pointcloud-images"><figure><figcaption>DSM 与高出地物栅格</figcaption><a-image :src="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.preview_file}`)" /></figure><figure><figcaption>高出地物足迹矢量</figcaption><svg class="pointcloud-vector" :viewBox="vectorViewBox" preserveAspectRatio="xMidYMid meet"><path v-for="(path, index) in vectorPaths" :key="index" :d="path" /></svg></figure></div></section></a-col>
+    </a-row>
+    <section v-if="workflow === 'semantic' && selectedCloud.semantic_preview_point_cloud" class="result-band">
+      <a-row :gutter="[18, 18]" class="pointcloud-workspace">
+        <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>语义分类案例</h2><a-input-search v-model:value="searchText" placeholder="搜索分类案例" allow-clear /><a-list size="small" :data-source="filteredCases"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()">{{ item.label }}</a-list-item></template></a-list></section></a-col>
+        <a-col :xs="24" :xl="19"><section class="surface-section"><div class="section-heading"><div><h2>语义分类结果</h2><p>{{ selectedCloud.semantic_method }}。颜色:棕色地面、绿色植被、橙色构筑物、紫色杆塔候选、黄色电线候选、灰色未知。</p></div><a-tag color="gold">人工核验候选</a-tag></div><SparsePointCloudViewer :source="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.semantic_preview_point_cloud}`)" /><a-descriptions size="small" :column="{ xs: 1, sm: 2, lg: 3 }"><a-descriptions-item v-for="(count, key) in selectedCloud.semantic_class_counts" :key="key" :label="String(key)">{{ count.toLocaleString() }}</a-descriptions-item></a-descriptions></section></a-col>
+      </a-row>
+      <section class="surface-section result-band"><div class="section-heading"><div><h2>分类结果与下载</h2><p>规则分类用于人工复核,不是资产台账或巡检结论。</p></div></div><a-space wrap><a-button v-if="selectedCloud.semantic_preview_point_cloud" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.semantic_preview_point_cloud}`)" target="_blank"><DownloadOutlined />语义预览 PLY</a-button><a-button v-if="selectedCloud.semantic_classified_las" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.semantic_classified_las}`)" target="_blank"><DownloadOutlined />语义分类 LAS</a-button><a-button v-if="selectedCloud.semantic_summary_file" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.semantic_summary_file}`)" target="_blank"><FileOutlined />语义统计 CSV</a-button><a-button v-if="selectedCloud.semantic_vector_file" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.semantic_vector_file}`)" target="_blank"><FileOutlined />语义候选 GeoJSON</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" target="_blank"><FileOutlined />运行元数据</a-button></a-space></section>
+    </section>
+    <section v-if="workflow === 'model' && annotationSource" class="surface-section result-band">
+      <div class="section-heading"><div><h2>人工标注与监督训练</h2><p>仅保存你刷选确认的真值点;规则候选颜色不会写入训练标签。当前训练完全使用本机 CPU,迁移服务器后可原样改为 CUDA。</p></div><a-tag color="blue">本机 CPU</a-tag></div>
+      <a-space wrap class="annotation-source-row"><span>标注源</span><a-select v-model:value="annotationSourceId" :options="annotationSources.map((item) => ({ value: item.id, label: item.label }))" /></a-space>
+      <a-alert type="info" show-icon :message="`标注底图:${annotationSource.sourceKind}`" description="为保持浏览器可交互,当前显示的是从原始 LAS 按体素确定性抽取的 40 万个 RGB/XYZ 点,不是语义规则分类颜色,也不是把 1,047 万原始点全部装入浏览器。" />
+      <a-alert v-if="annotationNotice" :type="annotationNotice.type" show-icon :message="annotationNotice.message" />
+      <PointCloudAnnotationViewer :source="annotationSource.url" :disabled="annotationSaving" @save="saveAnnotation" />
+      <a-divider />
+      <div class="section-heading"><div><h3>已保存标注版本</h3><p>显示所有标注源的版本,可删除标错版本。至少两个类别、每类 500 个用户确认点后可启动训练;系统按 XY 空间块划分训练、验证和测试,避免相邻线路或塔体点泄漏到测试集。</p></div></div>
+      <a-list size="small" :data-source="annotations"><template #renderItem="{ item }"><a-list-item><a-space wrap><span>{{ item.id }}</span><span>{{ annotationSourceLabel(item.sourceId) }}</span><span>{{ item.labelCount.toLocaleString() }} 点</span><a-button size="small" type="primary" @click="startTraining(item.id)">本机 CPU 训练</a-button><a-popconfirm title="删除后不能恢复该标注版本,确认删除?" ok-text="删除" cancel-text="取消" @confirm="deleteAnnotation(item.id)"><a-tooltip title="删除标注版本"><a-button size="small" danger :loading="annotationDeletingId === item.id" aria-label="删除标注版本"><DeleteOutlined /></a-button></a-tooltip></a-popconfirm></a-space></a-list-item></template></a-list>
+      <a-alert v-if="trainingJob" :type="trainingJob.status === 'failed' ? 'error' : trainingJob.status === 'complete' ? 'success' : 'info'" show-icon :message="`训练任务:${trainingJob.status}`" :description="trainingJob.error || (trainingJob.preview ? `已生成预测预览:${trainingJob.preview}` : '后台训练中,可继续浏览案例。')" />
+      <a-divider />
+      <div class="section-heading"><div><h3>应用训练模型</h3><p>选择本机已完成的模型,上传一份新的带 RGB 点云,在本机 CPU 上生成预测候选。XYZ-only 输入会明确拒绝,不会伪造颜色特征。</p></div><a-tag color="blue">本机 CPU</a-tag></div>
+      <a-alert v-if="!semanticModels.length" type="warning" show-icon message="尚未发现可用训练模型。先完成并保留一次监督训练。" />
+      <a-space v-else wrap class="model-inference-controls">
+        <a-select v-model:value="selectedSemanticModelId" :options="semanticModelOptions" class="model-select" />
+        <a-upload accept=".ply,.pcd,.las,.laz" :file-list="inferenceFile ? [{ uid: inferenceFile.name, name: inferenceFile.name, status: 'done' as const }] : []" :before-upload="beforeInferenceUpload" @remove="removeInferenceUpload"><a-button><UploadOutlined />选择待预测点云</a-button></a-upload>
+        <a-button type="primary" :loading="inferenceSubmitting" :disabled="!selectedSemanticModelId || !inferenceFile" @click="applySemanticModel"><PlayCircleOutlined />应用模型</a-button>
+      </a-space>
+      <a-alert v-if="selectedSemanticModel" class="model-inference-model" type="info" show-icon :message="`测试 F1:${Object.entries(selectedSemanticModel.testF1).map(([key, score]) => `${key} ${score.toFixed(3)}`).join(';') || '该模型未记录可用测试指标'}`" description="测试指标仅来自该标注源的空间分块,不能代表新场景准确率;当前杆塔类别仍有较高误报风险,必须人工核验。" />
+      <a-alert v-if="inferenceJob" :type="inferenceJob.status === 'failed' ? 'error' : inferenceJob.status === 'complete' ? 'success' : 'info'" show-icon :message="`模型应用任务:${inferenceJob.status}`" :description="inferenceJob.error || (inferenceJob.status === 'complete' ? `已完成 ${inferenceJob.inputName} 的分类候选。` : '后台 CPU 推理中,可继续浏览案例。')" />
+      <section v-if="inferenceJob?.status === 'complete' && inferenceJob.preview" class="model-inference-result">
+        <SparsePointCloudViewer :source="artifactUrl(inferenceJob.preview)" />
+        <a-descriptions v-if="inferenceSummary" size="small" :column="{ xs: 1, sm: 2, lg: 3 }"><a-descriptions-item label="输入点数">{{ inferenceSummary.input_points?.toLocaleString() }}</a-descriptions-item><a-descriptions-item label="预览点数">{{ inferenceSummary.preview_points?.toLocaleString() }}</a-descriptions-item><a-descriptions-item v-for="(count, code) in inferenceSummary.class_counts" :key="String(code)" :label="String(code)">{{ count.toLocaleString() }}</a-descriptions-item></a-descriptions>
+        <a-space wrap><a-button type="link" :href="artifactUrl(inferenceJob.preview)" target="_blank"><DownloadOutlined />预测预览 PLY</a-button><a-button v-if="inferenceJob.classifiedLas" type="link" :href="artifactUrl(inferenceJob.classifiedLas)" target="_blank"><DownloadOutlined />分类 LAS</a-button><a-button v-if="inferenceJob.classCounts" type="link" :href="artifactUrl(inferenceJob.classCounts)" target="_blank"><FileOutlined />类别统计 CSV</a-button><a-button v-if="inferenceJob.summary" type="link" :href="artifactUrl(inferenceJob.summary)" target="_blank"><FileOutlined />预测摘要 JSON</a-button><a-button v-if="inferenceJob.metadata" type="link" :href="artifactUrl(inferenceJob.metadata)" target="_blank"><FileOutlined />运行元数据</a-button><a-button v-if="selectedSemanticModel" type="link" :href="artifactUrl(selectedSemanticModel.model)" target="_blank"><DownloadOutlined />模型权重</a-button></a-space>
+      </section>
+    </section>
+    <a-row v-if="workflow === 'geometry'" :gutter="[18, 18]" class="result-band"><a-col :xs="24" :xl="12"><section class="surface-section"><div class="section-heading"><div><h2>点云与几何结果</h2><p>坐标为点云本地坐标;不是经纬度或测绘精度结果。</p></div><a-tag color="orange">{{ selectedCloud.elevated_footprint_count }} 个足迹</a-tag></div><a-descriptions size="small" :column="{ xs: 1, sm: 2 }"><a-descriptions-item label="原始点">{{ selectedCloud.original_points }}</a-descriptions-item><a-descriptions-item label="降采样点">{{ selectedCloud.downsampled_points }}</a-descriptions-item><a-descriptions-item label="地面内点">{{ selectedCloud.ground_inliers }}</a-descriptions-item><a-descriptions-item label="高出点">{{ selectedCloud.elevated_points }} ({{ (selectedCloud.elevated_point_ratio * 100).toFixed(1) }}%)</a-descriptions-item><a-descriptions-item label="网格三角形">{{ selectedCloud.mesh_triangles }}</a-descriptions-item><a-descriptions-item label="矢量化">{{ selectedCloud.vectorizer }}</a-descriptions-item></a-descriptions></section></a-col><a-col :xs="24" :xl="12"><section class="surface-section"><h2>固定处理参数</h2><a-descriptions size="small" :column="1"><a-descriptions-item label="体素尺寸">{{ currentCase.run.thresholds.voxel_size_local_units }}</a-descriptions-item><a-descriptions-item label="地面 RANSAC 距离">{{ currentCase.run.thresholds.ground_plane_distance }}</a-descriptions-item><a-descriptions-item label="高出地物阈值">{{ currentCase.run.thresholds.elevated_height }}</a-descriptions-item><a-descriptions-item label="耗时">{{ selectedCloud.elapsed_seconds }} 秒</a-descriptions-item></a-descriptions></section></a-col></a-row>
+    <section v-if="workflow === 'geometry'" class="surface-section result-band"><div class="section-heading"><div><h2>几何处理下载</h2><p>{{ currentCase.note }}</p></div></div><a-descriptions size="small" :column="{ xs: 1, sm: 2, lg: 5 }"><a-descriptions-item label="能力边界">{{ currentCase.run.classification }}</a-descriptions-item><a-descriptions-item label="设备">{{ currentCase.run.device }}</a-descriptions-item><a-descriptions-item label="Open3D">{{ currentCase.run.versions.open3d }}</a-descriptions-item><a-descriptions-item label="GeoAI">{{ currentCase.run.versions['geoai-py'] }}</a-descriptions-item><a-descriptions-item label="处理点云">{{ currentCase.run.processed_point_clouds }}</a-descriptions-item></a-descriptions><a-space wrap><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.classified_point_cloud}`)" target="_blank"><DownloadOutlined />几何分类点云 PLY</a-button><a-button v-if="selectedCloud.mesh_file" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.mesh_file}`)" target="_blank"><FileOutlined />近似网格 PLY</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.dsm_file}`)" target="_blank"><FileImageOutlined />DSM GeoTIFF</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.vector_file}`)" target="_blank"><FileOutlined />高出地物 GeoJSON</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.summary_file}`)" target="_blank"><FileOutlined />汇总 CSV</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" target="_blank"><FileOutlined />运行元数据</a-button></a-space></section>
+  </template>
+  <template v-else-if="currentCase && densePhotoRun">
+    <a-row :gutter="[18, 18]" class="pointcloud-workspace">
+      <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCases"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()">{{ item.label }}</a-list-item></template></a-list></section></a-col>
+      <a-col :xs="24" :xl="19"><section class="surface-section"><div class="section-heading"><div><h2>CPU 稠密重建与纹理网格</h2><p>拖动旋转、滚轮缩放。模型为局部 SfM 坐标,未经过控制点测绘配准。</p></div><a-space><a-tag color="green">CPU MVS</a-tag><a-button type="primary" size="small" :loading="running" @click="classifyDenseResult"><PlayCircleOutlined />对此稠密点云分类</a-button></a-space></div><a-segmented v-if="densePhotoRun.geometry_preview_model_file" v-model:value="densePreviewMode" :options="[{ label: '可靠纹理', value: 'textured' }, { label: '点云颜色', value: 'point_colors', disabled: !densePhotoRun.point_color_preview_model_file }, { label: '完整几何', value: 'geometry' }]" /><TexturedMeshViewer :source="artifactUrl(`${currentCase.artifactRoot}/${densePreviewSource}`)" :surface="densePreviewMode" /></section></a-col>
+    </a-row>
+    <a-row :gutter="[18, 18]" class="result-band"><a-col :xs="24" :xl="12"><section class="surface-section"><h2>重建统计</h2><a-descriptions size="small" :column="{ xs: 1, sm: 2 }"><a-descriptions-item label="输入照片">{{ densePhotoRun.input_images }}</a-descriptions-item><a-descriptions-item label="注册照片">{{ densePhotoRun.registered_images }}</a-descriptions-item><a-descriptions-item label="稀疏点">{{ densePhotoRun.sparse_points.toLocaleString() }}</a-descriptions-item><a-descriptions-item label="稠密点">{{ densePhotoRun.dense_points.toLocaleString() }}</a-descriptions-item><a-descriptions-item label="原始网格顶点">{{ densePhotoRun.mesh_vertices.toLocaleString() }}</a-descriptions-item><a-descriptions-item label="原始网格面">{{ densePhotoRun.mesh_faces.toLocaleString() }}</a-descriptions-item><a-descriptions-item v-if="densePhotoRun.preview_mesh_faces" label="可靠纹理面">{{ densePhotoRun.preview_mesh_faces.toLocaleString() }} ({{ ((densePhotoRun.preview_mesh_face_ratio ?? 0) * 100).toFixed(1) }}%)</a-descriptions-item><a-descriptions-item v-if="densePhotoRun.point_color_preview_faces" label="点云颜色面">{{ densePhotoRun.point_color_preview_faces.toLocaleString() }}</a-descriptions-item><a-descriptions-item v-if="densePhotoRun.geometry_preview_faces" label="完整几何面">{{ densePhotoRun.geometry_preview_faces.toLocaleString() }}</a-descriptions-item><a-descriptions-item v-if="densePhotoRun.elapsed_seconds !== null" label="耗时">{{ densePhotoRun.elapsed_seconds }} 秒</a-descriptions-item></a-descriptions></section></a-col><a-col :xs="24" :xl="12"><section class="surface-section"><h2>结果边界</h2><p>可靠纹理只显示有一致照片证据的面,孔洞代表纹理覆盖不足。点云颜色将最近融合点的真实 RGB 投影到完整网格,显示连续表面但不等同逐面照片纹理。完整几何使用中性材质,不补造颜色或纹理;它用于核验几何连续性。</p></section></a-col></a-row>
+    <section class="surface-section result-band"><div class="section-heading"><div><h2>运行与下载</h2><p>{{ currentCase.note }}</p></div></div><a-space wrap><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${densePhotoRun.textured_model_file}`)" target="_blank"><DownloadOutlined />可靠纹理 GLB</a-button><a-button v-if="densePhotoRun.point_color_preview_model_file" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${densePhotoRun.point_color_preview_model_file}`)" target="_blank"><DownloadOutlined />点云颜色 GLB</a-button><a-button v-if="densePhotoRun.geometry_preview_model_file" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${densePhotoRun.geometry_preview_model_file}`)" target="_blank"><DownloadOutlined />完整几何 GLB</a-button><a-button v-if="densePhotoRun.texture_preview_filter_report" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${densePhotoRun.texture_preview_filter_report}`)" target="_blank"><FileOutlined />纹理过滤报告</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${densePhotoRun.dense_point_cloud_file}`)" target="_blank"><FileOutlined />稠密点云 PLY</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${densePhotoRun.mesh_file}`)" target="_blank"><FileOutlined />原始网格 PLY</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${densePhotoRun.texture_file}`)" target="_blank"><FileImageOutlined />纹理图</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" target="_blank"><FileOutlined />运行元数据</a-button></a-space></section>
+  </template>
+  <template v-else-if="currentCase && photoRun">
+    <a-row :gutter="[18, 18]" class="pointcloud-workspace">
+      <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCases"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()">{{ item.label }}</a-list-item></template></a-list></section></a-col>
+      <a-col :xs="24" :xl="19"><section class="surface-section"><div class="section-heading"><div><h2>影像稀疏重建点云</h2><p>顶点颜色点云。鼠标拖动旋转,滚轮缩放;坐标没有绝对尺度或方向。</p></div><a-tag color="blue">CPU SfM</a-tag></div><SparsePointCloudViewer :source="artifactUrl(`${currentCase.artifactRoot}/${photoRun.point_cloud_file}`)" /></section></a-col>
+    </a-row>
+    <a-row :gutter="[18, 18]" class="result-band"><a-col :xs="24" :xl="12"><section class="surface-section"><h2>重建统计</h2><a-descriptions size="small" :column="{ xs: 1, sm: 2 }"><a-descriptions-item label="输入照片">{{ photoRun.input_images }}</a-descriptions-item><a-descriptions-item label="注册照片">{{ photoRun.registered_images }}</a-descriptions-item><a-descriptions-item label="稀疏点">{{ photoRun.sparse_points }}</a-descriptions-item><a-descriptions-item label="平均重投影误差">{{ photoRun.mean_reprojection_error_pixels }} px</a-descriptions-item><a-descriptions-item label="相机组">{{ photoRun.camera_count }}</a-descriptions-item><a-descriptions-item label="耗时">{{ photoRun.elapsed_seconds }} 秒</a-descriptions-item></a-descriptions></section></a-col><a-col :xs="24" :xl="12"><section class="surface-section"><h2>坐标与限制</h2><p>本次仅完成稀疏 SfM。当前机器无 CUDA,不能运行 pycolmap 的稠密 PatchMatch;GPS 保留在输入清单中,但未用于宣称测绘级地理配准。</p></section></a-col></a-row>
+    <section class="surface-section result-band"><div class="section-heading"><div><h2>运行与下载</h2><p>{{ currentCase.note }}</p></div></div><a-space wrap><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${photoRun.point_cloud_file}`)" target="_blank"><DownloadOutlined />稀疏点云 PLY</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${photoRun.camera_pose_file}`)" target="_blank"><FileOutlined />相机位姿 CSV</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${photoRun.input_manifest_file}`)" target="_blank"><FileOutlined />输入 GPS 清单</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" target="_blank"><FileOutlined />运行元数据</a-button></a-space></section>
+  </template>
+</template>
+
+<style scoped>
+.pointcloud-run-form { display: grid; gap: 16px; margin-bottom: 24px; }
+.pointcloud-workflow-tabs { margin-bottom: 16px; }
+.pointcloud-photo-job { margin-bottom: 24px; }
+.pointcloud-workspace, .result-band { margin-bottom: 24px; }
+.pointcloud-images figure { min-width: 0; }
+.pointcloud-images :deep(.ant-image), .pointcloud-images :deep(img) { width: 100%; height: 360px; object-fit: contain; background: #171e1a; }
+.pointcloud-vector { width: 100%; height: 360px; border: 1px solid #d9d9d9; background: #f7f8f9; }
+.pointcloud-vector path { fill: rgba(230, 90, 45, 0.24); stroke: #bf4c20; stroke-width: 1.5; vector-effect: non-scaling-stroke; }
+.model-inference-controls { display: flex; margin-bottom: 12px; }
+.model-select { min-width: min(100%, 440px); }
+.model-inference-model { margin-bottom: 12px; }
+.model-inference-result { display: grid; gap: 16px; margin-top: 16px; }
+@media (max-width: 1199px) { .pointcloud-images :deep(img), .pointcloud-vector { height: 300px; } }
+</style>
diff --git a/apps/workbench-console/src/components/RiskRulePanel.vue b/apps/workbench-console/src/components/RiskRulePanel.vue
new file mode 100644
index 0000000..b3ba044
--- /dev/null
+++ b/apps/workbench-console/src/components/RiskRulePanel.vue
@@ -0,0 +1,110 @@
+<script setup lang="ts">
+import { computed, onMounted, ref } from "vue";
+import { FileImageOutlined, FileOutlined, PlayCircleOutlined, UploadOutlined } from "@ant-design/icons-vue";
+
+import { artifactUrl, createRiskRuleRun, readFileAsPayload, type RiskRuleCase, type RiskScoreFeature } from "@/api/artifacts";
+import ArtifactState from "@/components/ArtifactState.vue";
+import { useArtifactStore } from "@/stores/artifacts";
+
+const store = useArtifactStore();
+const caseId = ref("");
+const observations = ref<File | null>(null);
+const zones = ref<File | null>(null);
+const rules = ref<File | null>(null);
+const showRunForm = ref(false);
+const running = ref(false);
+const runError = ref<string | null>(null);
+const searchText = ref("");
+const currentCase = computed<RiskRuleCase | undefined>(() => store.riskRuleCases[caseId.value] ?? Object.values(store.riskRuleCases)[0]);
+const caseOptions = computed(() => Object.values(store.riskRuleCases).map((item) => ({ value: item.id, label: item.label })));
+const filteredCaseOptions = computed(() => caseOptions.value.filter((item) => item.label.toLowerCase().includes(searchText.value.trim().toLowerCase())));
+const levelColor = (level: string) => ({ low: "green", medium: "gold", high: "orange", critical: "red" }[level] ?? "default");
+const vectorViewBox = computed(() => {
+  const bounds = currentCase.value?.run.raster.bounds ?? [0, 0, 1, 1];
+  const [minX, minY, maxX, maxY] = bounds;
+  return `${minX} ${-maxY} ${Math.max(maxX - minX, 1)} ${Math.max(maxY - minY, 1)}`;
+});
+const points = computed(() => (currentCase.value?.features ?? []).filter((feature): feature is RiskScoreFeature => feature.geometry.type === "Point" && Array.isArray(feature.geometry.coordinates)));
+
+function beforeUpload(kind: "observations" | "zones" | "rules") {
+  return (file: File) => {
+    if (kind === "observations") observations.value = file;
+    if (kind === "zones") zones.value = file;
+    if (kind === "rules") rules.value = file;
+    return false;
+  };
+}
+function clearFile(kind: "observations" | "zones" | "rules") {
+  if (kind === "observations") observations.value = null;
+  if (kind === "zones") zones.value = null;
+  if (kind === "rules") rules.value = null;
+}
+async function submitRun() {
+  if (!observations.value || !zones.value || !rules.value) {
+    runError.value = "请同时选择观测对象 GeoJSON、风险分区 GeoJSON 和规则 JSON。";
+    return;
+  }
+  running.value = true;
+  runError.value = null;
+  try {
+    const files = await Promise.all([readFileAsPayload(observations.value), readFileAsPayload(zones.value), readFileAsPayload(rules.value)]);
+    const { run } = await createRiskRuleRun({ observations: files[0], zones: files[1], rules: files[2] });
+    await store.loadRiskRule(true);
+    caseId.value = run.id;
+    observations.value = null; zones.value = null; rules.value = null; showRunForm.value = false;
+  } catch (error) {
+    runError.value = error instanceof Error ? error.message : "空间规则与风险评分运行失败。";
+  } finally {
+    running.value = false;
+  }
+}
+
+onMounted(async () => {
+  await store.loadRiskRule();
+  caseId.value = Object.keys(store.riskRuleCases)[0] ?? "";
+});
+</script>
+
+<template>
+  <ArtifactState :loading="store.loading" :error="store.error" />
+  <section class="workspace-command risk-command">
+    <div><h2>新建空间规则与风险评分运行</h2><p>上传带 CRS 的观测对象、风险分区和规则配置,在本机 CPU 上生成可审计的评分结果。</p></div>
+    <a-button type="primary" @click="showRunForm = !showRunForm"><PlayCircleOutlined />{{ showRunForm ? "收起运行表单" : "上传并运行" }}</a-button>
+  </section>
+
+  <section v-if="showRunForm" class="surface-section risk-run-form">
+    <a-alert type="info" show-icon message="需要 3 个文件:观测对象 GeoJSON(object_id、confidence、投影 CRS)、风险分区 GeoJSON(zone_type)和规则 JSON。" />
+    <div class="risk-upload-grid">
+      <div><label>观测对象 GeoJSON</label><a-upload accept=".geojson" :max-count="1" :file-list="observations ? [{ uid: observations.name, name: observations.name, status: 'done' as const }] : []" :before-upload="beforeUpload('observations')" @remove="clearFile('observations')"><a-button><UploadOutlined />选择文件</a-button></a-upload></div>
+      <div><label>风险分区 GeoJSON</label><a-upload accept=".geojson" :max-count="1" :file-list="zones ? [{ uid: zones.name, name: zones.name, status: 'done' as const }] : []" :before-upload="beforeUpload('zones')" @remove="clearFile('zones')"><a-button><UploadOutlined />选择文件</a-button></a-upload></div>
+      <div><label>规则 JSON</label><a-upload accept=".json" :max-count="1" :file-list="rules ? [{ uid: rules.name, name: rules.name, status: 'done' as const }] : []" :before-upload="beforeUpload('rules')" @remove="clearFile('rules')"><a-button><UploadOutlined />选择文件</a-button></a-upload></div>
+    </div>
+    <a-alert v-if="runError" type="error" show-icon :message="runError" />
+    <a-button type="primary" :loading="running" :disabled="!observations || !zones || !rules" @click="submitRun"><PlayCircleOutlined />开始评分</a-button>
+  </section>
+
+  <template v-if="currentCase">
+    <a-row :gutter="[18, 18]" class="risk-workspace">
+      <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCaseOptions"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value">{{ item.label }}</a-list-item></template></a-list></section></a-col>
+      <a-col :xs="24" :xl="19"><section class="surface-section"><div class="section-toolbar"><div><h2>栅格与矢量结果</h2><span class="toolbar-note">{{ currentCase.run.crs }} · {{ currentCase.run.raster.width }} x {{ currentCase.run.raster.height }} · {{ currentCase.run.parameters.raster_resolution }} m 像元</span></div></div><div class="comparison-grid risk-images"><figure><figcaption>风险评分栅格</figcaption><a-image :src="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.risk_preview}`)" /></figure><figure><figcaption>对象评分矢量</figcaption><svg class="risk-vector" :viewBox="vectorViewBox" preserveAspectRatio="xMidYMid meet"><g transform="scale(1,-1)"><circle v-for="feature in points" :key="feature.properties.object_id" :cx="feature.geometry.coordinates[0]" :cy="feature.geometry.coordinates[1]" :r="Math.max(currentCase.run.parameters.raster_resolution * 1.25, 6)" :class="`risk-${feature.properties.risk_level}`"><title>{{ feature.properties.object_id }}: {{ feature.properties.risk_score }}</title></circle></g></svg></figure></div></section></a-col>
+    </a-row>
+
+    <a-row :gutter="[18, 18]" class="result-band"><a-col :xs="24" :xl="10"><section class="surface-section"><div class="section-heading"><div><h2>风险汇总</h2><p>规则分数为可复核的候选优先级,不是自动处置结论。</p></div><a-tag color="red">最高 {{ currentCase.run.summary.maximum_score }} 分</a-tag></div><a-descriptions size="small" :column="2"><a-descriptions-item label="评分对象">{{ currentCase.run.summary.object_count }}</a-descriptions-item><a-descriptions-item label="平均分">{{ currentCase.run.summary.mean_score }}</a-descriptions-item><a-descriptions-item label="低风险">{{ currentCase.run.summary.risk_level_counts.low }}</a-descriptions-item><a-descriptions-item label="中风险">{{ currentCase.run.summary.risk_level_counts.medium }}</a-descriptions-item><a-descriptions-item label="高风险">{{ currentCase.run.summary.risk_level_counts.high }}</a-descriptions-item><a-descriptions-item label="严重风险">{{ currentCase.run.summary.risk_level_counts.critical }}</a-descriptions-item></a-descriptions></section></a-col><a-col :xs="24" :xl="14"><section class="surface-section"><h2>对象评分明细</h2><a-table :data-source="currentCase.features" :pagination="{ pageSize: 6 }" row-key="properties.object_id" size="small" :scroll="{ x: 760 }"><a-table-column title="对象" key="object"><template #default="{ record }">{{ record.properties.object_id }}</template></a-table-column><a-table-column title="分数" key="score" align="right"><template #default="{ record }">{{ record.properties.risk_score }}</template></a-table-column><a-table-column title="等级" key="level"><template #default="{ record }"><a-tag :color="levelColor(record.properties.risk_level)">{{ record.properties.risk_level }}</a-tag></template></a-table-column><a-table-column title="命中规则" key="rules"><template #default="{ record }">{{ record.properties.hit_rule_ids.join(" · ") || "无" }}</template></a-table-column><a-table-column title="建议" key="suggestions"><template #default="{ record }">{{ record.properties.suggestions.join(";") || "常规监测" }}</template></a-table-column></a-table></section></a-col></a-row>
+
+    <section class="surface-section result-band"><div class="section-heading"><div><h2>运行与下载</h2><p>{{ currentCase.note }}</p></div></div><a-descriptions size="small" :column="{ xs: 1, sm: 2, lg: 5 }"><a-descriptions-item label="能力边界">{{ currentCase.run.classification }}</a-descriptions-item><a-descriptions-item label="设备">{{ currentCase.run.device }}</a-descriptions-item><a-descriptions-item label="处理耗时">{{ currentCase.run.elapsed_seconds }} 秒</a-descriptions-item><a-descriptions-item label="规则数">{{ currentCase.run.parameters.rule_count }}</a-descriptions-item><a-descriptions-item label="分数上限">{{ currentCase.run.parameters.score_cap }}</a-descriptions-item></a-descriptions><a-space wrap><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.risk_raster}`)" target="_blank"><FileImageOutlined />风险 GeoTIFF</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.risk_vector}`)" target="_blank"><FileOutlined />评分 GeoJSON</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.risk_scores_csv}`)" target="_blank"><FileOutlined />评分 CSV</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.summary}`)" target="_blank"><FileOutlined />汇总 JSON</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" target="_blank"><FileOutlined />运行元数据</a-button></a-space></section>
+  </template>
+</template>
+
+<style scoped>
+.risk-run-form { display: grid; gap: 16px; margin-bottom: 24px; }
+.risk-upload-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
+.risk-upload-grid label { display: block; margin-bottom: 7px; color: #5d6a62; font-size: 12px; }
+.risk-workspace, .result-band { margin-bottom: 24px; }
+.risk-images figure { min-width: 0; }
+.risk-images :deep(.ant-image), .risk-images :deep(img) { width: 100%; height: 360px; object-fit: contain; background: #171e1a; }
+.risk-vector { width: 100%; height: 360px; border: 1px solid #d9e0da; background: #f7f8f7; }
+.risk-vector circle { stroke: #ffffff; stroke-width: 1.5; vector-effect: non-scaling-stroke; }
+.risk-low { fill: #3f8600; }.risk-medium { fill: #d89614; }.risk-high { fill: #d46b08; }.risk-critical { fill: #cf1322; }
+@media (max-width: 1199px) { .risk-images :deep(img), .risk-vector { height: 300px; } }
+@media (max-width: 640px) { .risk-upload-grid { grid-template-columns: 1fr; } }
+</style>
diff --git a/apps/workbench-console/src/components/SparsePointCloudViewer.vue b/apps/workbench-console/src/components/SparsePointCloudViewer.vue
new file mode 100644
index 0000000..cbd92f0
--- /dev/null
+++ b/apps/workbench-console/src/components/SparsePointCloudViewer.vue
@@ -0,0 +1,241 @@
+<script setup lang="ts">
+import { onBeforeUnmount, onMounted, ref, watch } from "vue";
+import { BorderTopOutlined, ReloadOutlined } from "@ant-design/icons-vue";
+import * as THREE from "three";
+import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
+
+const props = defineProps<{ source: string }>();
+
+type PropertyReader = { name: string; bytes: number; read: (view: DataView, offset: number) => number };
+
+const host = ref<HTMLDivElement | null>(null);
+const loading = ref(true);
+const error = ref<string | null>(null);
+const pointCount = ref(0);
+const pointSize = ref(2.4);
+
+let renderer: THREE.WebGLRenderer | null = null;
+let scene: THREE.Scene | null = null;
+let camera: THREE.PerspectiveCamera | null = null;
+let controls: OrbitControls | null = null;
+let points: THREE.Points<THREE.BufferGeometry, THREE.PointsMaterial> | null = null;
+let resizeObserver: ResizeObserver | null = null;
+let animationFrame = 0;
+let requestId = 0;
+
+const readers: Record<string, Omit<PropertyReader, "name">> = {
+  char: { bytes: 1, read: (view, offset) => view.getInt8(offset) },
+  int8: { bytes: 1, read: (view, offset) => view.getInt8(offset) },
+  uchar: { bytes: 1, read: (view, offset) => view.getUint8(offset) },
+  uint8: { bytes: 1, read: (view, offset) => view.getUint8(offset) },
+  short: { bytes: 2, read: (view, offset) => view.getInt16(offset, true) },
+  int16: { bytes: 2, read: (view, offset) => view.getInt16(offset, true) },
+  ushort: { bytes: 2, read: (view, offset) => view.getUint16(offset, true) },
+  uint16: { bytes: 2, read: (view, offset) => view.getUint16(offset, true) },
+  int: { bytes: 4, read: (view, offset) => view.getInt32(offset, true) },
+  int32: { bytes: 4, read: (view, offset) => view.getInt32(offset, true) },
+  uint: { bytes: 4, read: (view, offset) => view.getUint32(offset, true) },
+  uint32: { bytes: 4, read: (view, offset) => view.getUint32(offset, true) },
+  float: { bytes: 4, read: (view, offset) => view.getFloat32(offset, true) },
+  float32: { bytes: 4, read: (view, offset) => view.getFloat32(offset, true) },
+  double: { bytes: 8, read: (view, offset) => view.getFloat64(offset, true) },
+  float64: { bytes: 8, read: (view, offset) => view.getFloat64(offset, true) },
+};
+
+function headerEnd(bytes: Uint8Array): number {
+  const marker = [101, 110, 100, 95, 104, 101, 97, 100, 101, 114]; // end_header
+  for (let start = 0; start <= bytes.length - marker.length; start += 1) {
+    if (marker.every((value, index) => bytes[start + index] === value)) {
+      let end = start + marker.length;
+      if (bytes[end] === 13) end += 1;
+      if (bytes[end] === 10) return end + 1;
+    }
+  }
+  throw new Error("PLY 文件缺少 end_header。");
+}
+
+function parsePly(buffer: ArrayBuffer): { positions: Float32Array; colors: Float32Array } {
+  const bytes = new Uint8Array(buffer, 0, Math.min(buffer.byteLength, 64 * 1024));
+  const end = headerEnd(bytes);
+  const header = new TextDecoder("ascii").decode(new Uint8Array(buffer, 0, end));
+  const lines = header.split(/\r?\n/);
+  if (!lines.some((line) => line.trim() === "format binary_little_endian 1.0")) {
+    throw new Error("当前仅支持 binary_little_endian PLY。");
+  }
+  const vertexLine = lines.find((line) => line.startsWith("element vertex "));
+  const count = Number(vertexLine?.split(/\s+/)[2]);
+  if (!Number.isSafeInteger(count) || count < 1) throw new Error("PLY 顶点数量无效。");
+  const properties: PropertyReader[] = [];
+  let inVertex = false;
+  for (const line of lines) {
+    if (line.startsWith("element ")) {
+      inVertex = line.startsWith("element vertex ");
+      continue;
+    }
+    if (!inVertex || !line.startsWith("property ")) continue;
+    const [, type, name] = line.trim().split(/\s+/);
+    const reader = readers[type];
+    if (!reader || !name) throw new Error(`不支持的顶点属性类型:${type ?? "未知"}。`);
+    properties.push({ name, ...reader });
+  }
+  const stride = properties.reduce((total, property) => total + property.bytes, 0);
+  if (end + stride * count > buffer.byteLength) throw new Error("PLY 顶点数据不完整。");
+  const offsets = Object.fromEntries(properties.reduce<[string, number][]>((items, property) => {
+    const offset = items.length ? items[items.length - 1][1] + properties[items.length - 1].bytes : 0;
+    items.push([property.name, offset]);
+    return items;
+  }, []));
+  const required = ["x", "y", "z"];
+  if (required.some((name) => offsets[name] === undefined)) throw new Error("PLY 缺少 x/y/z 顶点坐标。");
+  const propertyByName = Object.fromEntries(properties.map((property) => [property.name, property]));
+  const view = new DataView(buffer, end);
+  const positions = new Float32Array(count * 3);
+  const colors = new Float32Array(count * 3);
+  const hasColor = ["red", "green", "blue"].every((name) => offsets[name] !== undefined);
+  for (let index = 0; index < count; index += 1) {
+    const base = index * stride;
+    for (const [axis, offset] of [["x", 0], ["y", 1], ["z", 2]] as const) {
+      positions[index * 3 + offset] = propertyByName[axis].read(view, base + offsets[axis]);
+    }
+    for (const [channel, offset] of [["red", 0], ["green", 1], ["blue", 2]] as const) {
+      colors[index * 3 + offset] = hasColor ? propertyByName[channel].read(view, base + offsets[channel]) / 255 : 0.72;
+    }
+  }
+  return { positions, colors };
+}
+
+function resize() {
+  if (!host.value || !renderer || !camera) return;
+  const width = Math.max(host.value.clientWidth, 1);
+  const height = Math.max(host.value.clientHeight, 1);
+  renderer.setSize(width, height, false);
+  camera.aspect = width / height;
+  camera.updateProjectionMatrix();
+}
+
+function frame() {
+  if (renderer && scene && camera && controls) {
+    controls.update();
+    renderer.render(scene, camera);
+  }
+  animationFrame = requestAnimationFrame(frame);
+}
+
+function resetView() {
+  if (!camera || !controls || !points) return;
+  const sphere = new THREE.Sphere();
+  points.geometry.computeBoundingSphere();
+  if (!points.geometry.boundingSphere) return;
+  sphere.copy(points.geometry.boundingSphere);
+  const radius = Math.max(sphere.radius, 0.01);
+  controls.target.copy(sphere.center);
+  camera.position.copy(sphere.center).add(new THREE.Vector3(radius * 1.25, radius * 0.85, radius * 1.65));
+  camera.near = radius / 100;
+  camera.far = radius * 100;
+  camera.updateProjectionMatrix();
+  controls.update();
+}
+
+function topDownView() {
+  if (!camera || !controls || !points) return;
+  points.geometry.computeBoundingSphere();
+  if (!points.geometry.boundingSphere) return;
+  const sphere = points.geometry.boundingSphere;
+  const radius = Math.max(sphere.radius, 0.01);
+  const distance = Math.max(camera.position.distanceTo(controls.target), radius * 1.5);
+  controls.target.copy(sphere.center);
+  camera.up.set(0, 1, 0);
+  camera.position.copy(sphere.center).add(new THREE.Vector3(0, 0, distance));
+  camera.near = radius / 100;
+  camera.far = radius * 100;
+  camera.updateProjectionMatrix();
+  controls.update();
+}
+
+function applyPointSize() {
+  if (points) points.material.size = pointSize.value;
+}
+
+async function loadSource(source: string) {
+  const activeId = ++requestId;
+  loading.value = true;
+  error.value = null;
+  try {
+    const response = await fetch(source, { cache: "no-store" });
+    if (!response.ok) throw new Error(`点云读取失败 (${response.status})。`);
+    const parsed = parsePly(await response.arrayBuffer());
+    if (activeId !== requestId || !scene) return;
+    points?.geometry.dispose();
+    points?.material.dispose();
+    const geometry = new THREE.BufferGeometry();
+    geometry.setAttribute("position", new THREE.BufferAttribute(parsed.positions, 3));
+    geometry.setAttribute("color", new THREE.BufferAttribute(parsed.colors, 3));
+    const material = new THREE.PointsMaterial({ size: pointSize.value, vertexColors: true, sizeAttenuation: false });
+    points = new THREE.Points(geometry, material);
+    scene.add(points);
+    pointCount.value = parsed.positions.length / 3;
+    resetView();
+  } catch (caught) {
+    if (activeId === requestId) error.value = caught instanceof Error ? caught.message : "点云无法读取。";
+  } finally {
+    if (activeId === requestId) loading.value = false;
+  }
+}
+
+onMounted(() => {
+  if (!host.value) return;
+  scene = new THREE.Scene();
+  scene.background = new THREE.Color("#101821");
+  camera = new THREE.PerspectiveCamera(42, 1, 0.01, 1_000);
+  renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" });
+  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+  host.value.appendChild(renderer.domElement);
+  controls = new OrbitControls(camera, renderer.domElement);
+  controls.enableDamping = true;
+  controls.dampingFactor = 0.08;
+  controls.screenSpacePanning = true;
+  resizeObserver = new ResizeObserver(resize);
+  resizeObserver.observe(host.value);
+  resize();
+  frame();
+  void loadSource(props.source);
+});
+
+watch(() => props.source, (source) => { void loadSource(source); });
+watch(pointSize, applyPointSize);
+
+onBeforeUnmount(() => {
+  cancelAnimationFrame(animationFrame);
+  resizeObserver?.disconnect();
+  controls?.dispose();
+  points?.geometry.dispose();
+  points?.material.dispose();
+  renderer?.dispose();
+  renderer?.domElement.remove();
+});
+</script>
+
+<template>
+  <div class="sparse-point-viewer">
+    <div ref="host" class="canvas-host" aria-label="稀疏点云交互视图" />
+    <div class="viewer-toolbar">
+      <span>{{ pointCount.toLocaleString() }} 点</span>
+      <label>点大小 <a-slider v-model:value="pointSize" :min="1" :max="8" :step="0.2" /></label>
+      <a-tooltip title="俯视模型(沿本地 Z 轴)"><a-button type="text" aria-label="俯视模型" @click="topDownView"><BorderTopOutlined /></a-button></a-tooltip>
+      <a-tooltip title="复位视角"><a-button type="text" aria-label="复位视角" @click="resetView"><ReloadOutlined /></a-button></a-tooltip>
+    </div>
+    <div v-if="loading || error" class="viewer-state"><a-spin v-if="loading" /><span v-else>{{ error }}</span></div>
+  </div>
+</template>
+
+<style scoped>
+.sparse-point-viewer { position: relative; height: 560px; overflow: hidden; background: #101821; border: 1px solid #273543; }
+.canvas-host { width: 100%; height: 100%; }
+.canvas-host :deep(canvas) { display: block; width: 100%; height: 100%; }
+.viewer-toolbar { position: absolute; right: 12px; bottom: 12px; display: flex; align-items: center; gap: 12px; padding: 8px 10px; color: #e8f1f5; background: rgba(12, 20, 29, 0.84); font-size: 13px; }
+.viewer-toolbar label { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
+.viewer-toolbar :deep(.ant-slider) { width: 100px; margin: 0; }
+.viewer-toolbar :deep(.ant-btn) { color: #e8f1f5; }
+.viewer-state { position: absolute; inset: 0; display: grid; place-items: center; color: #e8f1f5; background: rgba(12, 20, 29, 0.65); }
+@media (max-width: 767px) { .sparse-point-viewer { height: 420px; } .viewer-toolbar { left: 8px; right: 8px; bottom: 8px; gap: 8px; } .viewer-toolbar :deep(.ant-slider) { width: min(92px, 24vw); } }
+</style>
diff --git a/apps/workbench-console/src/components/TexturedMeshViewer.vue b/apps/workbench-console/src/components/TexturedMeshViewer.vue
new file mode 100644
index 0000000..968dae8
--- /dev/null
+++ b/apps/workbench-console/src/components/TexturedMeshViewer.vue
@@ -0,0 +1,199 @@
+<script setup lang="ts">
+import { onBeforeUnmount, onMounted, ref, watch } from "vue";
+import { BorderTopOutlined, ReloadOutlined } from "@ant-design/icons-vue";
+import * as THREE from "three";
+import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
+import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
+
+const props = withDefaults(defineProps<{ source: string; surface: "textured" | "geometry" | "point_colors" }>(), { surface: "textured" });
+
+const host = ref<HTMLDivElement | null>(null);
+const loading = ref(true);
+const error = ref<string | null>(null);
+const triangleCount = ref(0);
+const wireframe = ref(false);
+
+let renderer: THREE.WebGLRenderer | null = null;
+let scene: THREE.Scene | null = null;
+let camera: THREE.PerspectiveCamera | null = null;
+let controls: OrbitControls | null = null;
+let model: THREE.Group | null = null;
+let resizeObserver: ResizeObserver | null = null;
+let animationFrame = 0;
+let requestId = 0;
+
+function resize() {
+  if (!host.value || !renderer || !camera) return;
+  const width = Math.max(host.value.clientWidth, 1);
+  const height = Math.max(host.value.clientHeight, 1);
+  renderer.setSize(width, height, false);
+  camera.aspect = width / height;
+  camera.updateProjectionMatrix();
+}
+
+function frame() {
+  if (renderer && scene && camera && controls) {
+    controls.update();
+    renderer.render(scene, camera);
+  }
+  animationFrame = requestAnimationFrame(frame);
+}
+
+function resetView() {
+  if (!camera || !controls || !model) return;
+  const bounds = new THREE.Box3().setFromObject(model);
+  const sphere = bounds.getBoundingSphere(new THREE.Sphere());
+  const radius = Math.max(sphere.radius, 0.01);
+  controls.target.copy(sphere.center);
+  camera.position.copy(sphere.center).add(new THREE.Vector3(radius * 1.2, radius * 0.8, radius * 1.65));
+  camera.near = radius / 100;
+  camera.far = radius * 100;
+  camera.updateProjectionMatrix();
+  controls.update();
+}
+
+function topDownView() {
+  if (!camera || !controls || !model) return;
+  const bounds = new THREE.Box3().setFromObject(model);
+  const sphere = bounds.getBoundingSphere(new THREE.Sphere());
+  const radius = Math.max(sphere.radius, 0.01);
+  const distance = Math.max(camera.position.distanceTo(controls.target), radius * 1.5);
+  controls.target.copy(sphere.center);
+  camera.up.set(0, 1, 0);
+  camera.position.copy(sphere.center).add(new THREE.Vector3(0, 0, distance));
+  camera.near = radius / 100;
+  camera.far = radius * 100;
+  camera.updateProjectionMatrix();
+  controls.update();
+}
+
+function disposeModel() {
+  if (!model || !scene) return;
+  model.traverse((node) => {
+    if (!(node instanceof THREE.Mesh)) return;
+    node.geometry.dispose();
+    const materials = Array.isArray(node.material) ? node.material : [node.material];
+    materials.forEach((material) => {
+      Object.values(material).forEach((value) => {
+        if (value instanceof THREE.Texture) value.dispose();
+      });
+      material.dispose();
+    });
+  });
+  scene.remove(model);
+  model = null;
+}
+
+function applyWireframe() {
+  model?.traverse((node) => {
+    if (!(node instanceof THREE.Mesh)) return;
+    const materials = Array.isArray(node.material) ? node.material : [node.material];
+    materials.forEach((material) => { material.wireframe = wireframe.value; material.needsUpdate = true; });
+  });
+}
+
+function preparePreviewMaterials(group: THREE.Group) {
+  group.traverse((node) => {
+    if (!(node instanceof THREE.Mesh)) return;
+    const materials = Array.isArray(node.material) ? node.material : [node.material];
+    const previewMaterials = materials.map((material) => {
+      const source = material as THREE.MeshStandardMaterial;
+      return new THREE.MeshBasicMaterial({
+        map: props.surface === "textured" ? source.map ?? null : null,
+        color: props.surface === "textured" || props.surface === "point_colors" ? 0xffffff : "#9ba5a8",
+        vertexColors: props.surface === "point_colors",
+        side: THREE.DoubleSide,
+        transparent: source.transparent,
+        opacity: source.opacity,
+      });
+    });
+    node.material = Array.isArray(node.material) ? previewMaterials : previewMaterials[0];
+    materials.forEach((material) => material.dispose());
+  });
+}
+
+async function loadSource(source: string) {
+  const activeId = ++requestId;
+  loading.value = true;
+  error.value = null;
+  try {
+    const gltf = await new GLTFLoader().loadAsync(source);
+    if (activeId !== requestId || !scene) return;
+    disposeModel();
+    model = gltf.scene;
+    preparePreviewMaterials(model);
+    triangleCount.value = 0;
+    model.traverse((node) => {
+      if (node instanceof THREE.Mesh) triangleCount.value += node.geometry.index ? node.geometry.index.count / 3 : node.geometry.attributes.position.count / 3;
+    });
+    scene.add(model);
+    applyWireframe();
+    resetView();
+  } catch (caught) {
+    if (activeId === requestId) error.value = caught instanceof Error ? caught.message : "纹理模型无法读取。";
+  } finally {
+    if (activeId === requestId) loading.value = false;
+  }
+}
+
+onMounted(() => {
+  if (!host.value) return;
+  scene = new THREE.Scene();
+  scene.background = new THREE.Color("#101821");
+  scene.add(new THREE.HemisphereLight("#dce9ff", "#273137", 2.1));
+  const keyLight = new THREE.DirectionalLight("#ffffff", 2.4);
+  keyLight.position.set(4, 6, 5);
+  scene.add(keyLight);
+  camera = new THREE.PerspectiveCamera(42, 1, 0.01, 1_000);
+  renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" });
+  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+  renderer.outputColorSpace = THREE.SRGBColorSpace;
+  host.value.appendChild(renderer.domElement);
+  controls = new OrbitControls(camera, renderer.domElement);
+  controls.enableDamping = true;
+  controls.dampingFactor = 0.08;
+  controls.screenSpacePanning = true;
+  resizeObserver = new ResizeObserver(resize);
+  resizeObserver.observe(host.value);
+  resize();
+  frame();
+  void loadSource(props.source);
+});
+
+watch(() => props.source, (source) => { void loadSource(source); });
+watch(() => props.surface, () => { if (props.source) void loadSource(props.source); });
+watch(wireframe, applyWireframe);
+
+onBeforeUnmount(() => {
+  cancelAnimationFrame(animationFrame);
+  resizeObserver?.disconnect();
+  disposeModel();
+  controls?.dispose();
+  renderer?.dispose();
+  renderer?.domElement.remove();
+});
+</script>
+
+<template>
+  <div class="textured-mesh-viewer">
+    <div ref="host" class="canvas-host" aria-label="纹理网格交互视图" />
+    <div class="viewer-toolbar">
+      <span>{{ Math.round(triangleCount).toLocaleString() }} 面</span>
+      <a-checkbox v-model:checked="wireframe">线框</a-checkbox>
+      <a-tooltip title="俯视模型(沿本地 Z 轴)"><a-button type="text" aria-label="俯视模型" @click="topDownView"><BorderTopOutlined /></a-button></a-tooltip>
+      <a-tooltip title="复位视角"><a-button type="text" aria-label="复位视角" @click="resetView"><ReloadOutlined /></a-button></a-tooltip>
+    </div>
+    <div v-if="loading || error" class="viewer-state" :class="{ error }">{{ error || "正在加载纹理模型..." }}</div>
+  </div>
+</template>
+
+<style scoped>
+.textured-mesh-viewer { position: relative; height: 500px; overflow: hidden; border: 1px solid #243342; background: #101821; }
+.canvas-host { width: 100%; height: 100%; }
+.viewer-toolbar { position: absolute; right: 12px; top: 12px; display: flex; align-items: center; gap: 8px; padding: 5px 8px; color: #eef5f8; background: rgba(9, 18, 26, 0.78); }
+.viewer-toolbar :deep(.ant-checkbox-wrapper) { color: #eef5f8; }
+.viewer-toolbar :deep(.ant-btn) { color: #eef5f8; }
+.viewer-state { position: absolute; inset: 0; display: grid; place-items: center; padding: 24px; color: #eef5f8; background: rgba(16, 24, 33, 0.7); text-align: center; }
+.viewer-state.error { color: #ffd7cf; }
+@media (max-width: 767px) { .textured-mesh-viewer { height: 360px; } }
+</style>
diff --git a/apps/workbench-console/src/data/capabilities.ts b/apps/workbench-console/src/data/capabilities.ts
index 5002084..a80ddf7 100644
--- a/apps/workbench-console/src/data/capabilities.ts
+++ b/apps/workbench-console/src/data/capabilities.ts
@@ -16,7 +16,7 @@
   { id: "04-spatial-measurement", title: "空间测量", level: "B", status: "verified", note: "CPU 栅格对象计数、面积与周长测量,展示栅格和 GeoAI 矢量结果。" },
   { id: "05-3d-pointcloud", title: "三维点云", level: "B", status: "planned", note: "等待首个本地 Demo。" },
   { id: "06-spatial-reasoning", title: "空间推理", level: "C", status: "planned", note: "等待首个本地 Demo。" },
-  { id: "07-risk-rule-engine", title: "风险规则引擎", level: "C", status: "planned", note: "等待首个本地 Demo。" },
+  { id: "07-risk-rule-engine", title: "空间规则与风险评分", level: "C", status: "verified", note: "CPU 可审计规则 Demo,展示风险栅格、评分矢量、命中原因与处置建议。" },
   { id: "08-spatiotemporal-forecasting", title: "时空预测", level: "B", status: "planned", note: "等待首个本地 Demo。" },
   { id: "09-anomaly-detection", title: "异常检测", level: "B", status: "verified", note: "CPU 可解释特征与 Isolation Forest 对比,输出热力图、候选区和结构化结果。" },
   { id: "10-smart-route-planning", title: "智能航线规划", level: "C", status: "planned", note: "等待首个本地 Demo。" },
@@ -30,5 +30,12 @@
   { id: "18-reporting-model-governance", title: "报告与模型治理", level: "C", status: "planned", note: "等待首个本地 Demo。" }
 ];
 
+const pointCloudCapability = capabilities.find((item) => item.id === "05-3d-pointcloud");
+if (pointCloudCapability) {
+  pointCloudCapability.title = "三维重建与点云理解";
+  pointCloudCapability.status = "verified";
+  pointCloudCapability.note = "CPU 几何基线,展示 DSM 栅格、高出地物 GeoAI 足迹与近似网格。";
+}
+
 export const verifiedCapabilities = capabilities.filter((item) => item.status === "verified");
 export const capabilityById = (id: string) => capabilities.find((item) => item.id === id);
diff --git a/apps/workbench-console/src/stores/artifacts.ts b/apps/workbench-console/src/stores/artifacts.ts
index 7f7082d..e640f00 100644
--- a/apps/workbench-console/src/stores/artifacts.ts
+++ b/apps/workbench-console/src/stores/artifacts.ts
@@ -5,14 +5,18 @@
   loadChangeArtifacts,
   loadSemanticArtifacts,
   loadMeasurementArtifacts,
+  loadPointCloudArtifacts,
   loadTrajectoryArtifacts,
   loadAnomalyArtifacts,
+  loadRiskRuleArtifacts,
   type DetectionCase,
   type ChangeCase,
   type SemanticCase,
   type MeasurementCase,
+  type PointCloudCase,
   type TrajectoryCase,
-  type AnomalyCase
+  type AnomalyCase,
+  type RiskRuleCase
 } from "@/api/artifacts";
 
 interface ArtifactState {
@@ -20,8 +24,10 @@
   trajectoryCases: Record<string, TrajectoryCase>;
   semanticCases: Record<string, SemanticCase>;
   measurementCases: Record<string, MeasurementCase>;
+  pointCloudCases: Record<string, PointCloudCase>;
   changeCases: Record<string, ChangeCase>;
   anomalyCases: Record<string, AnomalyCase>;
+  riskRuleCases: Record<string, RiskRuleCase>;
   loading: boolean;
   error: string | null;
 }
@@ -31,7 +37,7 @@
     detectionRun: (state) => Object.values(state.detectionCases)[0]?.run ?? null,
     detectionImages: (state) => Object.values(state.detectionCases)[0]?.images ?? []
   },
-  state: (): ArtifactState => ({ detectionCases: {}, trajectoryCases: {}, semanticCases: {}, measurementCases: {}, changeCases: {}, anomalyCases: {}, loading: false, error: null }),
+  state: (): ArtifactState => ({ detectionCases: {}, trajectoryCases: {}, semanticCases: {}, measurementCases: {}, pointCloudCases: {}, changeCases: {}, anomalyCases: {}, riskRuleCases: {}, loading: false, error: null }),
   actions: {
     async loadChange(force = false) {
       if (!force && Object.keys(this.changeCases).length) return;
@@ -74,6 +80,20 @@
       try { this.anomalyCases = await loadAnomalyArtifacts(); }
       catch (error) { this.error = error instanceof Error ? error.message : "异常检测结果读取失败"; }
       finally { this.loading = false; }
+    },
+    async loadPointCloud(force = false) {
+      if (!force && Object.keys(this.pointCloudCases).length) return;
+      this.loading = true; this.error = null;
+      try { this.pointCloudCases = await loadPointCloudArtifacts(); }
+      catch (error) { this.error = error instanceof Error ? error.message : "Three-dimensional point-cloud results could not be loaded."; }
+      finally { this.loading = false; }
+    },
+    async loadRiskRule(force = false) {
+      if (!force && Object.keys(this.riskRuleCases).length) return;
+      this.loading = true; this.error = null;
+      try { this.riskRuleCases = await loadRiskRuleArtifacts(); }
+      catch (error) { this.error = error instanceof Error ? error.message : "空间规则与风险评分结果读取失败"; }
+      finally { this.loading = false; }
     }
   }
 });
diff --git a/apps/workbench-console/src/views/CapabilityView.vue b/apps/workbench-console/src/views/CapabilityView.vue
index 9cbd31c..59a1113 100644
--- a/apps/workbench-console/src/views/CapabilityView.vue
+++ b/apps/workbench-console/src/views/CapabilityView.vue
@@ -7,8 +7,10 @@
 import TrajectoryAnalysisPanel from "@/components/TrajectoryAnalysisPanel.vue";
 import SemanticMappingPanel from "@/components/SemanticMappingPanel.vue";
 import SpatialMeasurementPanel from "@/components/SpatialMeasurementPanel.vue";
+import PointCloudPanel from "@/components/PointCloudPanel.vue";
 import ChangeDetectionPanel from "@/components/ChangeDetectionPanel.vue";
 import AnomalyDetectionPanel from "@/components/AnomalyDetectionPanel.vue";
+import RiskRulePanel from "@/components/RiskRulePanel.vue";
 import { capabilityById } from "@/data/capabilities";
 
 const route = useRoute();
@@ -23,7 +25,9 @@
     <TrajectoryAnalysisPanel v-else-if="capability.id === '15-trajectory-analysis'" />
     <SemanticMappingPanel v-else-if="capability.id === '02-semantic-mapping'" />
     <SpatialMeasurementPanel v-else-if="capability.id === '04-spatial-measurement'" />
+    <PointCloudPanel v-else-if="capability.id === '05-3d-pointcloud'" />
     <AnomalyDetectionPanel v-else-if="capability.id === '09-anomaly-detection'" />
+    <RiskRulePanel v-else-if="capability.id === '07-risk-rule-engine'" />
     <a-empty v-else description="等待第一个可验证 Demo"><template #description><h2>尚无本地结果工件</h2><p>该能力已登记在目录中,但尚未产生可视化结果、结构化输出或运行元数据。</p></template></a-empty>
   </div>
   <div v-else class="view-container"><a-result status="404" title="未找到能力页面" sub-title="请从左侧目录选择已登记的能力。" /></div>
diff --git a/capabilities/00-change-detection/README.md b/capabilities/00-change-detection/README.md
index a29f09a..e700838 100644
--- a/capabilities/00-change-detection/README.md
+++ b/capabilities/00-change-detection/README.md
@@ -1,9 +1,9 @@
 # 变化检测
 
 这是一个可独立运行的 CPU Demo,用 `geoai-py 0.42.0` 的 ChangeStar 接口对双期
-影像生成变化概率栅格、二值变化栅格和变化图斑 GeoJSON。能力边界为 **A(直接
-GeoAI 能力)**;OpenCV 只负责普通 JPG 的特征配准和尺寸处理,Rasterio/GeoPandas
-负责无 CRS 输入的栅格与矢量写出。
+影像生成变化概率栅格、二值变化栅格和变化图斑 GeoJSON。核心推理的能力边界为
+**A(直接 GeoAI 能力)**;OpenCV 只负责普通 JPG 的特征配准和尺寸处理,Rasterio
+负责网格对齐、栅格写出及清理后掩膜的一对一矢量化。
 
 ## 输入契约
 
@@ -50,9 +50,13 @@
 - `before.tif`、`after_registered.tif`:处理输入;GeoTIFF 模式保留 CRS 和仿射变换。
 - `change_probability.tif`:模型输出的 float32 变化概率。
 - `change_mask_raw.tif`、`change_mask.tif`:阈值前后二值栅格(0/255)。
-- `change_overlay.png`:第二期影像上的红色变化叠加和双期对比。
-- `changes.geojson`:通过 `geoai.masks_to_vector` 导出的变化图斑,附带面积和概率
-  统计;无变化时也会写出空 `FeatureCollection`。
+- `change_overlay.jpg`:左侧为第一期处理图,右侧为配准后的第二期;红色表示
+  ChangeStar 结构变化,青色表示通用 RGB 视觉差异候选。
+- `change_model_overlay.jpg`:只显示 ChangeStar 红色结果,用于与历史输出对照。
+- `generic_difference_mask.tif`:视觉差异候选栅格。它用于补充车辆、人员等移动
+  目标的漏显,不是 ChangeStar 语义结果,可能包含阴影、边缘和配准残差。
+- `changes.geojson`:从最终清理掩膜逐区导出的变化图斑,附带面积和概率统计;
+  无变化时也会写出空 `FeatureCollection`。
 - `change_features.json`:图斑面积、概率统计和像素边界。
 - `run_metadata.json`:版本、模型、阈值、配准、缩放、耗时、坐标和限制说明。
 
@@ -66,6 +70,14 @@
    和矢量输出是否可读。该样本是近景边坡照片,不是 ChangeStar 训练分布,不能
    据此宣称工程变化检测准确率。
 
+控制台必须使用配准后的第二期作为栅格和矢量叠加底图;原始第二期只用于查看
+输入。2026-08-20 的道路样本验证中,青色视觉差异层补充覆盖了车辆与部分人员
+变化,但也出现道路边缘、阴影和局部配准残差,因此只能作为人工复核候选,不能
+合并进 ChangeStar 的变化像素、图斑、Precision、Recall 或工程告警统计。
+参数扫描预览和“转为正式案例”必须继续携带该青色候选层、候选栅格及配准预览;
+正式案例 `change-20260820-032014-615889` 已验证车辆消失位置形成完整青色轮廓,
+人员消失位置也有候选覆盖。
+
 另外对城市停车场双期样本做了分辨率对照:`1024` 档位输出 1 个图斑,`2048`
 档位输出 16 个图斑,更多车辆和停车区域变化被覆盖;`2048` 档位 CPU 用时约
 84 秒,仍存在误报和漏检,结果不能视为车辆变化检测准确率。
@@ -73,6 +85,44 @@
 没有人工变化真值,Demo 不报告 precision、recall、IoU 或“变化类型”。后续若要
 用于边坡/排洪沟等业务,需要同 CRS 正射影像、人工掩膜、误报/漏报统计和许可审查。
 
+## 人工真值基准评估
+
+参考文章 `09/25/30/39/40` 的建议不用于直接替换 ChangeStar,而用于补齐数据与验收层。
+新增的 [`evaluate_change_detection.py`](./evaluate_change_detection.py) 只评估已经生成的
+结果,不重跑模型,也不修改任何源影像。开始正式评估前,先复制
+[`benchmark_manifest.example.csv`](./benchmark_manifest.example.csv) 到
+`shared/data/processed/00-change-detection/<基准版本>/pairs.csv` 并填写真实路径。
+
+每个基准样本必须满足:
+
+- `before`、`after` 和 `truth` 都是同宽高、同 CRS、同仿射网格的 GeoTIFF;评估器不会
+  静默重采样人工真值。
+- `truth` 是单波段栅格,固定使用 `0=未变化`、`1=变化`、`255=忽略/不确定区域`。
+- `output_dir` 指向已有正式运行,至少包含 `change_mask.tif`;有
+  `change_probability.tif` 时可额外比较多个原始概率阈值。
+- 正式 GeoTIFF 基准运行使用 `--processing-mode geotiff --max-dimension 0`,或先裁出
+  可在 CPU 上保持原始分辨率运行的小区域,确保模型输出与人工真值仍在同一网格。
+- 同一地点或同一原始影像产生的相邻瓦片只能属于同一个 `split`,避免空间泄漏。
+- `change_type`、人工审核状态、采集时间、数据来源和许可应在清单或配套元数据中登记。
+
+运行示例:
+
+```powershell
+& .\.venvs\00-change-detection\Scripts\python.exe `
+  .\capabilities\00-change-detection\evaluate_change_detection.py `
+  --manifest .\shared\data\processed\00-change-detection\benchmark-v1\pairs.csv `
+  --thresholds 0.3,0.4,0.5,0.6,0.7 `
+  --output .\shared\outputs\00-change-detection\benchmark-v1\evaluation.json
+```
+
+输出同时记录基准汇总和逐样本的有效像素数、TP、FP、FN、TN、Precision、Recall、
+F1、IoU 和 Specificity。`cleaned_mask` 指标代表正式输出;`probability_raw` 阈值扫描不包含连通区域
+过滤,只用于选择候选阈值,不能直接当成最终产品参数。普通图片仅可通过显式
+`--allow-pixel-coordinates` 做管线测试,不属于地理配准合格的正式基准。
+
+当前只有合成小栅格单元测试验证了计算和拒绝错位网格的逻辑,尚未获得人工审核的真实
+变化掩膜,因此仍然没有可报告的真实 Precision、Recall、F1 或 IoU。
+
 ## 许可
 
 - `geoai-py 0.42.0`:MIT。
diff --git a/capabilities/00-change-detection/benchmark_manifest.example.csv b/capabilities/00-change-detection/benchmark_manifest.example.csv
new file mode 100644
index 0000000..d8b83ef
--- /dev/null
+++ b/capabilities/00-change-detection/benchmark_manifest.example.csv
@@ -0,0 +1,2 @@
+pair_id,before,after,truth,output_dir,split,change_type,review_status,license_note
+pair-001,../../../raw/00-change-detection/benchmark-v1/pair-001/before.tif,../../../raw/00-change-detection/benchmark-v1/pair-001/after.tif,truth/pair-001.tif,../../../../outputs/00-change-detection/benchmark-v1/pair-001,test,binary_change,reviewed,confirm before use
diff --git a/capabilities/00-change-detection/evaluate_change_detection.py b/capabilities/00-change-detection/evaluate_change_detection.py
new file mode 100644
index 0000000..80562cd
--- /dev/null
+++ b/capabilities/00-change-detection/evaluate_change_detection.py
@@ -0,0 +1,255 @@
+"""Validate paired change-detection data and score existing ChangeStar outputs.
+
+The evaluator deliberately stays outside ``run_change_detection.py``. Inference
+can therefore remain unchanged while a manually reviewed benchmark is assembled.
+Manifest paths are resolved relative to the manifest file unless absolute.
+"""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import json
+import math
+from pathlib import Path
+from typing import Any, Iterable
+
+import numpy as np
+import rasterio
+
+
+GRID_TOLERANCE = 1e-6
+IGNORE_VALUE = 255
+REQUIRED_COLUMNS = {"pair_id", "before", "after", "truth", "output_dir"}
+
+
+def _resolve(base: Path, value: str) -> Path:
+    path = Path(value)
+    return path if path.is_absolute() else (base / path).resolve()
+
+
+def _read_manifest(path: Path) -> list[dict[str, str]]:
+    if path.suffix.lower() == ".json":
+        payload = json.loads(path.read_text(encoding="utf-8"))
+        rows = payload.get("pairs") if isinstance(payload, dict) else payload
+        if not isinstance(rows, list):
+            raise ValueError("JSON manifest must be a list or an object with a 'pairs' list.")
+        return [{str(key): str(value) for key, value in row.items()} for row in rows]
+    with path.open("r", encoding="utf-8-sig", newline="") as handle:
+        rows = list(csv.DictReader(handle))
+    if not rows:
+        raise ValueError("Manifest is empty.")
+    return [{str(key): str(value) for key, value in row.items() if value is not None} for row in rows]
+
+
+def _transform_values(transform: Any) -> tuple[float, ...]:
+    return tuple(float(value) for value in (transform.a, transform.b, transform.c, transform.d, transform.e, transform.f))
+
+
+def _same_grid(left: rasterio.DatasetReader, right: rasterio.DatasetReader, tolerance: float = GRID_TOLERANCE) -> bool:
+    return (
+        left.width == right.width
+        and left.height == right.height
+        and left.crs == right.crs
+        and np.allclose(_transform_values(left.transform), _transform_values(right.transform), rtol=tolerance, atol=tolerance)
+    )
+
+
+def validate_pair(row: dict[str, str], base_dir: Path, *, allow_pixel_coordinates: bool = False) -> dict[str, Any]:
+    missing = sorted(REQUIRED_COLUMNS - set(row))
+    if missing:
+        raise ValueError(f"Manifest row is missing columns: {', '.join(missing)}")
+    pair_id = row["pair_id"].strip()
+    if not pair_id:
+        raise ValueError("pair_id must not be empty")
+    before_path = _resolve(base_dir, row["before"])
+    after_path = _resolve(base_dir, row["after"])
+    truth_path = _resolve(base_dir, row["truth"])
+    output_dir = _resolve(base_dir, row["output_dir"])
+    for path in (before_path, after_path, truth_path, output_dir):
+        if not path.exists():
+            raise FileNotFoundError(f"{pair_id}: missing {path}")
+    with rasterio.open(before_path) as before, rasterio.open(after_path) as after, rasterio.open(truth_path) as truth:
+        if before.count < 3 or after.count < 3:
+            raise ValueError(f"{pair_id}: before/after must contain at least three bands")
+        if truth.count != 1:
+            raise ValueError(f"{pair_id}: truth must be a single-band raster")
+        if not _same_grid(before, after):
+            raise ValueError(f"{pair_id}: before and after are not on the same CRS/grid")
+        if not _same_grid(before, truth):
+            raise ValueError(f"{pair_id}: truth is not on the same CRS/grid as before")
+        if not allow_pixel_coordinates and (before.crs is None or before.transform.is_identity):
+            raise ValueError(f"{pair_id}: benchmark requires a valid CRS and affine transform")
+        truth_values = truth.read(1, masked=True)
+        valid = ~np.ma.getmaskarray(truth_values)
+        values = np.asarray(truth_values.data)
+        if not np.any(valid):
+            raise ValueError(f"{pair_id}: truth has no valid pixels")
+        unexpected = sorted(int(value) for value in np.unique(values[valid]) if value not in (0, 1, IGNORE_VALUE))
+        if unexpected:
+            raise ValueError(f"{pair_id}: truth values must use 0=unchanged, 1=changed, 255=ignore; found {unexpected[:10]}")
+        positive_pixels = int(((values > 0) & (values != IGNORE_VALUE) & valid).sum())
+        return {
+            "pair_id": pair_id,
+            "before": str(before_path),
+            "after": str(after_path),
+            "truth": str(truth_path),
+            "output_dir": str(output_dir),
+            "shape": [before.height, before.width],
+            "crs": before.crs.to_string() if before.crs else None,
+            "positive_truth_pixels": positive_pixels,
+            "split": row.get("split", ""),
+            "change_type": row.get("change_type", ""),
+        }
+
+
+def _load_truth(path: Path) -> tuple[np.ndarray, np.ndarray, Any, str | None]:
+    with rasterio.open(path) as dataset:
+        masked = dataset.read(1, masked=True)
+        values = np.asarray(masked.data)
+        valid = ~np.ma.getmaskarray(masked)
+        valid &= values != IGNORE_VALUE
+        truth = (values > 0) & valid
+        return truth, valid, dataset.transform, dataset.crs.to_string() if dataset.crs else None
+
+
+def _load_prediction(path: Path, expected_shape: tuple[int, int]) -> tuple[np.ndarray, Any, str | None]:
+    with rasterio.open(path) as dataset:
+        prediction = dataset.read(1)
+        if prediction.shape != expected_shape:
+            raise ValueError(f"Prediction shape {prediction.shape} does not match truth {expected_shape}")
+        return prediction, dataset.transform, dataset.crs.to_string() if dataset.crs else None
+
+
+def _metric_record(truth: np.ndarray, predicted: np.ndarray, valid: np.ndarray, *, threshold: float | None, source: str) -> dict[str, Any]:
+    truth = truth & valid
+    predicted = predicted & valid
+    tp = int((predicted & truth).sum())
+    fp = int((predicted & ~truth).sum())
+    fn = int((~predicted & truth).sum())
+    tn = int((~predicted & ~truth & valid).sum())
+    return _metrics_from_counts(tp, fp, fn, tn, threshold=threshold, source=source)
+
+
+def _metrics_from_counts(tp: int, fp: int, fn: int, tn: int, *, threshold: float | None, source: str) -> dict[str, Any]:
+    precision = tp / (tp + fp) if tp + fp else 0.0
+    recall = tp / (tp + fn) if tp + fn else 0.0
+    f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0
+    iou = tp / (tp + fp + fn) if tp + fp + fn else 1.0
+    specificity = tn / (tn + fp) if tn + fp else 1.0
+    result: dict[str, Any] = {
+        "source": source,
+        "valid_pixels": tp + fp + fn + tn,
+        "truth_changed_pixels": tp + fn,
+        "predicted_changed_pixels": tp + fp,
+        "true_positive": tp,
+        "false_positive": fp,
+        "false_negative": fn,
+        "true_negative": tn,
+        "precision": round(precision, 6),
+        "recall": round(recall, 6),
+        "f1": round(f1, 6),
+        "iou": round(iou, 6),
+        "specificity": round(specificity, 6),
+    }
+    if threshold is not None:
+        result["threshold"] = threshold
+    return result
+
+
+def _aggregate_results(results: list[dict[str, Any]]) -> dict[str, Any]:
+    def combine(records: list[dict[str, Any]], *, threshold: float | None, source: str) -> dict[str, Any]:
+        totals = {key: sum(int(record[key]) for record in records) for key in ("true_positive", "false_positive", "false_negative", "true_negative")}
+        output = _metrics_from_counts(
+            totals["true_positive"],
+            totals["false_positive"],
+            totals["false_negative"],
+            totals["true_negative"],
+            threshold=threshold,
+            source=source,
+        )
+        output["pair_count"] = len(records)
+        return output
+
+    cleaned = [result["metrics"][0] for result in results]
+    summary: dict[str, Any] = {"cleaned_mask": combine(cleaned, threshold=None, source="cleaned_mask")}
+    thresholds = sorted({float(record["threshold"]) for result in results for record in result["threshold_sweep"]})
+    summary["threshold_sweep"] = [
+        combine(
+            [record for result in results for record in result["threshold_sweep"] if float(record["threshold"]) == threshold],
+            threshold=threshold,
+            source="probability_raw",
+        )
+        for threshold in thresholds
+    ]
+    return summary
+
+
+def evaluate_pair(item: dict[str, Any], thresholds: Iterable[float]) -> dict[str, Any]:
+    truth_path = Path(item["truth"])
+    output_dir = Path(item["output_dir"])
+    truth, valid, truth_transform, truth_crs = _load_truth(truth_path)
+    mask_path = output_dir / "change_mask.tif"
+    probability_path = output_dir / "change_probability.tif"
+    if not mask_path.is_file():
+        raise FileNotFoundError(f"{item['pair_id']}: missing {mask_path}")
+    mask, mask_transform, mask_crs = _load_prediction(mask_path, truth.shape)
+    if not np.allclose(_transform_values(truth_transform), _transform_values(mask_transform), rtol=GRID_TOLERANCE, atol=GRID_TOLERANCE) or truth_crs != mask_crs:
+        raise ValueError(f"{item['pair_id']}: prediction and truth are not on the same grid")
+    metrics = [_metric_record(truth, mask > 0, valid, threshold=None, source="cleaned_mask")]
+    sweep: list[dict[str, Any]] = []
+    if probability_path.is_file():
+        probability, probability_transform, probability_crs = _load_prediction(probability_path, truth.shape)
+        if not np.allclose(_transform_values(truth_transform), _transform_values(probability_transform), rtol=GRID_TOLERANCE, atol=GRID_TOLERANCE) or truth_crs != probability_crs:
+            raise ValueError(f"{item['pair_id']}: probability and truth are not on the same grid")
+        for threshold in thresholds:
+            if not math.isfinite(threshold) or not 0.0 <= threshold <= 1.0:
+                raise ValueError(f"threshold must be between 0 and 1: {threshold}")
+            sweep.append(_metric_record(truth, probability >= threshold, valid, threshold=threshold, source="probability_raw"))
+    return {"pair": item, "metrics": metrics, "threshold_sweep": sweep}
+
+
+def evaluate_manifest(manifest_path: Path, output_path: Path, *, thresholds: Iterable[float], allow_pixel_coordinates: bool = False) -> dict[str, Any]:
+    rows = _read_manifest(manifest_path)
+    validated = [validate_pair(row, manifest_path.parent, allow_pixel_coordinates=allow_pixel_coordinates) for row in rows]
+    results = [evaluate_pair(item, thresholds) for item in validated]
+    output = {
+        "schema_version": 1,
+        "capability": "00-change-detection",
+        "manifest": str(manifest_path.resolve()),
+        "benchmark_requires_manual_truth": True,
+        "allow_pixel_coordinates": allow_pixel_coordinates,
+        "summary": _aggregate_results(results),
+        "pairs": results,
+    }
+    output_path.parent.mkdir(parents=True, exist_ok=True)
+    output_path.write_text(json.dumps(output, ensure_ascii=False, indent=2), encoding="utf-8")
+    return output
+
+
+def _parse_thresholds(value: str) -> list[float]:
+    try:
+        thresholds = [float(part.strip()) for part in value.split(",") if part.strip()]
+    except ValueError as exc:
+        raise ValueError("thresholds must be comma-separated numbers") from exc
+    if not thresholds:
+        raise ValueError("at least one threshold is required")
+    return thresholds
+
+
+def build_parser() -> argparse.ArgumentParser:
+    parser = argparse.ArgumentParser(description="Validate and score existing change-detection outputs against manual truth rasters.")
+    parser.add_argument("--manifest", type=Path, required=True, help="CSV/JSON pair manifest")
+    parser.add_argument("--output", type=Path, required=True, help="evaluation JSON path")
+    parser.add_argument("--thresholds", default="0.3,0.4,0.5,0.6,0.7", help="probability thresholds for raw score comparison")
+    parser.add_argument("--allow-pixel-coordinates", action="store_true", help="allow non-georeferenced benchmark pairs; use only for pipeline tests")
+    return parser
+
+
+if __name__ == "__main__":
+    args = build_parser().parse_args()
+    try:
+        result = evaluate_manifest(args.manifest, args.output, thresholds=_parse_thresholds(args.thresholds), allow_pixel_coordinates=args.allow_pixel_coordinates)
+        print(json.dumps(result, ensure_ascii=False, indent=2))
+    except (FileNotFoundError, ValueError, rasterio.errors.RasterioIOError) as exc:
+        raise SystemExit(f"变化检测评估失败: {exc}") from exc
diff --git a/capabilities/00-change-detection/run_change_detection.py b/capabilities/00-change-detection/run_change_detection.py
index 5181d56..dc16af6 100644
--- a/capabilities/00-change-detection/run_change_detection.py
+++ b/capabilities/00-change-detection/run_change_detection.py
@@ -33,6 +33,7 @@
 MIN_MAX_DIMENSION = 512
 MAX_MAX_DIMENSION = 4096
 PROCESSING_MODES = {"auto", "image", "geotiff"}
+GENERIC_DIFF_THRESHOLD = 0.45
 
 
 def _to_uint8(data: np.ndarray) -> np.ndarray:
@@ -171,13 +172,59 @@
 
 
 def _write_overlay(path: Path, before: np.ndarray, after: np.ndarray, mask: np.ndarray) -> None:
+    _write_evidence_overlay(path, before, after, mask, None)
+
+
+def _write_evidence_overlay(path: Path, before: np.ndarray, after: np.ndarray, model_mask: np.ndarray, generic_mask: np.ndarray | None) -> None:
     left = before.copy()
     right = after.copy()
+    if generic_mask is not None:
+        cyan = np.zeros_like(right)
+        cyan[..., 1] = 220
+        cyan[..., 2] = 255
+        right[generic_mask] = (right[generic_mask].astype(np.float32) * 0.45 + cyan[generic_mask].astype(np.float32) * 0.55).astype(np.uint8)
     red = np.zeros_like(right)
     red[..., 0] = 255
-    right[mask] = (right[mask].astype(np.float32) * 0.45 + red[mask].astype(np.float32) * 0.55).astype(np.uint8)
+    right[model_mask] = (right[model_mask].astype(np.float32) * 0.45 + red[model_mask].astype(np.float32) * 0.55).astype(np.uint8)
     separator = np.full((before.shape[0], 8, 3), 235, dtype=np.uint8)
     Image.fromarray(np.concatenate([left, separator, right], axis=1)).save(path, quality=92)
+
+
+def _compute_generic_difference_mask(before: np.ndarray, after: np.ndarray, valid: np.ndarray, *, threshold: float = GENERIC_DIFF_THRESHOLD) -> tuple[np.ndarray, dict[str, Any]]:
+    """Create a conservative visual-difference candidate layer.
+
+    This is deliberately separate from ChangeStar's semantic mask: it highlights
+    strong local RGB differences such as moved vehicles or people, while
+    removing tiny noise, very large illumination regions, and invalid borders.
+    """
+    if before.shape != after.shape or before.shape[:2] != valid.shape:
+        raise ValueError("before, after, and valid must share the same spatial shape")
+    difference = np.mean(np.abs(before.astype(np.float32) - after.astype(np.float32)), axis=2) / 255.0
+    difference = cv2.GaussianBlur(difference, (3, 3), 0)
+    candidate = ((difference >= threshold) & valid).astype(np.uint8)
+    candidate = cv2.morphologyEx(candidate, cv2.MORPH_OPEN, np.ones((3, 3), dtype=np.uint8))
+    candidate = cv2.morphologyEx(candidate, cv2.MORPH_CLOSE, np.ones((5, 5), dtype=np.uint8))
+    count, labels, stats, _ = cv2.connectedComponentsWithStats(candidate, connectivity=8)
+    min_area = max(32, int(candidate.size * 0.00003))
+    max_area = max(min_area, int(candidate.size * 0.08))
+    cleaned = np.zeros_like(candidate)
+    kept = 0
+    for component in range(1, count):
+        x, y, width, height, area = (int(value) for value in stats[component])
+        touches_edge = x <= 0 or y <= 0 or x + width >= candidate.shape[1] or y + height >= candidate.shape[0]
+        if min_area <= area <= max_area and not touches_edge:
+            cleaned[labels == component] = 1
+            kept += 1
+    details = {
+        "threshold": threshold,
+        "minimum_component_pixels": min_area,
+        "maximum_component_pixels": max_area,
+        "components_before_filter": max(0, count - 1),
+        "components_after_filter": kept,
+        "candidate_pixels": int(cleaned.sum()),
+        "candidate_pixel_ratio": round(float(cleaned.mean()), 6),
+    }
+    return cleaned > 0, details
 
 
 def _feature_summary(mask: np.ndarray, probability: np.ndarray, transform: Affine, georeferenced: bool) -> list[dict[str, Any]]:
@@ -311,6 +358,8 @@
     output_crs = before_info["crs"] if actual_mode == "geotiff" else None
     _write_rgb_geotiff(work_dir / "before.tif", before_small, transform, output_crs)
     _write_rgb_geotiff(work_dir / "after_registered.tif", after_small, transform, output_crs)
+    Image.fromarray(before_small).save(output_dir / "before_processed_preview.jpg", quality=92)
+    Image.fromarray(after_small).save(output_dir / "after_registered_preview.jpg", quality=92)
 
     from geoai import ChangeStarDetection
 
@@ -325,10 +374,13 @@
     for component in range(1, count):
         if int(stats[component, cv2.CC_STAT_AREA]) < min_area:
             cleaned[labels == component] = 0
+    generic_mask, generic_details = _compute_generic_difference_mask(before_small, after_small, valid_small)
     _write_raster(output_dir / "change_probability.tif", probability, transform, "float32", output_crs)
     _write_raster(output_dir / "change_mask_raw.tif", raw_mask, transform, "uint8", output_crs)
     _write_raster(output_dir / "change_mask.tif", cleaned, transform, "uint8", output_crs)
-    _write_overlay(output_dir / "change_overlay.jpg", before_small, after_small, cleaned > 0)
+    _write_raster(output_dir / "generic_difference_mask.tif", generic_mask.astype(np.uint8) * 255, transform, "uint8", output_crs)
+    _write_evidence_overlay(output_dir / "change_overlay.jpg", before_small, after_small, cleaned > 0, generic_mask)
+    _write_overlay(output_dir / "change_model_overlay.jpg", before_small, after_small, cleaned > 0)
     vector_path = output_dir / "changes.geojson"
     features, vector_count = vectorize_cleaned_mask(output_dir / "change_mask.tif", output_dir / "change_probability.tif", vector_path)
     from rectangularize_vectors import rectangularize_vector
@@ -371,6 +423,7 @@
         "raw_changed_pixels": int((raw_mask > 0).sum()),
         "changed_pixels": int((cleaned > 0).sum()),
         "changed_pixel_ratio": round(float((cleaned > 0).mean()), 6),
+        "generic_difference": generic_details,
         "vector_feature_count": vector_count,
         "rectangle_feature_count": rectangle_count,
         "georeferenced": actual_mode == "geotiff",
@@ -383,7 +436,11 @@
             "probability_raster": "change_probability.tif",
             "raw_mask_raster": "change_mask_raw.tif",
             "mask_raster": "change_mask.tif",
+            "generic_difference_mask": "generic_difference_mask.tif",
             "overlay": "change_overlay.jpg",
+            "model_overlay": "change_model_overlay.jpg",
+            "before_processed_preview": "before_processed_preview.jpg",
+            "after_registered_preview": "after_registered_preview.jpg",
             "vector": "changes.geojson",
             "rectangle_vector": "changes_rectangles.geojson",
             "rectangle_vector_wgs84": "changes_rectangles_wgs84.geojson" if actual_mode == "geotiff" else None,
diff --git a/capabilities/00-change-detection/scan_change_detection_parameters.py b/capabilities/00-change-detection/scan_change_detection_parameters.py
index 6b6084f..36d61e3 100644
--- a/capabilities/00-change-detection/scan_change_detection_parameters.py
+++ b/capabilities/00-change-detection/scan_change_detection_parameters.py
@@ -34,7 +34,7 @@
     return cleaned, count - 1
 
 
-def preview(image: np.ndarray, mask: np.ndarray, max_dimension: int = 720) -> Image.Image:
+def preview(image: np.ndarray, mask: np.ndarray, generic_mask: np.ndarray | None = None, max_dimension: int = 720) -> Image.Image:
     height, width = image.shape[:2]
     scale = min(1.0, max_dimension / max(height, width))
     size = (max(1, int(round(width * scale))), max(1, int(round(height * scale))))
@@ -42,6 +42,13 @@
     small_mask = Image.fromarray((mask > 0).astype(np.uint8) * 255).resize(size, Image.Resampling.NEAREST)
     base_array = np.asarray(base).copy()
     mask_array = np.asarray(small_mask) > 0
+    if generic_mask is not None:
+        small_generic = Image.fromarray((generic_mask > 0).astype(np.uint8) * 255).resize(size, Image.Resampling.NEAREST)
+        generic_array = np.asarray(small_generic) > 0
+        cyan = np.zeros_like(base_array)
+        cyan[..., 1] = 220
+        cyan[..., 2] = 255
+        base_array[generic_array] = (base_array[generic_array].astype(np.float32) * 0.45 + cyan[generic_array].astype(np.float32) * 0.55).astype(np.uint8)
     red = np.zeros_like(base_array)
     red[..., 0] = 255
     base_array[mask_array] = (base_array[mask_array].astype(np.float32) * 0.45 + red[mask_array].astype(np.float32) * 0.55).astype(np.uint8)
@@ -60,6 +67,11 @@
         crs = probability_dataset.crs.to_string() if probability_dataset.crs else None
     with rasterio.open(input_path) as input_dataset:
         image = np.transpose(input_dataset.read([1, 2, 3]), (1, 2, 0))
+    generic_path = run_dir / "generic_difference_mask.tif"
+    generic_mask = None
+    if generic_path.is_file():
+        with rasterio.open(generic_path) as generic_dataset:
+            generic_mask = generic_dataset.read(1) > 0
     output_dir.mkdir(parents=True, exist_ok=False)
     rows: list[dict] = []
     contact_items: list[tuple[str, Image.Image]] = []
@@ -78,7 +90,7 @@
             # polygons are expensive and are not needed to compare thresholds.
             features = demo._feature_summary(cleaned, probability, transform, crs is not None)
             (item_dir / "regions.json").write_text(json.dumps({"features": features}, ensure_ascii=False, indent=2), encoding="utf-8")
-            overlay = preview(image, cleaned)
+            overlay = preview(image, cleaned, generic_mask)
             overlay.save(item_dir / "overlay_preview.jpg", quality=90)
             row = {
                 "threshold": threshold,
diff --git a/capabilities/00-change-detection/tests/test_change_detection.py b/capabilities/00-change-detection/tests/test_change_detection.py
index 5d53b60..4496929 100644
--- a/capabilities/00-change-detection/tests/test_change_detection.py
+++ b/capabilities/00-change-detection/tests/test_change_detection.py
@@ -29,6 +29,18 @@
         self.assertEqual(details["original_width"], 200)
         self.assertEqual(details["original_height"], 100)
 
+    def test_generic_difference_keeps_local_change_and_rejects_edge_region(self):
+        module = load_module()
+        before = np.zeros((256, 256, 3), dtype=np.uint8)
+        after = before.copy()
+        after[90:130, 100:150] = 255
+        after[0:40, 0:40] = 255
+        mask, details = module._compute_generic_difference_mask(before, after, np.ones((256, 256), dtype=bool))
+        self.assertTrue(mask[105, 120])
+        self.assertFalse(mask[10, 10])
+        self.assertEqual(details["components_after_filter"], 1)
+        self.assertGreater(details["candidate_pixels"], 0)
+
     def test_geotiff_mode_reads_crs_and_native_resolution(self):
         module = load_module()
         with tempfile.TemporaryDirectory() as directory:
diff --git a/capabilities/00-change-detection/tests/test_evaluate_change_detection.py b/capabilities/00-change-detection/tests/test_evaluate_change_detection.py
new file mode 100644
index 0000000..ffdb837
--- /dev/null
+++ b/capabilities/00-change-detection/tests/test_evaluate_change_detection.py
@@ -0,0 +1,103 @@
+import json
+import tempfile
+import unittest
+from pathlib import Path
+
+import numpy as np
+import rasterio
+from rasterio.transform import from_origin
+
+import importlib.util
+
+
+def load_module():
+    path = Path(__file__).parents[1] / "evaluate_change_detection.py"
+    spec = importlib.util.spec_from_file_location("evaluate_change_detection", path)
+    module = importlib.util.module_from_spec(spec)
+    assert spec and spec.loader
+    spec.loader.exec_module(module)
+    return module
+
+
+def write_raster(path: Path, data: np.ndarray, *, count: int = 1, crs: str = "EPSG:3857"):
+    transform = from_origin(100, 200, 1, 1)
+    data = np.repeat(data[None, ...], count, axis=0)
+    with rasterio.open(path, "w", driver="GTiff", width=data.shape[-1], height=data.shape[-2], count=count, dtype=str(data.dtype), crs=crs, transform=transform) as dataset:
+        dataset.write(data)
+
+
+class EvaluateChangeDetectionTests(unittest.TestCase):
+    def test_evaluates_existing_mask_and_threshold_sweep(self):
+        module = load_module()
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            before = root / "before.tif"
+            after = root / "after.tif"
+            truth = root / "truth.tif"
+            output_dir = root / "run"
+            output_dir.mkdir()
+            write_raster(before, np.zeros((4, 4), dtype=np.uint8), count=3)
+            write_raster(after, np.zeros((4, 4), dtype=np.uint8), count=3)
+            truth_data = np.zeros((4, 4), dtype=np.uint8)
+            truth_data[1:3, 1:3] = 1
+            write_raster(truth, truth_data)
+            prediction = np.zeros((4, 4), dtype=np.uint8)
+            prediction[1:3, 1:3] = 255
+            write_raster(output_dir / "change_mask.tif", prediction)
+            probability = np.zeros((4, 4), dtype=np.float32)
+            probability[1:3, 1:3] = 0.8
+            probability[0, 0] = 0.6
+            write_raster(output_dir / "change_probability.tif", probability)
+            manifest = root / "pairs.csv"
+            manifest.write_text("pair_id,before,after,truth,output_dir\ncase-1,before.tif,after.tif,truth.tif,run\n", encoding="utf-8")
+            result_path = root / "evaluation.json"
+            result = module.evaluate_manifest(manifest, result_path, thresholds=[0.5, 0.7])
+            metrics = result["pairs"][0]["metrics"][0]
+            self.assertEqual(metrics["true_positive"], 4)
+            self.assertEqual(metrics["false_positive"], 0)
+            self.assertEqual(metrics["false_negative"], 0)
+            self.assertEqual(metrics["iou"], 1.0)
+            self.assertEqual(len(result["pairs"][0]["threshold_sweep"]), 2)
+            self.assertEqual(result["summary"]["cleaned_mask"]["pair_count"], 1)
+            self.assertEqual(result["summary"]["cleaned_mask"]["iou"], 1.0)
+            self.assertEqual(json.loads(result_path.read_text(encoding="utf-8"))["schema_version"], 1)
+
+    def test_rejects_misaligned_truth(self):
+        module = load_module()
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            before = root / "before.tif"
+            after = root / "after.tif"
+            truth = root / "truth.tif"
+            output_dir = root / "run"
+            output_dir.mkdir()
+            write_raster(before, np.zeros((4, 4), dtype=np.uint8), count=3)
+            write_raster(after, np.zeros((4, 4), dtype=np.uint8), count=3)
+            with rasterio.open(truth, "w", driver="GTiff", width=4, height=4, count=1, dtype="uint8", crs="EPSG:3857", transform=from_origin(101, 200, 1, 1)) as dataset:
+                dataset.write(np.zeros((1, 4, 4), dtype=np.uint8))
+            manifest = root / "pairs.csv"
+            manifest.write_text("pair_id,before,after,truth,output_dir\ncase-1,before.tif,after.tif,truth.tif,run\n", encoding="utf-8")
+            with self.assertRaisesRegex(ValueError, "truth is not on the same CRS/grid"):
+                module.evaluate_manifest(manifest, root / "evaluation.json", thresholds=[0.5])
+
+    def test_pixel_coordinates_require_explicit_opt_in(self):
+        module = load_module()
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            before = root / "before.tif"
+            after = root / "after.tif"
+            truth = root / "truth.tif"
+            output_dir = root / "run"
+            output_dir.mkdir()
+            for path, count in ((before, 3), (after, 3), (truth, 1)):
+                data = np.zeros((4, 4), dtype=np.uint8)
+                with rasterio.open(path, "w", driver="GTiff", width=4, height=4, count=count, dtype="uint8") as dataset:
+                    dataset.write(np.repeat(data[None, ...], count, axis=0))
+            manifest = root / "pairs.csv"
+            manifest.write_text("pair_id,before,after,truth,output_dir\ncase-1,before.tif,after.tif,truth.tif,run\n", encoding="utf-8")
+            with self.assertRaisesRegex(ValueError, "requires a valid CRS"):
+                module.evaluate_manifest(manifest, root / "evaluation.json", thresholds=[0.5])
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/capabilities/05-3d-pointcloud/README.md b/capabilities/05-3d-pointcloud/README.md
index 9ec374c..176cfa2 100644
--- a/capabilities/05-3d-pointcloud/README.md
+++ b/capabilities/05-3d-pointcloud/README.md
@@ -1,6 +1,421 @@
-# 3D and Point Cloud
+# Three-dimensional reconstruction and point-cloud understanding
 
-- 输入:重叠照片、LAS/LAZ/PLY 点云或现有三维成果。
-- 输出:点云、网格、DSM、分类或构件结果。
-- 首个 Demo:先分析已有 ODM/点云成果,不在本机从零训练重型模型。
+## Boundary
 
+Classification: **B - ecosystem combination**.
+
+`geoai-py 0.42.0` has no installed API for SfM/MVS reconstruction or point-cloud
+classification. This Demo uses pycolmap/COLMAP for CPU sparse SfM and OpenMVS for
+CPU dense MVS, meshing and texturing from a coherent photo sequence, plus Open3D
+for existing point-cloud geometry. `geoai.masks_to_vector`
+turns an elevated-surface label raster into the GeoJSON footprint output. Open3D,
+Rasterio, GeoPandas, Laspy, and pycolmap supply the remaining work. This is not a
+built-in `geoai-py` 3D reconstruction function.
+
+## Runnable Demo
+
+Input: one existing `PLY`, `PCD`, `XYZ`, `LAS`, or `LAZ` point cloud. The first
+geometry branch consumes a reconstruction output and creates inspectable raster
+and vector outputs. The photo branch accepts a coherent JPG/JPEG sequence and
+creates a CPU sparse SfM point cloud. A user-supplied survey, reconstruction
+export, or image sequence is copied unchanged into a raw run directory before a
+console run is processed.
+
+```powershell
+$py = .\.venvs\05-3d-pointcloud\Scripts\python.exe
+& $py .\capabilities\05-3d-pointcloud\run_pointcloud_understanding.py `
+  --input .\shared\data\raw\05-3d-pointcloud\validation-20260821\normal_site.ply `
+  --output .\shared\outputs\05-3d-pointcloud\validation-normal-20260821
+```
+
+Photo sparse reconstruction:
+
+```powershell
+$py = .\.venvs\05-3d-pointcloud\Scripts\python.exe
+& $py .\capabilities\05-3d-pointcloud\run_photo_reconstruction.py `
+  --input .\shared\data\raw\05-3d-pointcloud\h30t-sideview-20260820-v2 `
+  --output .\shared\outputs\05-3d-pointcloud\real-h30t-sideview-20260821-v2
+```
+
+CPU dense MVS after a verified sparse model (the original source images remain
+unchanged; the output directory must be new):
+
+```powershell
+$py = .\.venvs\05-3d-pointcloud\Scripts\python.exe
+& $py .\capabilities\05-3d-pointcloud\run_cpu_dense_reconstruction.py `
+  --input .\shared\data\raw\05-3d-pointcloud\h30t-sideview-20260820-v2 `
+  --sparse-model .\shared\outputs\05-3d-pointcloud\real-h30t-sideview-20260821-v2\sparse_model\0 `
+  --output .\shared\outputs\05-3d-pointcloud\cpu-mvs-full-<new-run-id> `
+  --openmvs-bin .\shared\tools\openmvs-2.4.0\vc17\x64\Release `
+  --threads 12 --max-resolution 2400 --dense-resolution-level 0 `
+  --dense-number-views 8 --dense-number-views-fuse 2 --target-faces 800000
+```
+
+WebODM/ODX-compatible RTK input preparation (metadata only; it neither starts
+WebODM nor reconstructs a model):
+
+```powershell
+$py = .\.venvs\05-3d-pointcloud\Scripts\python.exe
+& $py .\capabilities\05-3d-pointcloud\prepare_odx_reconstruction_inputs.py `
+  --input .\shared\data\raw\05-3d-pointcloud\h30t-sideview-20260820-v2 `
+  --output .\shared\outputs\05-3d-pointcloud\odx-input-h30t-sideview-<new-run-id> `
+  --webodm-reference .\reference_project\WebODM-master
+```
+
+The generated package contains `geo.txt` in ODX's `EPSG:4326` format, a
+camera-position GeoJSON, a source-hash manifest, recommended ODX parameters,
+and an explicit compatibility report. It reproduces the referenced ODX DJI
+conversion: `FlightYawDegree`, `90 + GimbalPitchDegree`, and
+`GimbalRollDegree`, with the reported RTK deviations doubled as ODX does.
+Leave ODX's global `--gps-accuracy` unset for this package because it would
+overwrite the per-photo horizontal/vertical deviations in `geo.txt`.
+
+The CPU pipeline performs voxel downsampling, RANSAC dominant-ground fitting,
+height-above-ground labelling, alpha-shape mesh reconstruction, rasterization,
+and GeoAI footprint vectorization. The output directory must be new or empty.
+
+Use `--ground-up-axis auto` only when the source-local Z axis is not vertical;
+it rotates a processing copy from the dominant RANSAC plane and records that
+rotation. The raw point cloud is never changed.
+
+## Input and output contract
+
+Existing-point-cloud input yields `*.classified.ply`, an alpha-shape
+`*.reconstruction.ply` when the geometry permits it, DSM and height GeoTIFFs,
+an elevated-surface GeoTIFF, a PNG raster preview, elevated-footprint GeoJSON,
+summary CSV, and fixed `run_metadata.json`.
+
+Photo input first yields a COLMAP sparse model, coloured `sparse_points.ply`, a
+principal-axis PNG preview, `camera_poses_local.csv`, an EXIF GPS input manifest,
+and fixed `run_metadata.json`. The verified CPU dense branch additionally yields
+`dense.ply`, a triangle `mesh.ply`, OpenMVS texture atlas PNGs, an original
+`textured.glb`, a self-contained `textured_preview_filtered.glb` for reliable
+texture inspection, and `geometry_preview.glb` for complete-surface inspection.
+The reliable-texture preview flips atlas pixels vertically to match browser sampling
+while preserving OpenMVS UV coordinates, then removes any triangle whose four
+interior samples reach black atlas background or OpenMVS's orange
+`RGB(255,127,39)` no-coverage colour. `textured_preview_filter_report.json`
+records retained and rejected faces. `geometry_preview.glb` keeps every mesh face
+with a neutral material: it does not invent colour or texture and separates
+geometric coverage from texture coverage. It records the registered
+images, sparse/dense point counts, mesh counts, source hashes and CPU parameters.
+When an OpenMVS dense PLY contains fused RGB, `point_color_preview.glb` also
+assigns each complete-mesh vertex the RGB of its nearest fused point. This keeps
+all mesh faces and uses observed point colours, but it is a nearest-point colour
+projection rather than a per-face photo texture.
+
+Default local-coordinate thresholds: voxel size `0.20`, RANSAC plane distance
+`0.15`, elevated height `0.75`. Review them against the source units and density.
+
+## Coordinates and limitations
+
+PLY/PCD/XYZ input normally has no declared CRS. Its output is explicitly
+`local_point_cloud_coordinates`; GeoTIFF/GeoJSON coordinates are not geographic
+coordinates, metres, or survey-grade locations. LAS/LAZ source CRS is not
+promoted in this first Demo, so it is also treated as local.
+
+The generated `validation-*` PLYs are known-geometry test fixtures, not field
+data or an accuracy dataset. `normal_site.ply` checks a dense flat site;
+`difficult_sparse_site.ply` checks sparse tilted terrain and isolated high
+noise. The difficult case is expected to show false elevated cells around noise:
+it validates handling and output, not semantic accuracy.
+
+Photo SfM uses `SIMPLE_RADIAL` camera parameters. Position priors can preserve
+the RTK-scale camera trajectory, but neither camera calibration nor exterior
+orientation is survey-grade. pycolmap's dense PatchMatch requires CUDA and
+remains unavailable, but the separate OpenMVS 2.4.0 Windows CPU binary has been
+verified for dense depth fusion, meshing and texturing. This does not create a
+DSM, orthomosaic, semantic class, GIS coordinate or survey-grade measurement
+claim.
+
+Real DJI H30T validation: 18 side-view photos from `2026-08-20` completed CPU
+sparse SfM in 140.832 seconds. All 18 photos registered into one camera group;
+the result has 11,975 coloured sparse points and 1.1992 px mean reprojection
+error. A six-photo representative CPU MVS check completed depth fusion, meshing
+and texturing in 112 seconds (232,060 dense points, 199,970 faces). The full
+18-photo CPU MVS run then completed in 461 seconds: 681,788 dense points and a
+399,932-face textured mesh. It uses a 1,600 px dense-image cap and 12 CPU
+threads, so it is a verified CPU baseline rather than a maximum-quality GPU run.
+A two-photo low-baseline check failed cleanly, documenting the need for sufficient
+baseline. Reliable-texture reprocessing of the full 18-photo output reuses the
+completed dense point cloud, disables mesh hole closing/smoothing, requests no
+texture outlier removal, and filters the browser preview as above. The new raw
+mesh has 346,878 faces; 128,772 faces (37.12%) pass the four-sample atlas check.
+The remaining faces are deliberately absent from the preview because the photos
+do not provide consistent texture there. This avoids orange/black placeholders,
+but does not create a watertight model or improve the original coverage.
+
+High-resolution CPU verification of the same 18 photos completed in 2,341.305
+seconds at 2,400 x 1,774 depth maps, level 0, eight neighbouring views, and a
+two-view fusion threshold. It produced 3,379,097 dense points and a 799,632-face
+mesh, but only 293,229 faces (36.67%) passed the reliable-texture check. The mesh
+itself has one 799,646-face main connected component, so the dominant visible
+holes in reliable-texture display are coverage rejections, not deletion of the
+geometric surface. The console exposes both modes; this does not prove that the
+complete geometry is accurate in vegetation or occluded areas.
+The same high-resolution result also has a 799,632-face point-colour preview;
+it is useful for inspecting coloured complete geometry, but must not be treated
+as an image-texture accuracy claim.
+
+Metric diagnostic for the RTK/fixed-focal high-resolution result rejects it for
+height or measurement use. The read-only
+`diagnose_photo_pose_alignment.py` script compares source DJI RTK/LRF XMP with
+a sparse model and optional OpenMVS `dense.ply`. For all 18 LRF target points,
+the latest dense cloud has a 13.3814 m median nearest-point distance, 11.9794 m
+median horizontal error, and -4.8131 m median signed vertical error. The RTK
+camera trajectory itself is materially better (0.5978 m median fit error), so
+the dense failure is not a viewer/texture issue. A fixed-pose sparse trial using
+RTK camera centres plus LRF target directions kept camera centres at 0 m error
+but reached 12.0193 px mean reprojection error, compared with 1.2105 px for the
+RTK-constrained SfM baseline. It therefore rejects the uncalibrated assumptions
+that the GNSS receiver is the optical centre and the LRF target is the RGB
+principal point; it must not be used as an MVS input.
+
+## ODX-aligned CPU result
+
+The original result above remains a preserved baseline. A new local sparse
+model uses an ODX-style configuration: `OPENCV` intrinsics (the local analogue
+of ODX brown calibration), RTK/GPS spatial matching with four neighbours,
+2,000 px feature extraction, 18,000 features, and a 0.05 m position-prior
+loss scale. Both a normal six-photo set and a difficult six-photo set registered
+6/6 images, with 0.7581 px and 0.7596 px mean reprojection error respectively.
+The complete 18-photo model registered 18/18 images with 23,084 sparse points
+and 0.8410 px mean reprojection error.
+
+Its CPU OpenMVS run at 2,400 px, eight views and two-view fusion completed in
+2,390.086 seconds. It has 3,756,551 dense points and a 799,645-face mesh. The
+reliable-texture browser GLB retains 293,614 faces (36.72%) after rejecting
+black/orange atlas placeholders; the point-colour and neutral complete-geometry
+GLBs both retain all 799,645 faces. It is available in the case library as
+`ODX 对齐 CPU 高分重建(H30T 18 图)`.
+
+The read-only DJI RTK/LRF diagnostic reports a 0.4038 m median nearest dense
+point distance for all 18 targets, with 0.3073 m horizontal and 0.0709 m signed
+vertical median error. This is a material improvement over the preserved old
+baseline, but the LRF observations are used here as a diagnostic rather than
+independent surveyed check points. The result is suitable for visual comparison
+with WebODM and continuing reconstruction evaluation; it is not a survey-grade
+DSM, orthomosaic, GIS-coordinate, semantic-class, or engineering measurement
+claim.
+
+## WebODM/ODX reference and acceptance gate
+
+The local `reference_project/WebODM-master` was inspected to understand the
+comparison output. WebODM is the task/UI service; the current reference's
+default processing node is `webodm/nodeodx` (NodeODX), which runs the actual
+OpenSfM/OpenDroneMap pipeline. It accepts `geo.txt`, GCPs, and a verified
+`cameras.json`; it can also lock verified camera parameters during bundle
+adjustment. These are substantive inputs, not display settings that can be
+copied into the current COLMAP/OpenMVS code.
+
+The new `prepare_odx_reconstruction_inputs.py` produces a checked input package
+from DJI metadata. For the supplied H30T sequence, the verified package at
+`shared/outputs/05-3d-pointcloud/odx-input-h30t-sideview-20260822-v2/` contains
+18 camera records. Its median per-photo ODX horizontal/vertical observations
+are 0.00472 m and 0.01162 m respectively. It does not create a guessed
+`cameras.json`, and it excludes LRF target observations from GCP input.
+
+This workstation has neither Docker nor NodeODX/NodeODM available, so no claim
+is made that WebODM or ODX was executed locally. Before sending this photo set
+to an ODX worker or cloud service, provide one of: a camera-specific H30T RGB
+calibration plus antenna/camera lever-arm and boresight; or at least three
+surveyed stable GCPs, each marked in two or more images, with separate check
+points. Accept a resulting model only after it reports independent checkpoint
+residuals and they meet the intended measurement tolerance.
+
+Further accuracy improvement requires camera-specific H30T calibration and
+antenna-to-camera lever-arm/boresight data, or at least three stable surveyed
+3D control points marked in two or more images with separate check points. A
+uniform height translation is not a valid replacement for those inputs.
+
+The sparse PLY contains only `element vertex 11975`; it has no faces or normals.
+A viewer that triangulates it as a surface creates false sheets or spikes. The
+console renders sparse data strictly as coloured `THREE.Points`; dense MVS cases
+offer reliable-texture and complete-geometry modes with orbit, zoom, reset and
+optional wireframe. A point-colour mode can additionally project fused point RGB
+onto every mesh vertex. Complete geometry uses a neutral material and retains
+all mesh faces, while reliable texture retains only faces with image evidence.
+The original mesh and atlas files remain downloadable for review.
+The CPU mesh still has holes or potentially incorrect surfaces in occluded,
+vegetated, low-texture or reflective regions and cannot be assumed equivalent to
+a higher-resolution GPU/cloud output without visual review.
+
+The separate real input `shared/data/raw/05-3d-pointcloud/pcl-table-scene-20260821/table_scene_lms400.pcd`
+is the PCL tutorial's `table_scene_lms400` scan, downloaded unchanged from the
+PCL data repository (SHA-256 `E285D415641E0D9DE695B611DB874CC8FE995E8089B77A50D6056D24D8CBCC58`).
+Its 460,400 source points validate reading a real PCD and the `--ground-up-axis
+auto` processing-copy branch. It is a tabletop scan, not an aerial reconstruction
+or semantic truth set.
+
+Ground/elevated is a geometric split, not building, tree, equipment, or hazard
+recognition. Slopes, water, vertical dominant surfaces, dense vegetation, or a
+weak/no ground return can invalidate the RANSAC assumption. The mesh is an
+inspectable alpha-shape approximation and is not guaranteed watertight or
+metric-accurate. Obtain registered source data and manually reviewed truth before
+operational or commercial use.
+
+## Semantic point-cloud classification baseline
+
+The existing-point-cloud route now writes a separate CPU semantic-classification
+baseline. It uses RGB plus locally estimated ground/height, density, vertical
+span, and horizontal line continuity. It is still **B** work: `geoai-py` is used
+only for the existing elevated-surface raster-to-vector branch, not for a native
+point-cloud classifier.
+
+Classes are `ground`, `vegetation`, `building_structure`, `power_line`,
+`pole_tower`, and `other_unknown`. The last three names describe review outputs:
+the first version is deliberately a transparent rule baseline rather than a
+trained semantic model. It exports a colour-coded preview PLY (capped at 400,000
+points for the browser), semantic LAS class codes, class GeoTIFF/PNG, candidate
+GeoJSON, class-count CSV, and `run_metadata.json`.
+
+Real LAS check: the unchanged user input `baseData/las/part_01.las` contains
+10,473,127 points, RGB and return attributes, but every source LAS
+`classification` value is `0`; it is not semantic truth. Run
+`validation-las-part01-semantic-20260822-v11-original-rgb` used 0.20 local-unit voxel sampling
+and local-neighbourhood shape features (horizontal linearity for conductors and
+vertical linearity for pole/tower members). It completed in 73.586 seconds CPU
+with 1,370,984 output points: 356,170 ground, 714,122 vegetation, 1,031
+structure, 3,183 pole/tower candidates, 8,162 power-line candidates, and 288,316
+unknown. The preview retains every line/pole candidate before sampling the other
+classes, because uniform preview sampling hid sparse conductors. Candidate
+precision and recall remain unvalidated. Do not use these candidates as asset
+inventory, clearance, or inspection conclusions until a user reviews the browser
+preview and supplies labelled ground truth.
+
+The generated H30T dense cloud was also checked as
+`validation-h30t-dense-semantic-20260822-v2`: 3,756,551 input points became
+1,205,115 sampled points in 38.673 CPU seconds. Its dominant plane was too
+steep to be accepted as ground, so the output uses only the local low-point
+fallback and records `ground_plane_available: false`. It produced 181,436 ground,
+663,962 vegetation, 988 structure, 641 pole/tower candidates, zero power-line
+candidates and 358,088 unknown. This proves the technical route accepts the
+reconstructed PLY; it does not validate class accuracy for this side-view model.
+
+## Console
+
+The independent local experiment console exposes `GET/POST
+/api/3d-pointcloud/runs`. It accepts at most two PLY/PCD/XYZ/LAS/LAZ files for
+new geometry runs, generates a server-side run ID, preserves raw source bytes and
+SHA-256, and stores raw, processed, and output artifacts separately. Its case
+library also discovers sparse photo-SfM and dense CPU-MVS result directories.
+Sparse results display an interactive Three.js point viewer, registration
+statistics, poses, input manifest and downloads. Dense results display reliable
+texture, point-colour and complete-geometry modes, plus dense PLY, mesh PLY and
+texture downloads. The point-colour mode projects fused RGB point values to the
+complete mesh; the complete geometry mode retains all mesh faces with a neutral
+material. Neither fabricates a photo texture. The
+sparse viewer supports orbit, zoom, point-size adjustment and reset, and never triangulates PLY vertices. It
+has no connection to a drone product, product account, product API, or product
+database.
+
+The console keeps incompatible inputs in two workflow tabs. `照片三维重建`
+accepts 3-30 JPG/JPEG files from one coherent flight/camera sequence through
+bounded binary uploads, preserves source bytes and SHA-256 values in a new run
+directory, and starts a background CPU sparse-SfM then OpenMVS dense-MVS job.
+The page polls `queued`, `sparse_sfm`, `dense_mvs`, `complete`, or `failed` and
+automatically adds a completed result to the case library. Enable `GPS/RTK
+先验` only when all input photos have valid position metadata; it selects the
+verified spatial matching configuration. Disable it for ordinary cameras, which
+use exhaustive matching. `既有点云理解` remains the PLY/PCD/XYZ/LAS/LAZ-to-
+raster/vector route. Creating a new run or discovering its result does not
+rebuild the console; `npm run build` is only needed after changing Vue/TypeScript
+frontend source.
+
+The console separates this capability into four workspaces: photo 3D
+reconstruction; point-cloud geometry processing; point-cloud semantic
+classification; and annotation, training, and model application. Photo cases
+are isolated from point-cloud cases. Geometry and semantic workspaces each have
+their own filtered case library; a source run can appear in both only when it
+has both output families. The model workspace contains the linked annotation,
+training, and portable-model workflow without placing it below every semantic
+result.
+
+```powershell
+py -3.12 .\scripts\serve_workbench_console.py --port 6188
+```
+
+## Licenses
+
+- `geoai-py`: MIT. This does not grant rights for data or other dependencies.
+- Open3D: MIT; check release and bundled dependency notices before redistribution.
+- Laspy: BSD-3-Clause; Rasterio, GeoPandas, and Shapely retain their own licenses.
+- No model weights or third-party point-cloud dataset are bundled. Record the
+  licence of each survey/reconstruction source before commercial use.
+- The PCL tutorial scan remains subject to the upstream PCL data repository's
+  licensing and attribution terms; it is validation input only and is not
+  cleared here for commercial redistribution.
+- WebODM is AGPL-3.0. NodeODX/ODX, OpenSfM/OpenDroneMap, their images, and any
+  cloud processing service require separate license and data-handling review.
+
+## Human annotation and supervised CPU training
+
+The workbench now provides a separate human annotation and supervised training
+area. A semantic run writes a 400,000-point `*.semantic-annotation-source.ply`
+that preserves observed RGB and coordinates. Rule-coloured previews are never
+ used as model inputs or ground truth. Brush and rectangle selections are saved
+ as separate revisions under `shared/outputs/05-3d-pointcloud/annotations/<annotation-id>/`.
+ The annotation viewer renders observed RGB through an sRGB-correct, anti-aliased
+ circular point sprite. It separates navigation from annotation: browse mode
+ uses left-drag rotation, while brush/rectangle mode reserves left-drag for
+ labels and retains right-drag rotation, middle-drag panning and wheel zoom.
+ Middle-button auto-scroll is suppressed within the viewer. The rotate-centre
+ tool sets the camera target to a clicked visible point without creating a label.
+ The model-orientation control independently rotates the displayed model about
+ its centre on local X/Y/Z axes from 0 through 360 degrees. It is a viewer-only
+ transform: source coordinates, semantic outputs and saved point indices remain
+ unchanged, while point picking and rectangle selection use the transformed view.
+ A saved revision is never edited in place. If it contains an error, the console
+ can remove that selected revision after confirmation; it cannot delete source
+ LAS/PLY data, semantic results, or training outputs. Each revision records
+ source run/checksum, point indices, class codes and time.
+
+`train_pointcloud_semantic_model.py` trains a compact PointNet-style shared MLP
+from user-confirmed labels only. It runs locally with `--device cpu` and later
+uses the same code/data with `--device cuda`. Training requires two or more
+classes and at least 500 confirmed points per class, partitions XY blocks into
+train/validation/test, and writes `model.pt`, `metrics.json`, a predicted PLY,
+per-class precision/recall/F1 and a confusion matrix. Metrics apply only to the
+labelled source blocks and are not field-wide accuracy claims.
+
+CPU validation `validation-las-part01-semantic-20260822-v11-original-rgb` processed unchanged
+`baseData/las/part_01.las` in 73.586 seconds and created its 400,000-point RGB
+annotation source. Its 3,183 pole/tower and 8,162 power-line results remain rule
+candidates until users supply labels and supervised evaluation is completed.
+
+## Applying a trained model
+
+The supervised model can be applied to a new coloured PLY, PCD, LAS, or LAZ
+point cloud without retraining. This is still B work: PyTorch, Open3D, NumPy,
+and Laspy perform inference; `geoai-py` is not a built-in point-cloud semantic
+classifier.
+
+The input must contain readable observed RGB values. XYZ-only inputs fail rather
+than inventing colour features. Coordinates remain unchanged. XYZ features are
+normalised using the new input's own centre and extent, matching the feature
+definition used during training without forcing the old scene coordinates onto a
+new site.
+
+```powershell
+.\.venvs\05-3d-pointcloud\Scripts\python.exe `
+  .\capabilities\05-3d-pointcloud\apply_pointcloud_semantic_model.py `
+  --model .\shared\outputs\05-3d-pointcloud\training-runs\<model-id>\model.pt `
+  --input .\path\to\new-rgb-cloud.las `
+  --output .\shared\outputs\05-3d-pointcloud\model-inference-runs\<new-run-id> `
+  --device cpu
+```
+
+Each new output directory contains `predicted-semantic-preview.ply` (a
+class-aware deterministic browser preview capped at 400,000 points),
+`predicted-semantic-classified.las` (LAS classification codes),
+`class-counts.csv`, `prediction-summary.json`, and `run_metadata.json` with
+model/input SHA-256 values, versions, normalisation, class counts, runtime, and
+limitations. No vector asset layer is fabricated from semantic predictions.
+
+Verified CPU check on 2026-08-24: model
+`semantic-model-20260824-023343-571983` applied to the 400,000-point RGB
+annotation source in 1.608 seconds through the CLI. The local-console upload and
+background-job route was also verified, including PLY and classified-LAS artifact
+download. This verifies the workflow, not field accuracy. Its labelled spatial
+test F1 is vegetation 0.979, power line 0.815, and pole/tower 0.348; the latter
+has substantial false-positive risk and all output remains review candidates.
diff --git a/capabilities/05-3d-pointcloud/apply_pointcloud_semantic_model.py b/capabilities/05-3d-pointcloud/apply_pointcloud_semantic_model.py
new file mode 100644
index 0000000..dbd0ced
--- /dev/null
+++ b/capabilities/05-3d-pointcloud/apply_pointcloud_semantic_model.py
@@ -0,0 +1,191 @@
+"""Apply a human-labelled point-cloud semantic model to a new RGB point cloud.
+
+This script deliberately accepts only explicit model/input/output paths.  The
+workbench server supplies those paths from fixed directories; it never forwards
+browser paths or commands to this process.
+"""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import hashlib
+import json
+import sys
+import time
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import Any
+
+import laspy
+import numpy as np
+import open3d as o3d
+import torch
+
+from train_pointcloud_semantic_model import CLASS_SCHEMA, PointWiseNet
+
+
+PREVIEW_POINT_LIMIT = 400_000
+
+
+def sha256(path: Path) -> str:
+    digest = hashlib.sha256()
+    with path.open("rb") as stream:
+        for chunk in iter(lambda: stream.read(8 * 1024 * 1024), b""):
+            digest.update(chunk)
+    return digest.hexdigest()
+
+
+def source_has_rgb(path: Path) -> bool:
+    suffix = path.suffix.lower()
+    if suffix in {".las", ".laz"}:
+        header = laspy.read(path).header
+        return all(name in header.point_format.dimension_names for name in ("red", "green", "blue"))
+    if suffix == ".ply":
+        with path.open("rb") as stream:
+            header = stream.read(64 * 1024).decode("latin-1", errors="ignore").lower()
+        return "end_header" in header and all(f" {name}" in header for name in ("red", "green", "blue"))
+    if suffix == ".pcd":
+        with path.open("rb") as stream:
+            header = stream.read(64 * 1024).decode("latin-1", errors="ignore").lower()
+        fields = next((line for line in header.splitlines() if line.startswith("fields ")), "")
+        return " rgb" in f" {fields}" or " rgba" in f" {fields}"
+    return False
+
+
+def load_cloud(path: Path) -> tuple[np.ndarray, np.ndarray, Any | None]:
+    if path.suffix.lower() in {".las", ".laz"}:
+        las = laspy.read(path)
+        dimensions = set(las.point_format.dimension_names)
+        if not {"red", "green", "blue"}.issubset(dimensions):
+            raise ValueError("LAS/LAZ input has no RGB dimensions; this model requires observed RGB.")
+        xyz = np.column_stack((las.x, las.y, las.z)).astype(np.float32)
+        raw_rgb = np.column_stack((las.red, las.green, las.blue)).astype(np.float32)
+        maximum = float(raw_rgb.max())
+        if maximum <= 0:
+            raise ValueError("LAS/LAZ input RGB values are all zero; observed RGB is required.")
+        return xyz, np.clip(raw_rgb / max(maximum, 1.0), 0.0, 1.0), las
+    if not source_has_rgb(path):
+        raise ValueError("Input has no readable RGB fields; XYZ-only point clouds cannot use this trained model.")
+    cloud = o3d.io.read_point_cloud(str(path))
+    xyz = np.asarray(cloud.points, dtype=np.float32)
+    rgb = np.asarray(cloud.colors, dtype=np.float32)
+    if len(xyz) < 1 or xyz.shape != rgb.shape:
+        raise ValueError("Input point cloud has no readable XYZ/RGB points.")
+    return xyz, np.clip(rgb, 0.0, 1.0), None
+
+
+def preview_indices(predictions: np.ndarray, limit: int) -> np.ndarray:
+    """Deterministically retain every small predicted class before filling the budget."""
+    count = len(predictions)
+    if count <= limit:
+        return np.arange(count, dtype=np.int64)
+    rng = np.random.default_rng(42)
+    selected: list[np.ndarray] = []
+    remaining = limit
+    groups = sorted((np.flatnonzero(predictions == code) for code in np.unique(predictions)), key=len)
+    for group in groups:
+        keep = min(len(group), max(1, min(50_000, remaining // max(1, len(groups) - len(selected)))))
+        selected.append(group if keep == len(group) else np.sort(rng.choice(group, size=keep, replace=False)))
+        remaining -= keep
+    chosen = np.concatenate(selected)
+    if len(chosen) < limit:
+        mask = np.ones(count, dtype=bool)
+        mask[chosen] = False
+        fill = rng.choice(np.flatnonzero(mask), size=limit - len(chosen), replace=False)
+        chosen = np.concatenate((chosen, fill))
+    if len(chosen) > limit:
+        chosen = rng.choice(chosen, size=limit, replace=False)
+    return np.sort(chosen.astype(np.int64))
+
+
+def load_model(path: Path, device: torch.device) -> tuple[PointWiseNet, list[int]]:
+    payload = torch.load(path, map_location=device, weights_only=False)
+    if not isinstance(payload, dict) or payload.get("schema_version") != 1:
+        raise ValueError("Unsupported model schema.")
+    codes = payload.get("class_codes")
+    if not isinstance(codes, list) or len(codes) < 2 or any(not isinstance(code, int) or code not in CLASS_SCHEMA for code in codes):
+        raise ValueError("Model class schema is invalid.")
+    model = PointWiseNet(len(codes)).to(device)
+    try:
+        model.load_state_dict(payload["state_dict"], strict=True)
+    except (KeyError, RuntimeError) as exc:
+        raise ValueError("Model weights do not match the supported PointWiseNet architecture.") from exc
+    model.eval()
+    return model, codes
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="Apply a local point-cloud semantic model to a new RGB point cloud.")
+    parser.add_argument("--model", type=Path, required=True)
+    parser.add_argument("--input", type=Path, required=True)
+    parser.add_argument("--output", type=Path, required=True)
+    parser.add_argument("--device", choices={"cpu", "cuda"}, default="cpu")
+    parser.add_argument("--batch-size", type=int, default=4096)
+    args = parser.parse_args()
+    if args.device == "cuda" and not torch.cuda.is_available():
+        raise SystemExit("CUDA was requested but is unavailable.")
+    if args.batch_size < 1 or args.batch_size > 262_144:
+        raise SystemExit("Batch size must be between 1 and 262144.")
+    if not args.model.is_file() or not args.input.is_file():
+        raise SystemExit("Model or input point cloud is unavailable.")
+    if args.output.exists() and any(args.output.iterdir()):
+        raise SystemExit("Output directory must be new or empty.")
+
+    started = time.perf_counter()
+    device = torch.device(args.device)
+    model, class_codes = load_model(args.model, device)
+    xyz, rgb, source_las = load_cloud(args.input)
+    center = xyz.mean(axis=0)
+    scale = float(max(np.abs(xyz - center).max(), 1e-6))
+    features = np.column_stack(((xyz - center) / scale, rgb)).astype(np.float32)
+    pieces: list[np.ndarray] = []
+    with torch.no_grad():
+        for start in range(0, len(features), args.batch_size):
+            values = torch.from_numpy(features[start:start + args.batch_size]).to(device)
+            pieces.append(model(values).argmax(dim=1).cpu().numpy())
+    predictions = np.asarray([class_codes[index] for index in np.concatenate(pieces)], dtype=np.uint8)
+    counts = {int(code): int((predictions == code).sum()) for code in class_codes}
+
+    args.output.mkdir(parents=True, exist_ok=True)
+    preview_selection = preview_indices(predictions, PREVIEW_POINT_LIMIT)
+    preview = o3d.geometry.PointCloud()
+    preview.points = o3d.utility.Vector3dVector(xyz[preview_selection])
+    preview.colors = o3d.utility.Vector3dVector(np.asarray([CLASS_SCHEMA[int(code)]["color"] for code in predictions[preview_selection]], dtype=np.float64) / 255.0)
+    preview_path = args.output / "predicted-semantic-preview.ply"
+    if not o3d.io.write_point_cloud(str(preview_path), preview, write_ascii=False):
+        raise RuntimeError("Could not write predicted PLY preview.")
+
+    classified_las = args.output / "predicted-semantic-classified.las"
+    if source_las is not None:
+        source_las.classification = predictions
+        source_las.write(classified_las)
+    else:
+        header = laspy.LasHeader(point_format=3, version="1.2")
+        header.offsets = xyz.min(axis=0)
+        header.scales = np.array([0.001, 0.001, 0.001])
+        generated = laspy.LasData(header)
+        generated.x, generated.y, generated.z = xyz[:, 0], xyz[:, 1], xyz[:, 2]
+        generated.red = np.rint(rgb[:, 0] * 65535).astype(np.uint16)
+        generated.green = np.rint(rgb[:, 1] * 65535).astype(np.uint16)
+        generated.blue = np.rint(rgb[:, 2] * 65535).astype(np.uint16)
+        generated.classification = predictions
+        generated.write(classified_las)
+
+    csv_path = args.output / "class-counts.csv"
+    with csv_path.open("w", newline="", encoding="utf-8") as stream:
+        writer = csv.DictWriter(stream, fieldnames=["class_code", "class_key", "class_label", "point_count"])
+        writer.writeheader()
+        for code in class_codes:
+            writer.writerow({"class_code": code, "class_key": CLASS_SCHEMA[code]["key"], "class_label": CLASS_SCHEMA[code]["label"], "point_count": counts[code]})
+    summary = {"class_codes": class_codes, "class_counts": {str(code): counts[code] for code in class_codes}, "input_points": int(len(xyz)), "preview_points": int(len(preview_selection)), "preview_sampling": "deterministic class-aware cap; smaller predicted classes retained before the remaining budget is sampled", "input_has_rgb": True}
+    summary_path = args.output / "prediction-summary.json"
+    summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
+    metadata = {"capability": "05-3d-pointcloud", "classification": "B", "created_at": datetime.now(UTC).isoformat(), "model": {"path": str(args.model), "sha256": sha256(args.model), "architecture": "PointWiseNet shared MLP"}, "input": {"path": str(args.input), "sha256": sha256(args.input), "bytes": args.input.stat().st_size, "points": int(len(xyz)), "has_rgb": True}, "classes": {str(code): CLASS_SCHEMA[code] for code in class_codes}, "prediction": summary, "processing": {"device": args.device, "batch_size": args.batch_size, "normalization": {"method": "source-local per input", "xyz_center": center.tolist(), "xyz_scale": scale}}, "versions": {"python": sys.version.split()[0], "torch": torch.__version__, "open3d": o3d.__version__, "laspy": laspy.__version__}, "artifacts": {"preview": preview_path.name, "classified_las": classified_las.name, "class_counts": csv_path.name, "summary": summary_path.name}, "elapsed_seconds": round(time.perf_counter() - started, 3), "limitations": ["Predictions are model candidates, not asset inventory or inspection conclusions.", "This model requires observed RGB; it cannot infer labels for XYZ-only point clouds.", "Model metrics apply only to the labelled source spatial blocks. The current pole/tower class has high false-positive risk and requires review.", "New inputs are normalized with their own XYZ centre and scale to match the training feature definition; this preserves their coordinates but does not prove cross-site generalization."]}
+    (args.output / "run_metadata.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
+    print(json.dumps(metadata, ensure_ascii=False))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/capabilities/05-3d-pointcloud/diagnose_photo_pose_alignment.py b/capabilities/05-3d-pointcloud/diagnose_photo_pose_alignment.py
new file mode 100644
index 0000000..b75d046
--- /dev/null
+++ b/capabilities/05-3d-pointcloud/diagnose_photo_pose_alignment.py
@@ -0,0 +1,279 @@
+"""Diagnose the agreement between DJI RTK/LRF metadata and a COLMAP model.
+
+The script is read-only: it does not modify source photos, databases, or a
+reconstruction.  DJI H30T photos can contain both the RTK camera position and
+the laser rangefinder target position.  Together they provide an independent
+external-orientation check that is more informative than reprojection error.
+"""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import json
+import math
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+from PIL import Image
+import pycolmap
+from scipy.spatial import cKDTree
+
+
+REQUIRED_XMP_FIELDS = (
+    "GpsLatitude",
+    "GpsLongitude",
+    "AbsoluteAltitude",
+    "LRFTargetLat",
+    "LRFTargetLon",
+    "LRFTargetAbsAlt",
+    "LRFTargetDistance",
+)
+
+
+def xmp_record(path: Path) -> dict[str, Any]:
+    """Read the relevant DJI XMP fields without rewriting the original JPEG."""
+    with Image.open(path) as image:
+        xmp = image.getxmp()
+    description = xmp.get("xmpmeta", {}).get("RDF", {}).get("Description", {})
+    record: dict[str, Any] = {"image": path.name}
+    for key in (*REQUIRED_XMP_FIELDS, "GimbalYawDegree", "GimbalPitchDegree", "GimbalRollDegree"):
+        value = description.get(key)
+        try:
+            record[key] = float(value) if value not in (None, "") else None
+        except (TypeError, ValueError):
+            record[key] = None
+    missing = [field for field in REQUIRED_XMP_FIELDS if record[field] is None]
+    if missing:
+        raise ValueError(f"{path.name} is missing DJI RTK/LRF XMP fields: {', '.join(missing)}")
+    return record
+
+
+def wgs84_to_enu(points: np.ndarray, origin: np.ndarray) -> np.ndarray:
+    """Convert [lat, lon, ellipsoidal/absolute height] coordinates to local ENU."""
+    # WGS-84 ECEF followed by the conventional east/north/up rotation.
+    semi_major = 6378137.0
+    eccentricity_sq = 6.6943799901413165e-3
+    latitude = np.deg2rad(points[:, 0])
+    longitude = np.deg2rad(points[:, 1])
+    altitude = points[:, 2]
+    radius = semi_major / np.sqrt(1.0 - eccentricity_sq * np.sin(latitude) ** 2)
+    ecef = np.column_stack(
+        (
+            (radius + altitude) * np.cos(latitude) * np.cos(longitude),
+            (radius + altitude) * np.cos(latitude) * np.sin(longitude),
+            (radius * (1.0 - eccentricity_sq) + altitude) * np.sin(latitude),
+        )
+    )
+    origin_latitude = math.radians(float(origin[0]))
+    origin_longitude = math.radians(float(origin[1]))
+    origin_altitude = float(origin[2])
+    origin_radius = semi_major / math.sqrt(1.0 - eccentricity_sq * math.sin(origin_latitude) ** 2)
+    origin_xyz = np.asarray(
+        [
+            (origin_radius + origin_altitude) * math.cos(origin_latitude) * math.cos(origin_longitude),
+            (origin_radius + origin_altitude) * math.cos(origin_latitude) * math.sin(origin_longitude),
+            (origin_radius * (1.0 - eccentricity_sq) + origin_altitude) * math.sin(origin_latitude),
+        ]
+    )
+    delta = ecef - origin_xyz
+    rotation = np.asarray(
+        [
+            [-math.sin(origin_longitude), math.cos(origin_longitude), 0.0],
+            [-math.sin(origin_latitude) * math.cos(origin_longitude), -math.sin(origin_latitude) * math.sin(origin_longitude), math.cos(origin_latitude)],
+            [math.cos(origin_latitude) * math.cos(origin_longitude), math.cos(origin_latitude) * math.sin(origin_longitude), math.sin(origin_latitude)],
+        ]
+    )
+    return delta @ rotation.T
+
+
+def similarity_alignment(source: np.ndarray, target: np.ndarray) -> tuple[float, np.ndarray, np.ndarray]:
+    """Find the non-reflective similarity transform target ~= scale * R * source + t."""
+    if source.shape != target.shape or source.ndim != 2 or source.shape[0] < 3:
+        raise ValueError("At least three paired 3D positions are required for alignment.")
+    source_mean = source.mean(axis=0)
+    target_mean = target.mean(axis=0)
+    source_centered = source - source_mean
+    target_centered = target - target_mean
+    covariance = target_centered.T @ source_centered / source.shape[0]
+    left, singular_values, right_t = np.linalg.svd(covariance)
+    correction = np.eye(3)
+    correction[-1, -1] = np.linalg.det(left @ right_t)
+    rotation = left @ correction @ right_t
+    variance = float(np.sum(source_centered**2) / source.shape[0])
+    if variance <= 0:
+        raise ValueError("Camera-position source variance is zero.")
+    scale = float(np.sum(singular_values * np.diag(correction)) / variance)
+    translation = target_mean - scale * (rotation @ source_mean)
+    return scale, rotation, translation
+
+
+def angle_degrees(first: np.ndarray, second: np.ndarray) -> float:
+    first_norm = float(np.linalg.norm(first))
+    second_norm = float(np.linalg.norm(second))
+    if first_norm == 0 or second_norm == 0:
+        return float("nan")
+    cosine = float(np.clip(np.dot(first, second) / (first_norm * second_norm), -1.0, 1.0))
+    return math.degrees(math.acos(cosine))
+
+
+def describe(values: list[float]) -> dict[str, float]:
+    finite = np.asarray([value for value in values if math.isfinite(value)], dtype=float)
+    if not len(finite):
+        return {"count": 0, "median": float("nan"), "mean": float("nan"), "max": float("nan")}
+    return {"count": int(len(finite)), "median": round(float(np.median(finite)), 4), "mean": round(float(np.mean(finite)), 4), "max": round(float(np.max(finite)), 4)}
+
+
+def openmvs_dense_positions(path: Path) -> np.ndarray:
+    """Read OpenMVS's binary PLY positions without materializing its variable lists."""
+    with path.open("rb") as stream:
+        vertex_count: int | None = None
+        while True:
+            line = stream.readline()
+            if not line:
+                raise ValueError(f"OpenMVS dense PLY header is incomplete: {path}")
+            decoded = line.decode("ascii", errors="strict").strip()
+            if decoded.startswith("element vertex "):
+                vertex_count = int(decoded.rsplit(" ", 1)[-1])
+            if decoded == "end_header":
+                break
+        if vertex_count is None or vertex_count < 1:
+            raise ValueError(f"OpenMVS dense PLY has no vertices: {path}")
+        points = np.empty((vertex_count, 3), dtype=np.float32)
+        fixed_bytes = 27  # x/y/z, RGB, normal; list counts and payloads follow each record.
+        for index in range(vertex_count):
+            payload = stream.read(fixed_bytes)
+            if len(payload) != fixed_bytes:
+                raise ValueError(f"OpenMVS dense PLY ended inside vertex {index}: {path}")
+            points[index] = np.frombuffer(payload, dtype="<f4", count=3)
+            view_count_raw = stream.read(1)
+            if len(view_count_raw) != 1:
+                raise ValueError(f"OpenMVS dense PLY misses view indices at vertex {index}: {path}")
+            stream.seek(view_count_raw[0] * 4, 1)
+            weight_count_raw = stream.read(1)
+            if len(weight_count_raw) != 1:
+                raise ValueError(f"OpenMVS dense PLY misses view weights at vertex {index}: {path}")
+            stream.seek(weight_count_raw[0] * 4, 1)
+    return points
+
+
+def diagnose(input_dir: Path, sparse_model: Path, output_dir: Path, dense_ply: Path | None = None) -> dict[str, Any]:
+    if output_dir.exists() and any(output_dir.iterdir()):
+        raise ValueError("Output directory is not empty; use a new output directory.")
+    source_images = {path.name: path for path in input_dir.iterdir() if path.suffix.lower() in {".jpg", ".jpeg"}}
+    reconstruction = pycolmap.Reconstruction(sparse_model)
+    images = sorted(reconstruction.images.values(), key=lambda image: image.name)
+    if len(images) < 3:
+        raise ValueError("The sparse model must contain at least three registered images.")
+    absent = [image.name for image in images if image.name not in source_images]
+    if absent:
+        raise ValueError(f"Source image files are missing for registered model images: {', '.join(absent[:3])}")
+    records = [xmp_record(source_images[image.name]) for image in images]
+    camera_wgs = np.asarray([[record["GpsLatitude"], record["GpsLongitude"], record["AbsoluteAltitude"]] for record in records])
+    target_wgs = np.asarray([[record["LRFTargetLat"], record["LRFTargetLon"], record["LRFTargetAbsAlt"]] for record in records])
+    camera_enu = wgs84_to_enu(camera_wgs, camera_wgs[0])
+    target_enu = wgs84_to_enu(target_wgs, camera_wgs[0])
+    model_centers = np.asarray([image.projection_center() for image in images], dtype=float)
+    scale, rotation, translation = similarity_alignment(model_centers, camera_enu)
+    fitted_centers = (scale * (rotation @ model_centers.T)).T + translation
+    location_residuals = np.linalg.norm(fitted_centers - camera_enu, axis=1)
+    rows: list[dict[str, Any]] = []
+    optical_axis_errors: list[float] = []
+    target_range_errors: list[float] = []
+    target_direction_errors: list[float] = []
+    for index, (image, record) in enumerate(zip(images, records, strict=True)):
+        # COLMAP camera coordinates are right/down/forward, so [0, 0, 1] is the optical axis.
+        camera_from_world = image.cam_from_world().rotation.matrix()
+        axis_in_model = camera_from_world.T @ np.asarray([0.0, 0.0, 1.0])
+        axis_in_enu = rotation @ axis_in_model
+        to_lrf_target = target_enu[index] - camera_enu[index]
+        optical_error = angle_degrees(axis_in_enu, to_lrf_target)
+        optical_axis_errors.append(optical_error)
+        target_range = float(np.linalg.norm(to_lrf_target))
+        target_range_errors.append(abs(target_range - float(record["LRFTargetDistance"])))
+        bearing = math.degrees(math.atan2(to_lrf_target[0], to_lrf_target[1]))
+        pitch = math.degrees(math.atan2(to_lrf_target[2], float(np.hypot(to_lrf_target[0], to_lrf_target[1]))))
+        yaw_error = abs(((bearing - float(record["GimbalYawDegree"]) + 180.0) % 360.0) - 180.0) if record["GimbalYawDegree"] is not None else float("nan")
+        pitch_error = abs(pitch - float(record["GimbalPitchDegree"])) if record["GimbalPitchDegree"] is not None else float("nan")
+        target_direction_errors.extend((yaw_error, pitch_error))
+        rows.append(
+            {
+                "image": image.name,
+                "camera_east_m": round(float(camera_enu[index, 0]), 4),
+                "camera_north_m": round(float(camera_enu[index, 1]), 4),
+                "camera_up_m": round(float(camera_enu[index, 2]), 4),
+                "lrf_target_east_m": round(float(target_enu[index, 0]), 4),
+                "lrf_target_north_m": round(float(target_enu[index, 1]), 4),
+                "lrf_target_up_m": round(float(target_enu[index, 2]), 4),
+                "lrf_distance_xmp_m": record["LRFTargetDistance"],
+                "lrf_distance_coordinate_m": round(target_range, 4),
+                "lrf_distance_difference_m": round(target_range_errors[-1], 4),
+                "gimbal_yaw_xmp_deg": record["GimbalYawDegree"],
+                "gimbal_pitch_xmp_deg": record["GimbalPitchDegree"],
+                "target_bearing_from_coordinates_deg": round(bearing, 4),
+                "target_pitch_from_coordinates_deg": round(pitch, 4),
+                "sfm_to_lrf_optical_axis_error_deg": round(optical_error, 4),
+                "sfm_camera_position_fit_error_m": round(float(location_residuals[index]), 4),
+            }
+        )
+    dense_summary: dict[str, Any] | None = None
+    if dense_ply is not None:
+        if not dense_ply.is_file():
+            raise ValueError(f"Dense PLY does not exist: {dense_ply}")
+        target_model = ((rotation.T @ (target_enu - translation).T) / scale).T
+        dense_points = openmvs_dense_positions(dense_ply)
+        distances, nearest_indices = cKDTree(dense_points).query(target_model, workers=-1)
+        nearest_enu = (scale * (rotation @ dense_points[np.asarray(nearest_indices)].T)).T + translation
+        differences = nearest_enu - target_enu
+        horizontal_errors = np.linalg.norm(differences[:, :2], axis=1)
+        vertical_errors = differences[:, 2]
+        for row, distance, horizontal, vertical in zip(rows, distances, horizontal_errors, vertical_errors, strict=True):
+            row["dense_nearest_target_distance_m"] = round(float(distance) * scale, 4)
+            row["dense_nearest_target_horizontal_error_m"] = round(float(horizontal), 4)
+            row["dense_nearest_target_vertical_error_m"] = round(float(vertical), 4)
+        dense_summary = {
+            "source": str(dense_ply),
+            "point_count": int(len(dense_points)),
+            "nearest_dense_point_to_lrf_target_distance_m": describe((np.asarray(distances) * scale).tolist()),
+            "nearest_dense_point_to_lrf_target_horizontal_error_m": describe(horizontal_errors.tolist()),
+            "nearest_dense_point_to_lrf_target_signed_vertical_error_m": describe(vertical_errors.tolist()),
+        }
+    output_dir.mkdir(parents=True, exist_ok=True)
+    csv_path = output_dir / "pose_lrf_alignment.csv"
+    with csv_path.open("w", newline="", encoding="utf-8-sig") as handle:
+        writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
+        writer.writeheader()
+        writer.writerows(rows)
+    summary = {
+        "input_images": len(rows),
+        "coordinate_system": "local ENU metres, origin at first RTK camera position",
+        "similarity_alignment": {"scale": round(scale, 8), "rotation_model_to_enu": rotation.round(8).tolist(), "translation_m": translation.round(8).tolist()},
+        "camera_position_fit_error_m": describe(location_residuals.tolist()),
+        "sfm_optical_axis_to_lrf_target_error_deg": describe(optical_axis_errors),
+        "metadata_lrf_coordinate_range_difference_m": describe(target_range_errors),
+        "dense_lrf_target_check": dense_summary,
+        "interpretation": "Median optical-axis error above 3 degrees means the current SfM orientations disagree materially with DJI RTK/LRF exterior observations; do not launch another dense MVS run until an orientation-constrained sparse model is validated.",
+        "artifacts": {"per_image_csv": csv_path.name},
+    }
+    (output_dir / "diagnostic_summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2, allow_nan=False), encoding="utf-8")
+    return summary
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="Compare DJI RTK/LRF observations against a COLMAP sparse model.")
+    parser.add_argument("--input", type=Path, required=True, help="Original DJI JPG/JPEG directory.")
+    parser.add_argument("--sparse-model", type=Path, required=True, help="Registered COLMAP sparse-model directory.")
+    parser.add_argument("--output", type=Path, required=True, help="New or empty diagnostic output directory.")
+    parser.add_argument("--dense-ply", type=Path, help="Optional OpenMVS dense.ply to compare directly with LRF target positions.")
+    args = parser.parse_args()
+    try:
+        result = diagnose(args.input, args.sparse_model, args.output, args.dense_ply)
+    except (ValueError, RuntimeError, OSError) as error:
+        raise SystemExit(str(error)) from error
+    print(json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/capabilities/05-3d-pointcloud/generate_validation_inputs.py b/capabilities/05-3d-pointcloud/generate_validation_inputs.py
new file mode 100644
index 0000000..7fc4789
--- /dev/null
+++ b/capabilities/05-3d-pointcloud/generate_validation_inputs.py
@@ -0,0 +1,69 @@
+"""Generate reproducible point-cloud fixtures for the CPU Demo tests.
+
+The fixtures have known geometry and are not presented as field data.  The
+console accepts actual PLY/PCD/XYZ/LAS/LAZ products from a survey or an ODM
+reconstruction; these two inputs verify the processing contract before one is
+used on a larger collection.
+"""
+
+from __future__ import annotations
+
+import argparse
+from pathlib import Path
+
+import numpy as np
+import open3d as o3d
+
+
+def _write_cloud(path: Path, points: np.ndarray) -> None:
+    cloud = o3d.geometry.PointCloud()
+    cloud.points = o3d.utility.Vector3dVector(points)
+    colors = np.tile(np.array([[0.35, 0.62, 0.41]], dtype=float), (len(points), 1))
+    colors[points[:, 2] > 1.0] = [0.83, 0.34, 0.12]
+    cloud.colors = o3d.utility.Vector3dVector(colors)
+    if not o3d.io.write_point_cloud(str(path), cloud, write_ascii=False):
+        raise RuntimeError(f"Could not write {path}")
+
+
+def normal_scene(seed: int = 42) -> np.ndarray:
+    """A dense flat site with one roof and one rounded elevated object."""
+    rng = np.random.default_rng(seed)
+    ground_xy = rng.uniform(-12, 12, size=(5_000, 2))
+    ground = np.column_stack((ground_xy, rng.normal(0, 0.025, size=len(ground_xy))))
+    roof_xy = rng.uniform([-4.5, -3.0], [3.5, 3.0], size=(2_400, 2))
+    roof = np.column_stack((roof_xy, rng.normal(2.8, 0.035, size=len(roof_xy))))
+    wall_y = rng.uniform(-3, 3, size=600)
+    walls = np.vstack((
+        np.column_stack((np.full(600, -4.5), wall_y, rng.uniform(0, 2.8, 600))),
+        np.column_stack((np.full(600, 3.5), wall_y, rng.uniform(0, 2.8, 600))),
+    ))
+    crown = rng.normal([7.2, 6.0, 3.0], [1.0, 1.0, 0.7], size=(1_000, 3))
+    return np.vstack((ground, roof, walls, crown))
+
+
+def difficult_scene(seed: int = 7) -> np.ndarray:
+    """A sparse scene with tilted terrain, a small roof, and isolated high noise."""
+    rng = np.random.default_rng(seed)
+    ground_xy = rng.uniform(-12, 12, size=(750, 2))
+    ground_z = 0.035 * ground_xy[:, 0] - 0.02 * ground_xy[:, 1] + rng.normal(0, 0.06, len(ground_xy))
+    ground = np.column_stack((ground_xy, ground_z))
+    roof_xy = rng.uniform([-2.5, -2.0], [1.4, 1.7], size=(270, 2))
+    roof_z = 0.035 * roof_xy[:, 0] - 0.02 * roof_xy[:, 1] + 2.0 + rng.normal(0, 0.06, len(roof_xy))
+    roof = np.column_stack((roof_xy, roof_z))
+    noise_xy = rng.uniform(-12, 12, size=(35, 2))
+    noise = np.column_stack((noise_xy, rng.uniform(1.6, 4.2, len(noise_xy))))
+    return np.vstack((ground, roof, noise))
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="Generate known-geometry PLY validation fixtures.")
+    parser.add_argument("--output", type=Path, required=True)
+    args = parser.parse_args()
+    args.output.mkdir(parents=True, exist_ok=True)
+    _write_cloud(args.output / "normal_site.ply", normal_scene())
+    _write_cloud(args.output / "difficult_sparse_site.ply", difficult_scene())
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/capabilities/05-3d-pointcloud/prepare_odx_reconstruction_inputs.py b/capabilities/05-3d-pointcloud/prepare_odx_reconstruction_inputs.py
new file mode 100644
index 0000000..ad75788
--- /dev/null
+++ b/capabilities/05-3d-pointcloud/prepare_odx_reconstruction_inputs.py
@@ -0,0 +1,261 @@
+"""Prepare auditable WebODM/ODX inputs from DJI JPEG metadata.
+
+This is an input-preparation utility, not a reconstruction engine.  It mirrors
+the DJI XMP handling in the referenced ODX/OpenDroneMap source so that a
+separate ODX/WebODM worker can receive the same RTK and attitude observations.
+It never alters the source photographs.
+"""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import hashlib
+import json
+import shutil
+from dataclasses import asdict, dataclass
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import Any
+
+from PIL import Image
+
+
+IMAGE_SUFFIXES = {".jpg", ".jpeg"}
+
+
+@dataclass(frozen=True)
+class OdxPhoto:
+    """One complete ODX ``geo.txt`` observation derived from one source photo."""
+
+    image: str
+    longitude: float
+    latitude: float
+    altitude_m: float
+    yaw_deg: float
+    pitch_deg: float
+    roll_deg: float
+    horizontal_accuracy_m: float
+    vertical_accuracy_m: float
+    width: int
+    height: int
+    sha256: str
+    rtk_std_lat_raw: float
+    rtk_std_lon_raw: float
+    rtk_std_hgt_raw: float
+
+
+def collect_images(input_dir: Path) -> list[Path]:
+    if not input_dir.is_dir():
+        raise ValueError(f"Image directory does not exist: {input_dir}")
+    images = sorted(path for path in input_dir.iterdir() if path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES)
+    if len(images) < 2:
+        raise ValueError("ODX input preparation needs at least two JPG/JPEG images.")
+    return images
+
+
+def sha256(path: Path) -> str:
+    digest = hashlib.sha256()
+    with path.open("rb") as stream:
+        for chunk in iter(lambda: stream.read(1024 * 1024), b""):
+            digest.update(chunk)
+    return digest.hexdigest()
+
+
+def xmp_description(path: Path) -> dict[str, Any]:
+    with Image.open(path) as image:
+        payload = image.getxmp()
+    description = payload.get("xmpmeta", {}).get("RDF", {}).get("Description", {})
+    if not isinstance(description, dict):
+        raise ValueError(f"DJI XMP description is unavailable: {path.name}")
+    return description
+
+
+def read_float(description: dict[str, Any], field: str, image_name: str) -> float:
+    value = description.get(field)
+    try:
+        return float(value)
+    except (TypeError, ValueError) as exc:
+        raise ValueError(f"{image_name} is missing a numeric DJI XMP field: {field}") from exc
+
+
+def odx_orientation_from_dji(flight_yaw_deg: float, gimbal_pitch_deg: float, gimbal_roll_deg: float) -> tuple[float, float, float]:
+    """Match ODX's DJI conversion before ``compute_opk``.
+
+    ODX reads ``FlightYawDegree`` and the gimbal pitch/roll.  For DJI cameras,
+    it normalizes pitch with ``90 + GimbalPitchDegree`` before converting YPR to
+    OPK.  Writing those normalized values to ``geo.txt`` gives GeoFile the same
+    final orientation values it would have received from the source JPEG.
+    """
+
+    return flight_yaw_deg, 90.0 + gimbal_pitch_deg, gimbal_roll_deg
+
+
+def photo_record(path: Path) -> OdxPhoto:
+    description = xmp_description(path)
+    latitude = read_float(description, "GpsLatitude", path.name)
+    longitude = read_float(description, "GpsLongitude", path.name)
+    altitude = read_float(description, "AbsoluteAltitude", path.name)
+    flight_yaw = read_float(description, "FlightYawDegree", path.name)
+    gimbal_pitch = read_float(description, "GimbalPitchDegree", path.name)
+    gimbal_roll = read_float(description, "GimbalRollDegree", path.name)
+    std_lat = read_float(description, "RtkStdLat", path.name)
+    std_lon = read_float(description, "RtkStdLon", path.name)
+    std_hgt = read_float(description, "RtkStdHgt", path.name)
+    yaw, pitch, roll = odx_orientation_from_dji(flight_yaw, gimbal_pitch, gimbal_roll)
+    with Image.open(path) as image:
+        width, height = image.size
+    # This duplicates the referenced ODX DJI branch, including its deliberate
+    # twofold inflation of reported RTK standard deviations.
+    return OdxPhoto(
+        image=path.name,
+        longitude=longitude,
+        latitude=latitude,
+        altitude_m=altitude,
+        yaw_deg=yaw,
+        pitch_deg=pitch,
+        roll_deg=roll,
+        horizontal_accuracy_m=2.0 * max(std_lat, std_lon),
+        vertical_accuracy_m=2.0 * std_hgt,
+        width=width,
+        height=height,
+        sha256=sha256(path),
+        rtk_std_lat_raw=std_lat,
+        rtk_std_lon_raw=std_lon,
+        rtk_std_hgt_raw=std_hgt,
+    )
+
+
+def geo_line(record: OdxPhoto) -> str:
+    return "{image} {longitude:.9f} {latitude:.9f} {altitude_m:.4f} {yaw_deg:.5f} {pitch_deg:.5f} {roll_deg:.5f} {horizontal_accuracy_m:.5f} {vertical_accuracy_m:.5f}".format(**asdict(record))
+
+
+def write_geo_txt(records: list[OdxPhoto], path: Path) -> None:
+    """Write the source-supported ``EPSG:4326`` ODX/WebODM geo format."""
+
+    path.write_text("EPSG:4326\n" + "\n".join(geo_line(record) for record in records) + "\n", encoding="utf-8")
+
+
+def write_camera_positions_geojson(records: list[OdxPhoto], path: Path) -> None:
+    features = [
+        {
+            "type": "Feature",
+            "properties": {
+                "image": record.image,
+                "altitude_m": record.altitude_m,
+                "yaw_deg_odx": record.yaw_deg,
+                "pitch_deg_odx": record.pitch_deg,
+                "roll_deg_odx": record.roll_deg,
+                "horizontal_accuracy_m": record.horizontal_accuracy_m,
+                "vertical_accuracy_m": record.vertical_accuracy_m,
+                "source_sha256": record.sha256,
+            },
+            "geometry": {"type": "Point", "coordinates": [record.longitude, record.latitude, record.altitude_m]},
+        }
+        for record in records
+    ]
+    payload = {"type": "FeatureCollection", "name": "odx_camera_positions", "features": features}
+    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+
+
+def odx_option_recommendations(records: list[OdxPhoto]) -> dict[str, Any]:
+    h_acc = sorted(record.horizontal_accuracy_m for record in records)
+    v_acc = sorted(record.vertical_accuracy_m for record in records)
+    midpoint = len(records) // 2
+    return {
+        "engine": "WebODM/NodeODX (not installed or executed on this workstation)",
+        "recommendations": {
+            "geo": "geo.txt",
+            "feature-quality": "high",
+            "pc-quality": "high",
+            "min-num-features": 18000,
+            "matcher-neighbors": 4,
+            "camera-lens": "brown",
+        },
+        "conditional_options": {
+            "gps-accuracy": "Leave unset when using this geo.txt: ODX would overwrite its per-photo horizontal and vertical RTK accuracy values. Use only as an explicit fallback when those values are unavailable.",
+            "use-fixed-camera-params": "Enable only after a camera-specific cameras.json has been independently calibrated and checked.",
+            "cameras": "Do not supply a template or self-calibrated file as a trusted calibration. Supply only a verified H30T RGB camera calibration.",
+            "gcp": "Add at least three surveyed stable control points marked in two or more photos; retain independent checkpoints for acceptance.",
+        },
+        "accuracy_observation": {
+            "horizontal_accuracy_median": h_acc[midpoint],
+            "vertical_accuracy_median": v_acc[midpoint],
+            "source_rule": "ODX DJI parser uses max(RtkStdLat, RtkStdLon), RtkStdHgt, then doubles both standard deviations.",
+            "unit_assumption": "Values are recorded as metres, matching the referenced ODX parser. Confirm this against the DJI export specification before survey use.",
+        },
+    }
+
+
+def prepare(input_dir: Path, output_dir: Path, reference_root: Path | None = None) -> dict[str, Any]:
+    if output_dir.exists() and any(output_dir.iterdir()):
+        raise ValueError("Output directory is not empty; use a new output directory.")
+    records = [photo_record(path) for path in collect_images(input_dir)]
+    output_dir.mkdir(parents=True, exist_ok=True)
+    geo_path = output_dir / "geo.txt"
+    positions_path = output_dir / "camera_positions.geojson"
+    manifest_path = output_dir / "dji_odx_input_manifest.csv"
+    options_path = output_dir / "odx_options.recommendations.json"
+    write_geo_txt(records, geo_path)
+    write_camera_positions_geojson(records, positions_path)
+    with manifest_path.open("w", newline="", encoding="utf-8-sig") as stream:
+        writer = csv.DictWriter(stream, fieldnames=list(asdict(records[0])))
+        writer.writeheader()
+        writer.writerows(asdict(record) for record in records)
+    options = odx_option_recommendations(records)
+    options_path.write_text(json.dumps(options, ensure_ascii=False, indent=2), encoding="utf-8")
+    nodeodx_available = shutil.which("nodeodx") is not None or shutil.which("nodeodm") is not None
+    docker_available = shutil.which("docker") is not None
+    reference_description = ""
+    if reference_root is not None:
+        reference_description = str(reference_root)
+    report = {
+        "capability": "05-3d-pointcloud",
+        "classification": "B",
+        "created_at": datetime.now(UTC).isoformat(),
+        "purpose": "Generate WebODM/ODX-compatible metadata inputs without modifying DJI source JPEGs.",
+        "webodm_reference": {
+            "root": reference_description,
+            "implementation_observed": "WebODM delegates processing to NodeODX; ODX GeoFile accepts EPSG:4326 geo.txt fields filename longitude latitude altitude yaw pitch roll horizontal_accuracy vertical_accuracy.",
+            "license": "WebODM source repository: AGPL-3.0; confirm NodeODX/ODX and all downstream dependency licenses separately.",
+        },
+        "local_engine": {
+            "docker_available": docker_available,
+            "nodeodx_or_nodeodm_command_available": nodeodx_available,
+            "executed": False,
+            "conclusion": "This artifact prepares inputs only. It does not claim that WebODM, NodeODX, OpenSfM, or ODX ran locally.",
+        },
+        "artifacts": {
+            "geo": geo_path.name,
+            "camera_positions_vector": positions_path.name,
+            "input_manifest": manifest_path.name,
+            "odx_options": options_path.name,
+        },
+        "source_images": len(records),
+        "limitations": [
+            "The geo.txt attitudes reproduce ODX's DJI metadata normalization but do not calibrate antenna-to-camera lever arm, time synchronization, boresight, or rolling shutter.",
+            "LRF target fields are intentionally excluded; they are observations, not image ground-control points.",
+            "No cameras.json is generated because a self-calibrated or guessed camera file must not be presented as a verified calibration.",
+            "The existing CPU MVS result failed its independent LRF check; this input package does not make that result metric-accurate.",
+        ],
+    }
+    (output_dir / "webodm_compatibility_report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
+    return report
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="Prepare ODX/WebODM geo inputs from DJI RTK JPEG metadata.")
+    parser.add_argument("--input", required=True, type=Path, help="Directory of coherent DJI JPG/JPEG source photos.")
+    parser.add_argument("--output", required=True, type=Path, help="New or empty directory for the ODX input package.")
+    parser.add_argument("--webodm-reference", type=Path, help="Optional local WebODM source root recorded in the audit report.")
+    args = parser.parse_args()
+    try:
+        result = prepare(args.input, args.output, args.webodm_reference)
+    except (OSError, ValueError) as error:
+        raise SystemExit(str(error)) from error
+    print(json.dumps(result, ensure_ascii=False, indent=2))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/capabilities/05-3d-pointcloud/requirements.txt b/capabilities/05-3d-pointcloud/requirements.txt
index f8db713..13321a4 100644
--- a/capabilities/05-3d-pointcloud/requirements.txt
+++ b/capabilities/05-3d-pointcloud/requirements.txt
@@ -2,4 +2,6 @@
 open3d>=0.18,<1
 laspy[lazrs]>=2.5,<3
 trimesh>=4.4,<5
-
+pycolmap>=3.12,<5
+matplotlib>=3.9,<4
+scipy>=1.11,<2
diff --git a/capabilities/05-3d-pointcloud/run_cpu_dense_reconstruction.py b/capabilities/05-3d-pointcloud/run_cpu_dense_reconstruction.py
new file mode 100644
index 0000000..ade6837
--- /dev/null
+++ b/capabilities/05-3d-pointcloud/run_cpu_dense_reconstruction.py
@@ -0,0 +1,417 @@
+"""Run the verified OpenMVS CPU reconstruction path after a COLMAP sparse model.
+
+The source images stay unchanged. DJI MPO/JPEG source files are converted into an
+RGB JPEG processing copy because COLMAP's undistorter cannot read that container.
+"""
+
+from __future__ import annotations
+
+import argparse
+import base64
+import hashlib
+import io
+import json
+import struct
+import subprocess
+import time
+from datetime import UTC, datetime
+from pathlib import Path
+
+import pycolmap
+import trimesh
+import numpy as np
+from PIL import Image
+from scipy.spatial import cKDTree
+
+
+IMAGE_SUFFIXES = {".jpg", ".jpeg"}
+
+
+def sha256(path: Path) -> str:
+    with path.open("rb") as stream:
+        return hashlib.file_digest(stream, "sha256").hexdigest()
+
+
+def images_in(path: Path) -> list[Path]:
+    images = sorted(item for item in path.iterdir() if item.is_file() and item.suffix.lower() in IMAGE_SUFFIXES)
+    if len(images) < 3:
+        raise ValueError("CPU dense reconstruction needs at least three JPG/JPEG images.")
+    return images
+
+
+def run(command: list[str], cwd: Path) -> None:
+    completed = subprocess.run(command, cwd=cwd, text=True, capture_output=True, check=False)
+    (cwd / f"{Path(command[0]).stem}.stdout.log").write_text(completed.stdout, encoding="utf-8")
+    (cwd / f"{Path(command[0]).stem}.stderr.log").write_text(completed.stderr, encoding="utf-8")
+    if completed.returncode:
+        message = (completed.stderr or completed.stdout or "OpenMVS command failed.").strip()
+        raise RuntimeError(f"{Path(command[0]).name} failed: {message[-1000:]}")
+
+
+def ply_vertex_count(path: Path) -> int:
+    with path.open("rb") as stream:
+        for raw_line in stream:
+            line = raw_line.decode("ascii", errors="replace").strip()
+            if line.startswith("element vertex "):
+                return int(line.rsplit(" ", 1)[-1])
+            if line == "end_header":
+                break
+    raise ValueError(f"PLY vertex count is unavailable: {path}")
+
+
+def load_glb(source: Path) -> tuple[dict, list[bytes]]:
+    """Read a GLB and resolve its embedded or data-URI buffers."""
+    payload = source.read_bytes()
+    if len(payload) < 20:
+        raise ValueError(f"Invalid GLB (too short): {source}")
+    magic, version, total_length = struct.unpack_from("<4sII", payload)
+    if magic != b"glTF" or version != 2 or total_length != len(payload):
+        raise ValueError(f"Invalid GLB header: {source}")
+
+    chunks: list[tuple[bytes, bytes]] = []
+    offset = 12
+    while offset < len(payload):
+        if offset + 8 > len(payload):
+            raise ValueError(f"Invalid GLB chunk header: {source}")
+        length, chunk_type = struct.unpack_from("<I4s", payload, offset)
+        offset += 8
+        if offset + length > len(payload):
+            raise ValueError(f"Invalid GLB chunk length: {source}")
+        chunks.append((chunk_type, payload[offset:offset + length]))
+        offset += length
+    if offset != len(payload):
+        raise ValueError(f"Invalid GLB chunk alignment: {source}")
+
+    json_chunk = next((data for kind, data in chunks if kind == b"JSON"), None)
+    binary_chunk = next((data for kind, data in chunks if kind == b"BIN\x00"), None)
+    if json_chunk is None or binary_chunk is None:
+        raise ValueError(f"GLB must include JSON and BIN chunks: {source}")
+    document = json.loads(json_chunk.decode("utf-8").rstrip(" \t\r\n\x00"))
+    buffers = [binary_chunk]
+    for buffer in document.get("buffers", [])[1:]:
+        uri = buffer.get("uri", "")
+        if uri.startswith("data:") and "," in uri:
+            buffers.append(base64.b64decode(uri.split(",", 1)[1]))
+        elif uri:
+            buffers.append((source.parent / uri).read_bytes())
+        else:
+            raise ValueError(f"Missing GLB buffer URI in {source}")
+    return document, buffers
+
+
+def accessor_values(document: dict, buffers: list[bytes], accessor_index: int) -> list[tuple[float, ...] | tuple[int]]:
+    accessor = document["accessors"][accessor_index]
+    view = document["bufferViews"][accessor["bufferView"]]
+    component_type = accessor["componentType"]
+    value_type = accessor["type"]
+    if component_type == 5126 and value_type == "VEC2":
+        value_format, value_size = "<ff", 8
+    elif component_type == 5126 and value_type == "VEC3":
+        value_format, value_size = "<fff", 12
+    elif component_type == 5125 and value_type == "SCALAR":
+        value_format, value_size = "<I", 4
+    else:
+        raise ValueError(f"Unsupported GLB accessor: component={component_type}, type={value_type}")
+    stride = view.get("byteStride", value_size)
+    start = view.get("byteOffset", 0) + accessor.get("byteOffset", 0)
+    buffer = buffers[view.get("buffer", 0)]
+    end = start + (accessor["count"] - 1) * stride + value_size
+    if start < 0 or end > len(buffer) or "sparse" in accessor:
+        raise ValueError("Invalid or sparse GLB accessor")
+    return [struct.unpack_from(value_format, buffer, start + index * stride) for index in range(accessor["count"])]
+
+
+def image_bytes(document: dict, buffers: list[bytes], source: Path, image_index: int) -> bytes:
+    image = document["images"][image_index]
+    if "bufferView" in image:
+        view = document["bufferViews"][image["bufferView"]]
+        start = view.get("byteOffset", 0)
+        return buffers[view.get("buffer", 0)][start:start + view["byteLength"]]
+    return (source.parent / image["uri"]).read_bytes()
+
+
+def reliable_texture_pixel(pixel: tuple[int, int, int]) -> bool:
+    red, green, blue = pixel
+    if max(pixel) < 16:
+        return False
+    # OpenMVS's default --empty-color is RGB(255, 127, 39), including
+    # antialiased boundary variants around that placeholder colour.
+    return not (red >= 230 and 80 <= green <= 175 and blue <= 95)
+
+
+def build_filtered_preview_glb(source: Path, destination: Path, report_path: Path) -> dict:
+    """Export only faces with reliable texture samples into a self-contained GLB.
+
+    The OpenMVS atlas has a top-left image origin while the browser GLB loader
+    samples the image in the opposite vertical direction. The preview therefore
+    flips atlas pixels during export and retains the original UVs. Faces that
+    sample the black atlas background or OpenMVS's orange empty colour are
+    omitted instead of being presented as plausible reconstruction detail.
+    """
+    document, buffers = load_glb(source)
+    image_cache: dict[int, Image.Image] = {}
+    flipped_images: dict[int, bytes] = {}
+    for index in range(len(document.get("images", []))):
+        with Image.open(io.BytesIO(image_bytes(document, buffers, source, index))) as image:
+            converted = image.convert("RGB").transpose(Image.Transpose.FLIP_TOP_BOTTOM)
+            output = io.BytesIO()
+            converted.save(output, format="PNG", optimize=False)
+            flipped_images[index] = output.getvalue()
+            image_cache[index] = converted
+
+    binary = bytearray()
+    views: list[dict] = []
+    accessors: list[dict] = []
+
+    def append_view(payload: bytes | bytearray, target: int | None = None) -> int:
+        binary.extend(b"\x00" * ((-len(binary)) % 4))
+        offset = len(binary)
+        binary.extend(payload)
+        view: dict = {"buffer": 0, "byteOffset": offset, "byteLength": len(payload)}
+        if target is not None:
+            view["target"] = target
+        views.append(view)
+        return len(views) - 1
+
+    primitives: list[dict] = []
+    report_primitives: list[dict] = []
+    for mesh in document.get("meshes", []):
+        for primitive in mesh.get("primitives", []):
+            attributes = primitive.get("attributes", {})
+            if not {"POSITION", "TEXCOORD_0"}.issubset(attributes) or "indices" not in primitive:
+                continue
+            positions = accessor_values(document, buffers, attributes["POSITION"])
+            texcoords = accessor_values(document, buffers, attributes["TEXCOORD_0"])
+            indices = accessor_values(document, buffers, primitive["indices"])
+            if len(indices) % 3:
+                raise ValueError("GLB primitive index count is not divisible by three")
+            material_index = primitive.get("material", 0)
+            texture_index = document["materials"][material_index]["pbrMetallicRoughness"]["baseColorTexture"]["index"]
+            image_index = document["textures"][texture_index]["source"]
+            texture = image_cache[image_index]
+            kept_indices: list[int] = []
+            rejected_black = rejected_empty = 0
+            for offset in range(0, len(indices), 3):
+                triangle = [texcoords[indices[offset + vertex][0]] for vertex in range(3)]
+                samples = (
+                    (1 / 3, 1 / 3, 1 / 3),
+                    (0.6, 0.2, 0.2),
+                    (0.2, 0.6, 0.2),
+                    (0.2, 0.2, 0.6),
+                )
+                valid = True
+                saw_black = False
+                for weights in samples:
+                    u = sum(weights[index] * triangle[index][0] for index in range(3))
+                    v = sum(weights[index] * triangle[index][1] for index in range(3))
+                    pixel = texture.getpixel((round(min(texture.width - 1, max(0, u * (texture.width - 1)))), round(min(texture.height - 1, max(0, v * (texture.height - 1))))))
+                    if max(pixel) < 16:
+                        saw_black = True
+                    if not reliable_texture_pixel(pixel):
+                        valid = False
+                        break
+                if valid:
+                    kept_indices.extend(index[0] for index in indices[offset:offset + 3])
+                elif saw_black:
+                    rejected_black += 1
+                else:
+                    rejected_empty += 1
+            if not kept_indices:
+                continue
+            position_payload = b"".join(struct.pack("<fff", *position) for position in positions)
+            texcoord_payload = b"".join(struct.pack("<ff", *texcoord) for texcoord in texcoords)
+            index_payload = b"".join(struct.pack("<I", index) for index in kept_indices)
+            position_accessor = len(accessors)
+            accessors.append({"bufferView": append_view(position_payload, 34962), "componentType": 5126, "count": len(positions), "type": "VEC3", "min": [min(value[index] for value in positions) for index in range(3)], "max": [max(value[index] for value in positions) for index in range(3)]})
+            texcoord_accessor = len(accessors)
+            accessors.append({"bufferView": append_view(texcoord_payload, 34962), "componentType": 5126, "count": len(texcoords), "type": "VEC2", "min": [min(value[index] for value in texcoords) for index in range(2)], "max": [max(value[index] for value in texcoords) for index in range(2)]})
+            index_accessor = len(accessors)
+            accessors.append({"bufferView": append_view(index_payload, 34963), "componentType": 5125, "count": len(kept_indices), "type": "SCALAR", "min": [min(kept_indices)], "max": [max(kept_indices)]})
+            primitives.append({"attributes": {"POSITION": position_accessor, "TEXCOORD_0": texcoord_accessor}, "indices": index_accessor, "material": material_index, "mode": 4})
+            report_primitives.append({"material_index": material_index, "input_faces": len(indices) // 3, "kept_faces": len(kept_indices) // 3, "rejected_black_faces": rejected_black, "rejected_empty_color_faces": rejected_empty})
+    if not primitives:
+        raise ValueError("No reliably textured faces remain after filtering")
+
+    image_views = []
+    for image_index in range(len(document["images"])):
+        image_views.append(append_view(flipped_images[image_index]))
+    output_document = {
+        "asset": {"version": "2.0", "generator": "geoai-workbench reliable-texture preview"},
+        "buffers": [{"byteLength": len(binary)}],
+        "bufferViews": views,
+        "accessors": accessors,
+        "images": [{"bufferView": image_views[index], "mimeType": "image/png"} for index in range(len(image_views))],
+        "textures": [{"source": index} for index in range(len(image_views))],
+        "materials": [{"name": material.get("name", f"material-{index}"), "pbrMetallicRoughness": {"baseColorTexture": {"index": material["pbrMetallicRoughness"]["baseColorTexture"]["index"]}, "metallicFactor": 0.0}, "doubleSided": True} for index, material in enumerate(document["materials"])],
+        "meshes": [{"primitives": primitives}],
+        "nodes": [{"mesh": 0}],
+        "scenes": [{"nodes": [0]}],
+        "scene": 0,
+    }
+    report = {
+        "source_glb": source.name,
+        "preview_glb": destination.name,
+        "texture_origin_fix": "atlas_pixels_flipped_vertically; original OpenMVS UV coordinates preserved",
+        "filter": "remove faces with any interior sample in black atlas background or OpenMVS RGB(255,127,39) empty colour",
+        "primitives": report_primitives,
+        "input_faces": sum(item["input_faces"] for item in report_primitives),
+        "kept_faces": sum(item["kept_faces"] for item in report_primitives),
+        "rejected_black_faces": sum(item["rejected_black_faces"] for item in report_primitives),
+        "rejected_empty_color_faces": sum(item["rejected_empty_color_faces"] for item in report_primitives),
+    }
+    report["kept_face_ratio"] = report["kept_faces"] / report["input_faces"]
+    report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
+    json_payload = json.dumps(output_document, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
+    json_payload += b" " * ((-len(json_payload)) % 4)
+    binary.extend(b"\x00" * ((-len(binary)) % 4))
+    output = bytearray(struct.pack("<4sII", b"glTF", 2, 0))
+    output.extend(struct.pack("<I4s", len(json_payload), b"JSON"))
+    output.extend(json_payload)
+    output.extend(struct.pack("<I4s", len(binary), b"BIN\x00"))
+    output.extend(binary)
+    struct.pack_into("<I", output, 8, len(output))
+    destination.write_bytes(output)
+    return report
+
+
+def build_geometry_preview_glb(source: Path, destination: Path) -> int:
+    """Export the complete reconstructed surface without inferred texture colours."""
+    mesh = trimesh.load_mesh(source, process=False)
+    if not isinstance(mesh, trimesh.Trimesh) or not len(mesh.faces):
+        raise ValueError(f"Mesh preview requires triangular faces: {source}")
+    preview = trimesh.Trimesh(
+        vertices=mesh.vertices,
+        faces=mesh.faces,
+        vertex_normals=mesh.vertex_normals if len(mesh.vertex_normals) == len(mesh.vertices) else None,
+        process=False,
+    )
+    destination.write_bytes(trimesh.Scene(preview).export(file_type="glb"))
+    return len(preview.faces)
+
+
+def load_openmvs_dense_points_with_colors(source: Path) -> tuple[np.ndarray, np.ndarray]:
+    """Read positions and RGB from OpenMVS's variable-length binary PLY records."""
+    with source.open("rb") as stream:
+        vertex_count: int | None = None
+        while True:
+            line = stream.readline()
+            if not line:
+                raise ValueError(f"OpenMVS dense PLY header is incomplete: {source}")
+            decoded = line.decode("ascii", errors="strict").strip()
+            if decoded.startswith("element vertex "):
+                vertex_count = int(decoded.rsplit(" ", 1)[-1])
+            if decoded == "end_header":
+                break
+        if vertex_count is None or vertex_count < 1:
+            raise ValueError(f"OpenMVS dense PLY has no vertices: {source}")
+        positions = np.empty((vertex_count, 3), dtype=np.float32)
+        colors = np.empty((vertex_count, 3), dtype=np.uint8)
+        fixed_vertex = struct.Struct("<fffBBBfff")
+        for index in range(vertex_count):
+            payload = stream.read(fixed_vertex.size)
+            if len(payload) != fixed_vertex.size:
+                raise ValueError(f"OpenMVS dense PLY ended inside vertex {index}: {source}")
+            x, y, z, red, green, blue, *_ = fixed_vertex.unpack(payload)
+            positions[index] = (x, y, z)
+            colors[index] = (red, green, blue)
+            view_count = stream.read(1)
+            if len(view_count) != 1:
+                raise ValueError(f"OpenMVS dense PLY misses view indices at vertex {index}: {source}")
+            stream.seek(view_count[0] * 4, 1)
+            weight_count = stream.read(1)
+            if len(weight_count) != 1:
+                raise ValueError(f"OpenMVS dense PLY misses view weights at vertex {index}: {source}")
+            stream.seek(weight_count[0] * 4, 1)
+    return positions, colors
+
+
+def build_point_color_preview_glb(dense_source: Path, mesh_source: Path, destination: Path) -> int:
+    """Colour every mesh vertex from the nearest fused RGB point without fabricating texture."""
+    points, colors = load_openmvs_dense_points_with_colors(dense_source)
+    mesh = trimesh.load_mesh(mesh_source, process=False)
+    if not isinstance(mesh, trimesh.Trimesh) or not len(mesh.faces):
+        raise ValueError(f"Point-colour preview requires triangular faces: {mesh_source}")
+    _, nearest = cKDTree(points).query(mesh.vertices, workers=-1)
+    rgba = np.column_stack((colors[np.asarray(nearest, dtype=np.intp)], np.full(len(mesh.vertices), 255, dtype=np.uint8)))
+    preview = trimesh.Trimesh(vertices=mesh.vertices, faces=mesh.faces, vertex_colors=rgba, process=False)
+    destination.write_bytes(trimesh.Scene(preview).export(file_type="glb"))
+    return len(preview.faces)
+
+
+def resolve_external_tool_paths(args: argparse.Namespace) -> None:
+    """Make subprocess arguments independent from the output working directory."""
+    for field in ("input", "sparse_model", "output", "openmvs_bin"):
+        setattr(args, field, getattr(args, field).resolve())
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="Create an OpenMVS dense point cloud, mesh and texture using only CPU.")
+    parser.add_argument("--input", type=Path, required=True, help="Original coherent JPG/JPEG sequence.")
+    parser.add_argument("--sparse-model", type=Path, required=True, help="COLMAP binary sparse model directory.")
+    parser.add_argument("--output", type=Path, required=True, help="New or empty result directory.")
+    parser.add_argument("--openmvs-bin", type=Path, required=True, help="Directory containing OpenMVS Windows executables.")
+    parser.add_argument("--threads", type=int, default=12)
+    parser.add_argument("--max-resolution", type=int, default=1600)
+    parser.add_argument("--dense-resolution-level", type=int, default=2, help="OpenMVS depth-map pyramid level; 0 uses the capped source resolution.")
+    parser.add_argument("--dense-min-resolution", type=int, default=640)
+    parser.add_argument("--dense-number-views", type=int, default=5, help="Neighbouring cameras used for each depth estimate.")
+    parser.add_argument("--dense-number-views-fuse", type=int, default=2)
+    parser.add_argument("--target-faces", type=int, default=400000)
+    parser.add_argument("--mesh-close-holes", type=int, default=0)
+    parser.add_argument("--mesh-smooth", type=int, default=0)
+    parser.add_argument("--texture-outlier-threshold", type=float, default=0.0)
+    args = parser.parse_args()
+    resolve_external_tool_paths(args)
+    if args.threads < 1 or args.max_resolution < 640 or args.dense_resolution_level < 0 or args.dense_min_resolution < 1 or args.dense_number_views < 2 or args.dense_number_views_fuse < 2 or args.dense_number_views_fuse > args.dense_number_views or args.target_faces < 10000 or args.mesh_close_holes < 0 or args.mesh_smooth < 0 or args.texture_outlier_threshold < 0:
+        raise SystemExit("Invalid processing limits; thread count, mesh values and texture threshold must be non-negative, with max resolution at least 640 and target faces at least 10000.")
+    if args.output.exists() and any(args.output.iterdir()):
+        raise SystemExit("Output directory is not empty; use a new output directory.")
+    tools = {name: args.openmvs_bin / f"{name}.exe" for name in ("InterfaceCOLMAP", "DensifyPointCloud", "ReconstructMesh", "TextureMesh")}
+    if any(not tool.is_file() for tool in tools.values()):
+        raise SystemExit("OpenMVS executable directory is incomplete.")
+    images = images_in(args.input)
+    reconstruction = pycolmap.Reconstruction(args.sparse_model)
+    if len(reconstruction.images) < 3:
+        raise SystemExit("Sparse model has fewer than three registered images.")
+    args.output.mkdir(parents=True, exist_ok=True)
+    started = time.perf_counter()
+    processed = args.output / "processed_rgb_images"
+    processed.mkdir()
+    manifest = []
+    for source in images:
+        with Image.open(source) as image:
+            image.convert("RGB").save(processed / source.name, quality=100, subsampling=0, optimize=False)
+            manifest.append({"image": source.name, "source_bytes": source.stat().st_size, "source_sha256": sha256(source), "source_size": list(image.size), "source_mode": image.mode})
+    (processed / "conversion_manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
+    undistorted = args.output / "undistorted_rgb"
+    pycolmap.undistort_images(undistorted, args.sparse_model, processed, num_threads=args.threads, jpeg_quality=95)
+    scene = args.output / "scene.mvs"
+    run([str(tools["InterfaceCOLMAP"]), "--input-file", str(undistorted), "--image-folder", str(undistorted / "images"), "--output-file", str(scene), "--max-threads", str(args.threads)], args.output)
+    dense = args.output / "dense.mvs"
+    run([str(tools["DensifyPointCloud"]), "--input-file", str(scene), "--output-file", str(dense), "--max-threads", str(args.threads), "--resolution-level", str(args.dense_resolution_level), "--max-resolution", str(args.max_resolution), "--min-resolution", str(args.dense_min_resolution), "--number-views", str(args.dense_number_views), "--number-views-fuse", str(args.dense_number_views_fuse), "--iters", "3", "--geometric-iters", "2", "--postprocess-dmaps", "5", "--filter-point-cloud", "1", "--tower-mode", "0"], args.output)
+    mesh = args.output / "mesh.mvs"
+    run([str(tools["ReconstructMesh"]), "--input-file", str(dense), "--output-file", str(mesh), "--max-threads", str(args.threads), "--target-face-num", str(args.target_faces), "--remove-spurious", "20", "--remove-spikes", "1", "--close-holes", str(args.mesh_close_holes), "--smooth", str(args.mesh_smooth)], args.output)
+    textured = args.output / "textured.mvs"
+    run([str(tools["TextureMesh"]), "--input-file", str(dense), "--mesh-file", str(args.output / "mesh.ply"), "--output-file", str(textured), "--export-type", "glb", "--max-threads", str(args.threads), "--resolution-level", "0", "--min-resolution", "640", "--max-texture-size", "4096", "--close-holes", "0", "--outlier-threshold", str(args.texture_outlier_threshold)], args.output)
+    textured_scene = trimesh.load(args.output / "textured.glb", force="scene")
+    (args.output / "textured_embedded.glb").write_bytes(textured_scene.export(file_type="glb"))
+    geometry_preview_faces = build_geometry_preview_glb(args.output / "mesh.ply", args.output / "geometry_preview.glb")
+    point_color_preview_faces = build_point_color_preview_glb(args.output / "dense.ply", args.output / "mesh.ply", args.output / "point_color_preview.glb")
+    preview_report = build_filtered_preview_glb(args.output / "textured.glb", args.output / "textured_preview_filtered.glb", args.output / "textured_preview_filter_report.json")
+    geometry = list(textured_scene.geometry.values())
+    texture_files = sorted(path.name for path in args.output.glob("textured_*.png"))
+    metadata = {
+        "capability": "05-3d-pointcloud", "classification": "B", "created_at": datetime.now(UTC).isoformat(),
+        "method": "COLMAP CPU sparse SfM, RGB processing-copy undistortion, OpenMVS CPU PatchMatch depth fusion, mesh reconstruction, and texture atlas export",
+        "model": "none (classical multi-view stereo CPU baseline)", "device": f"CPU (OpenMVS max_threads={args.threads})",
+        "thresholds": {"max_threads": args.threads, "dense_resolution_level": args.dense_resolution_level, "dense_max_resolution_px": args.max_resolution, "dense_min_resolution_px": args.dense_min_resolution, "dense_number_views": args.dense_number_views, "dense_fusion_min_views": args.dense_number_views_fuse, "mesh_target_faces": args.target_faces, "mesh_close_holes": args.mesh_close_holes, "mesh_smooth": args.mesh_smooth, "texture_outlier_threshold": args.texture_outlier_threshold},
+        "dense_photo_reconstruction": {"input_images": len(images), "registered_images": len(reconstruction.images), "sparse_points": len(reconstruction.points3D), "dense_points": ply_vertex_count(args.output / "dense.ply"), "dense_point_cloud_file": "dense.ply", "mesh_file": "mesh.ply", "textured_model_file": "textured_preview_filtered.glb", "geometry_preview_model_file": "geometry_preview.glb", "geometry_preview_faces": geometry_preview_faces, "point_color_preview_model_file": "point_color_preview.glb", "point_color_preview_faces": point_color_preview_faces, "source_textured_model_file": "textured.glb", "embedded_source_textured_model_file": "textured_embedded.glb", "texture_preview_filter_report": "textured_preview_filter_report.json", "texture_file": texture_files[0] if texture_files else None, "texture_files": texture_files, "mesh_vertices": sum(len(item.vertices) for item in geometry), "mesh_faces": sum(len(item.faces) for item in geometry), "preview_mesh_faces": preview_report["kept_faces"], "preview_mesh_face_ratio": preview_report["kept_face_ratio"], "coordinate_basis": "local_sfm_coordinates_arbitrary_scale_and_orientation", "processed_rgb_conversion_manifest": "processed_rgb_images/conversion_manifest.json"},
+        "elapsed_seconds": round(time.perf_counter() - started, 3),
+        "limitations": ["CPU MVS baseline only; review visual quality before raising resolution or treating the output as operational.", "Source imagery remains unchanged; processing copies have no survey-grade coordinate claim.", "OpenMVS is AGPL-3.0-only and needs a separate commercial license assessment."],
+    }
+    (args.output / "run_metadata.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
+    print(json.dumps(metadata, ensure_ascii=False, indent=2))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/capabilities/05-3d-pointcloud/run_photo_reconstruction.py b/capabilities/05-3d-pointcloud/run_photo_reconstruction.py
new file mode 100644
index 0000000..bc69443
--- /dev/null
+++ b/capabilities/05-3d-pointcloud/run_photo_reconstruction.py
@@ -0,0 +1,338 @@
+"""CPU sparse SfM reconstruction from a coherent photo sequence.
+
+This is deliberately a sparse reconstruction.  COLMAP's dense PatchMatch
+stage requires CUDA in the supported pycolmap build, which is unavailable on
+this workstation.  The output point cloud has an arbitrary SfM scale and
+orientation unless a separately verified control/registration workflow is
+supplied.
+"""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import hashlib
+import json
+import time
+from datetime import UTC, datetime
+from importlib import metadata as importlib_metadata
+from pathlib import Path
+from typing import Any
+
+import pycolmap
+import matplotlib
+from PIL import Image
+
+matplotlib.use("Agg")
+from matplotlib import pyplot as plt
+import numpy as np
+
+
+SUPPORTED_IMAGE_SUFFIXES = {".jpg", ".jpeg"}
+MATCHING_MODES = {"sequential", "spatial", "exhaustive"}
+CAMERA_MODELS = {"SIMPLE_RADIAL", "OPENCV"}
+
+
+def collect_images(input_path: Path) -> list[Path]:
+    candidates = [input_path] if input_path.is_file() else sorted(input_path.iterdir()) if input_path.is_dir() else []
+    images = [path for path in candidates if path.is_file() and path.suffix.lower() in SUPPORTED_IMAGE_SUFFIXES]
+    if len(images) < 2:
+        raise ValueError("Photo reconstruction needs at least two JPG/JPEG images.")
+    return images
+
+
+def _gps_record(path: Path) -> dict[str, Any]:
+    record: dict[str, Any] = {"image": path.name, "datetime": None, "latitude": None, "longitude": None, "altitude_m": None}
+    try:
+        with Image.open(path) as image:
+            exif = image.getexif()
+            record["datetime"] = exif.get(306) or exif.get(36867) or exif.get(36868)
+            gps = exif.get_ifd(34853)
+            if not gps:
+                return record
+            latitude = gps.get(2)
+            longitude = gps.get(4)
+            if latitude and longitude:
+                record["latitude"] = float(latitude[0] + latitude[1] / 60 + latitude[2] / 3600)
+                record["longitude"] = float(longitude[0] + longitude[1] / 60 + longitude[2] / 3600)
+                if gps.get(1) == "S":
+                    record["latitude"] *= -1
+                if gps.get(3) == "W":
+                    record["longitude"] *= -1
+            if gps.get(6) is not None:
+                record["altitude_m"] = float(gps[6])
+    except Exception:
+        record["metadata_read_error"] = True
+    return record
+
+
+def _source_record(path: Path) -> dict[str, Any]:
+    digest = hashlib.sha256()
+    with path.open("rb") as handle:
+        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
+            digest.update(chunk)
+    return {"image": path.name, "bytes": path.stat().st_size, "sha256": digest.hexdigest()}
+
+
+def _write_input_manifest(images: list[Path], output_dir: Path) -> str:
+    path = output_dir / "input_image_manifest.csv"
+    fields = ["image", "datetime", "latitude", "longitude", "altitude_m"]
+    with path.open("w", newline="", encoding="utf-8-sig") as handle:
+        writer = csv.DictWriter(handle, fieldnames=fields, extrasaction="ignore")
+        writer.writeheader()
+        writer.writerows(_gps_record(image) for image in images)
+    return path.name
+
+
+def _write_camera_poses(reconstruction: pycolmap.Reconstruction, output_dir: Path) -> tuple[str, list[str]]:
+    path = output_dir / "camera_poses_local.csv"
+    fields = ["image", "camera_id", "center_x_local", "center_y_local", "center_z_local", "observed_points"]
+    registered: list[str] = []
+    with path.open("w", newline="", encoding="utf-8-sig") as handle:
+        writer = csv.DictWriter(handle, fieldnames=fields)
+        writer.writeheader()
+        for image in sorted(reconstruction.images.values(), key=lambda item: item.name):
+            center = image.projection_center()
+            writer.writerow(
+                {
+                    "image": image.name,
+                    "camera_id": image.camera_id,
+                    "center_x_local": round(float(center[0]), 8),
+                    "center_y_local": round(float(center[1]), 8),
+                    "center_z_local": round(float(center[2]), 8),
+                    "observed_points": image.num_points3D,
+                }
+            )
+            registered.append(image.name)
+    return path.name, registered
+
+
+def _write_sparse_preview(reconstruction: pycolmap.Reconstruction, output_dir: Path) -> str:
+    points = list(reconstruction.points3D.values())
+    if not points:
+        raise ValueError("SfM model contains no sparse points.")
+    values = np.asarray([point.xyz for point in points], dtype=float)
+    colors = np.asarray([point.color for point in points], dtype=float) / 255.0
+    # Project onto the two principal axes for a stable, inspectable local-space preview.
+    centered = values - values.mean(axis=0)
+    _, _, vectors = np.linalg.svd(centered, full_matrices=False)
+    projected = centered @ vectors[:2].T
+    path = output_dir / "sparse_points.preview.png"
+    figure, axis = plt.subplots(figsize=(10, 7), dpi=150)
+    axis.scatter(projected[:, 0], projected[:, 1], s=1.4, c=colors, linewidths=0, alpha=0.9, rasterized=True)
+    axis.set_aspect("equal", adjustable="box")
+    axis.set_xlabel("principal axis 1 (local SfM units)")
+    axis.set_ylabel("principal axis 2 (local SfM units)")
+    axis.set_title("Sparse SfM point-cloud preview")
+    axis.grid(True, linewidth=0.3, alpha=0.35)
+    figure.tight_layout()
+    figure.savefig(path, facecolor="white")
+    plt.close(figure)
+    return path.name
+
+
+def validate_matching_configuration(matching_mode: str, matching_neighbors: int) -> None:
+    if matching_mode not in MATCHING_MODES:
+        raise ValueError(f"matching mode must be one of: {', '.join(sorted(MATCHING_MODES))}.")
+    if matching_neighbors < 1:
+        raise ValueError("matching neighbors must be at least 1.")
+
+
+def validate_camera_configuration(camera_model: str, focal_length_px: float | None) -> None:
+    if camera_model not in CAMERA_MODELS:
+        raise ValueError(f"camera model must be one of: {', '.join(sorted(CAMERA_MODELS))}.")
+    if focal_length_px is not None and camera_model != "SIMPLE_RADIAL":
+        raise ValueError("A custom focal-length prior currently requires the SIMPLE_RADIAL camera model.")
+
+
+def match_images(
+    database_path: Path,
+    image_count: int,
+    matching_options: pycolmap.FeatureMatchingOptions,
+    matching_mode: str,
+    matching_neighbors: int,
+) -> None:
+    validate_matching_configuration(matching_mode, matching_neighbors)
+    verification = pycolmap.TwoViewGeometryOptions()
+    if matching_mode == "sequential":
+        pairing = pycolmap.SequentialPairingOptions()
+        pairing.overlap = min(matching_neighbors, image_count - 1)
+        pairing.quadratic_overlap = True
+        pycolmap.match_sequential(database_path, matching_options, pairing, verification, pycolmap.Device.cpu)
+    elif matching_mode == "spatial":
+        pairing = pycolmap.SpatialPairingOptions()
+        pairing.max_num_neighbors = min(matching_neighbors, image_count - 1)
+        pairing.min_num_neighbors = min(matching_neighbors, image_count - 1)
+        pairing.max_distance = 0.0
+        pairing.ignore_z = True
+        pycolmap.match_spatial(database_path, matching_options, pairing, verification, pycolmap.Device.cpu)
+    else:
+        pycolmap.match_exhaustive(database_path, matching_options, pycolmap.ExhaustivePairingOptions(), verification, pycolmap.Device.cpu)
+
+
+def reconstruct_photos(
+    input_path: Path,
+    output_dir: Path,
+    max_image_size: int = 2400,
+    max_features: int = 8192,
+    initial_min_triangulation_angle: float = 1.5,
+    use_position_priors: bool = False,
+    prior_position_loss_scale_m: float = 0.5,
+    lock_focal_length_prior: bool = False,
+    focal_length_px: float | None = None,
+    matching_mode: str = "sequential",
+    matching_neighbors: int = 10,
+    camera_model: str = "SIMPLE_RADIAL",
+) -> dict[str, Any]:
+    if max_image_size < 640 or max_features < 1024 or initial_min_triangulation_angle <= 0 or prior_position_loss_scale_m <= 0 or (focal_length_px is not None and focal_length_px <= 0):
+        raise ValueError("max image size must be at least 640, max features at least 1024, and triangulation/prior-position scales must be positive.")
+    validate_matching_configuration(matching_mode, matching_neighbors)
+    validate_camera_configuration(camera_model, focal_length_px)
+    if output_dir.exists() and any(output_dir.iterdir()):
+        raise ValueError("Output directory is not empty; use a new output directory.")
+    images = collect_images(input_path)
+    output_dir.mkdir(parents=True, exist_ok=True)
+    started = time.perf_counter()
+    database_path = output_dir / "database.db"
+    model_root = output_dir / "sparse_model"
+
+    reader_options = pycolmap.ImageReaderOptions()
+    reader_options.camera_model = camera_model
+    reader_options.default_focal_length_factor = 1.2
+    extraction_options = pycolmap.FeatureExtractionOptions()
+    extraction_options.use_gpu = False
+    extraction_options.max_image_size = max_image_size
+    extraction_options.sift.max_num_features = max_features
+    matching_options = pycolmap.FeatureMatchingOptions()
+    matching_options.use_gpu = False
+
+    image_root = images[0].parent
+    image_names = [image.name for image in images]
+    pycolmap.extract_features(
+        database_path,
+        image_root,
+        image_names,
+        pycolmap.CameraMode.SINGLE,
+        reader_options,
+        extraction_options,
+        pycolmap.Device.cpu,
+    )
+    database = pycolmap.Database.open(database_path)
+    if focal_length_px is not None:
+        cameras = database.read_all_cameras()
+        if len(cameras) != 1 or cameras[0].model.name != "SIMPLE_RADIAL":
+            database.close()
+            raise ValueError("A custom focal-length prior currently requires one SIMPLE_RADIAL camera group.")
+        camera = cameras[0]
+        parameters = camera.params.copy()
+        parameters[0] = focal_length_px
+        camera.params = parameters
+        database.update_camera(camera)
+    pose_prior_count = database.num_pose_priors()
+    database.close()
+    if (use_position_priors or matching_mode == "spatial") and pose_prior_count != len(images):
+        raise ValueError(f"RTK/GPS position priors were requested, but only {pose_prior_count}/{len(images)} source images provide valid positions.")
+    match_images(database_path, len(images), matching_options, matching_mode, matching_neighbors)
+    mapping_options = pycolmap.IncrementalPipelineOptions()
+    mapping_options.min_model_size = min(3, len(images))
+    mapping_options.mapper.init_min_num_inliers = 100
+    mapping_options.mapper.init_min_tri_angle = initial_min_triangulation_angle
+    mapping_options.ba_use_gpu = False
+    mapping_options.use_prior_position = use_position_priors
+    mapping_options.use_robust_loss_on_prior_position = use_position_priors
+    mapping_options.prior_position_loss_scale = prior_position_loss_scale_m
+    mapping_options.ba_refine_focal_length = not lock_focal_length_prior
+    mapping_options.mapper.abs_pose_refine_focal_length = not lock_focal_length_prior
+    models = pycolmap.incremental_mapping(database_path, image_root, model_root, mapping_options)
+    if not models:
+        raise ValueError("SfM could not register a reconstruction model. Check image overlap, blur, and repeated texture.")
+    reconstruction = max(models.values(), key=lambda item: (len(item.images), len(item.points3D)))
+    selected_model = next(key for key, value in models.items() if value is reconstruction)
+    selected_path = model_root / str(selected_model)
+    selected_path.mkdir(parents=True, exist_ok=True)
+    reconstruction.write(selected_path)
+    point_cloud_path = output_dir / "sparse_points.ply"
+    reconstruction.export_PLY(point_cloud_path)
+    preview_file = _write_sparse_preview(reconstruction, output_dir)
+    pose_file, registered = _write_camera_poses(reconstruction, output_dir)
+    manifest_file = _write_input_manifest(images, output_dir)
+    unregistered = [name for name in image_names if name not in set(registered)]
+    return {
+        "input_images": len(images),
+        "source_files": [_source_record(image) for image in images],
+        "registered_images": len(registered),
+        "unregistered_images": unregistered,
+        "sparse_points": len(reconstruction.points3D),
+        "mean_reprojection_error_pixels": round(float(reconstruction.compute_mean_reprojection_error()), 4),
+        "camera_count": len(reconstruction.cameras),
+        "coordinate_basis": "RTK_position_prior_constrained_local_coordinates" if use_position_priors else "local_sfm_coordinates_arbitrary_scale_and_orientation",
+        "position_priors": {"enabled": use_position_priors, "available_images": pose_prior_count, "loss_scale_m": prior_position_loss_scale_m if use_position_priors else None},
+        "focal_length_prior_locked": lock_focal_length_prior,
+        "focal_length_prior_px": focal_length_px,
+        "camera_model": camera_model,
+        "matching": {"mode": matching_mode, "neighbors": matching_neighbors},
+        "point_cloud_file": point_cloud_path.name,
+        "preview_file": preview_file,
+        "camera_pose_file": pose_file,
+        "input_manifest_file": manifest_file,
+        "colmap_model_directory": str(Path("sparse_model") / str(selected_model)).replace("\\", "/"),
+        "elapsed_seconds": round(time.perf_counter() - started, 3),
+        "initial_min_triangulation_angle_degrees": initial_min_triangulation_angle,
+    }
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="Build a CPU sparse SfM point cloud from a JPG/JPEG sequence.")
+    parser.add_argument("--input", type=Path, required=True, help="JPG/JPEG file or directory from one coherent camera sequence.")
+    parser.add_argument("--output", type=Path, required=True, help="New or empty output directory.")
+    parser.add_argument("--max-image-size", type=int, default=2400, help="Maximum processed image edge in pixels.")
+    parser.add_argument("--max-features", type=int, default=8192, help="Maximum SIFT features per photo.")
+    parser.add_argument("--initial-min-triangulation-angle", type=float, default=1.5, help="Initial-pair minimum triangulation angle in degrees.")
+    parser.add_argument("--use-position-priors", action="store_true", help="Require and use source GPS/RTK position priors during bundle adjustment.")
+    parser.add_argument("--prior-position-loss-scale-m", type=float, default=0.5, help="Robust GPS/RTK prior scale in metres when position priors are enabled.")
+    parser.add_argument("--lock-focal-length-prior", action="store_true", help="Keep the camera focal-length prior read from source metadata fixed during mapping.")
+    parser.add_argument("--focal-length-px", type=float, help="Override the source focal-length prior in pixels before mapping; use with --lock-focal-length-prior for a fixed calibration trial.")
+    parser.add_argument("--matching-mode", choices=sorted(MATCHING_MODES), default="sequential", help="Image pairing strategy. Spatial matching uses source GPS/RTK position priors.")
+    parser.add_argument("--matching-neighbors", type=int, default=10, help="Nearest images per photo for sequential or spatial matching.")
+    parser.add_argument("--camera-model", choices=sorted(CAMERA_MODELS), default="SIMPLE_RADIAL", help="COLMAP camera model. OPENCV is the closest local counterpart to ODX's brown projection model.")
+    args = parser.parse_args()
+    try:
+        result = reconstruct_photos(args.input, args.output, args.max_image_size, args.max_features, args.initial_min_triangulation_angle, args.use_position_priors, args.prior_position_loss_scale_m, args.lock_focal_length_prior, args.focal_length_px, args.matching_mode, args.matching_neighbors, args.camera_model)
+    except (ValueError, RuntimeError) as error:
+        raise SystemExit(str(error)) from error
+    metadata = {
+        "capability": "05-3d-pointcloud",
+        "classification": "B",
+        "created_at": datetime.now(UTC).isoformat(),
+        "versions": {"pycolmap": pycolmap.__version__, "geoai-py": importlib_metadata.version("geoai-py")},
+        "method": f"COLMAP SIFT CPU feature extraction, {args.matching_mode} geometric matching, incremental SfM, and sparse coloured point-cloud export",
+        "model": "none (geometric SfM CPU baseline)",
+        "device": "CPU",
+        "thresholds": {
+            "max_image_size": args.max_image_size,
+            "max_features_per_image": args.max_features,
+            "initial_min_triangulation_angle_degrees": args.initial_min_triangulation_angle,
+            "use_position_priors": args.use_position_priors,
+            "prior_position_loss_scale_m": args.prior_position_loss_scale_m if args.use_position_priors else None,
+            "lock_focal_length_prior": args.lock_focal_length_prior,
+            "focal_length_prior_px": args.focal_length_px,
+            "matching_mode": args.matching_mode,
+            "matching_neighbors": args.matching_neighbors,
+            "camera_model": args.camera_model,
+        },
+        "photo_reconstruction": result,
+        "elapsed_seconds": result["elapsed_seconds"],
+        "limitations": [
+            "This is a sparse SfM reconstruction. pycolmap PatchMatch dense reconstruction requires CUDA and is not available on this CPU-only workstation.",
+            "Position priors constrain camera locations but do not replace a surveyed camera calibration, lever-arm correction, GCP check, or accuracy assessment.",
+            "Self-calibrated camera parameters, point density, and completeness require review against source-camera calibration and control points.",
+            "A successful reconstruction only demonstrates visual geometry. It does not identify rocks, vegetation, equipment, cracks, or hazards.",
+        ],
+    }
+    (args.output / "run_metadata.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
+    print(json.dumps(metadata, ensure_ascii=False, indent=2))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/capabilities/05-3d-pointcloud/run_pointcloud_understanding.py b/capabilities/05-3d-pointcloud/run_pointcloud_understanding.py
new file mode 100644
index 0000000..3bbc7d9
--- /dev/null
+++ b/capabilities/05-3d-pointcloud/run_pointcloud_understanding.py
@@ -0,0 +1,683 @@
+"""CPU point-cloud understanding for existing reconstruction outputs.
+
+This B capability uses Open3D for point-cloud reading, downsampling, ground
+plane fitting, and mesh reconstruction.  ``geoai.masks_to_vector`` converts
+the elevated-object raster to GeoJSON.  It does not run photo-based SfM/MVS or
+claim semantic object labels.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import math
+import time
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from importlib import metadata as importlib_metadata
+from pathlib import Path
+from typing import Any
+
+import geopandas as gpd
+import laspy
+import numpy as np
+import open3d as o3d
+import pandas as pd
+import rasterio
+from PIL import Image
+from rasterio.features import shapes
+from rasterio.transform import from_origin
+from scipy import ndimage
+from scipy.spatial import cKDTree
+from shapely.geometry import shape
+
+
+SUPPORTED_SUFFIXES = {".ply", ".pcd", ".xyz", ".xyzn", ".xyzrgb", ".las", ".laz"}
+
+# LAS class codes keep the result useful outside this workbench.  The labels are
+# deliberately conservative: uncertain elevated points remain unclassified.
+SEMANTIC_CLASSES = {
+    1: {"key": "other_unknown", "label": "Other / unknown", "color": (128, 128, 128)},
+    2: {"key": "ground", "label": "Ground", "color": (151, 111, 51)},
+    5: {"key": "vegetation", "label": "Vegetation", "color": (59, 163, 87)},
+    6: {"key": "building_structure", "label": "Building / structure", "color": (224, 115, 55)},
+    15: {"key": "pole_tower", "label": "Pole / tower candidate", "color": (149, 89, 210)},
+    16: {"key": "power_line", "label": "Power-line candidate", "color": (231, 196, 61)},
+}
+SEMANTIC_PRIORITY = {1: 1, 2: 2, 5: 3, 6: 4, 15: 5, 16: 6}
+
+
+@dataclass(frozen=True)
+class PointCloudData:
+    points: np.ndarray
+    colors: np.ndarray
+    source_has_rgb: bool
+
+
+def _normalise_rgb(colors: np.ndarray) -> np.ndarray:
+    values = np.asarray(colors, dtype=np.float64)
+    if values.size == 0:
+        return values.reshape((-1, 3))
+    high = float(np.nanpercentile(values, 99.5))
+    divisor = 65_535.0 if high > 255 else 255.0
+    return np.clip(values / divisor, 0.0, 1.0)
+
+
+def read_point_cloud_data(path: Path) -> PointCloudData:
+    if path.suffix.lower() in {".las", ".laz"}:
+        source = laspy.read(path)
+        points = np.column_stack((source.x, source.y, source.z)).astype(np.float64)
+        dimensions = set(source.point_format.dimension_names)
+        has_rgb = {"red", "green", "blue"}.issubset(dimensions)
+        colors = _normalise_rgb(np.column_stack((source.red, source.green, source.blue))) if has_rgb else np.full((len(points), 3), 0.72)
+    else:
+        cloud = o3d.io.read_point_cloud(str(path))
+        points = np.asarray(cloud.points, dtype=np.float64)
+        has_rgb = cloud.has_colors()
+        colors = np.asarray(cloud.colors, dtype=np.float64) if has_rgb else np.full((len(points), 3), 0.72)
+    if len(points) < 50:
+        raise ValueError(f"Point cloud needs at least 50 finite points: {path.name}")
+    finite = np.isfinite(points).all(axis=1) & np.isfinite(colors).all(axis=1)
+    if not finite.any():
+        raise ValueError(f"Point cloud has no finite points: {path.name}")
+    return PointCloudData(points=points[finite], colors=np.clip(colors[finite], 0.0, 1.0), source_has_rgb=has_rgb)
+
+
+def read_point_cloud(path: Path) -> o3d.geometry.PointCloud:
+    data = read_point_cloud_data(path)
+    cloud = o3d.geometry.PointCloud()
+    cloud.points = o3d.utility.Vector3dVector(data.points)
+    cloud.colors = o3d.utility.Vector3dVector(data.colors)
+    return cloud
+
+
+def voxel_downsample_data(data: PointCloudData, voxel_size: float) -> PointCloudData:
+    """Keep one deterministic representative per voxel while retaining RGB."""
+    origin = data.points.min(axis=0)
+    cells = np.floor((data.points - origin) / voxel_size).astype(np.int64)
+    _, indices = np.unique(cells, axis=0, return_index=True)
+    indices.sort()
+    return PointCloudData(points=data.points[indices], colors=data.colors[indices], source_has_rgb=data.source_has_rgb)
+
+
+def _plane_height(plane: np.ndarray, x: np.ndarray, y: np.ndarray) -> np.ndarray:
+    a, b, c, d = plane
+    return -(a * x + b * y + d) / c
+
+
+def fit_ground(cloud: o3d.geometry.PointCloud, distance_threshold: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
+    plane, inliers = cloud.segment_plane(distance_threshold=distance_threshold, ransac_n=3, num_iterations=1_000)
+    plane_values = np.asarray(plane, dtype=float)
+    if abs(plane_values[2]) < 0.7:
+        raise ValueError("Dominant RANSAC plane is too steep to be used as ground.")
+    if plane_values[2] < 0:
+        plane_values *= -1
+    values = np.asarray(cloud.points)
+    ground_z = _plane_height(plane_values, values[:, 0], values[:, 1])
+    height_above_ground = values[:, 2] - ground_z
+    return plane_values, np.asarray(inliers, dtype=int), height_above_ground
+
+
+def orient_ground_up(cloud: o3d.geometry.PointCloud, distance_threshold: float, up_axis: str) -> tuple[o3d.geometry.PointCloud, dict[str, Any]]:
+    """Optionally rotate a source-local cloud so its dominant ground plane uses Z as up."""
+    if up_axis == "z":
+        return cloud, {"method": "source_z_axis", "applied": False}
+    plane, _ = cloud.segment_plane(distance_threshold=distance_threshold, ransac_n=3, num_iterations=1_000)
+    normal = np.asarray(plane[:3], dtype=float)
+    normal /= np.linalg.norm(normal)
+    if normal[2] < 0:
+        normal *= -1
+    target = np.array([0.0, 0.0, 1.0])
+    axis = np.cross(normal, target)
+    axis_norm = np.linalg.norm(axis)
+    angle = math.atan2(axis_norm, float(np.dot(normal, target)))
+    if axis_norm > 1e-12:
+        rotation = o3d.geometry.get_rotation_matrix_from_axis_angle(axis / axis_norm * angle)
+        cloud = o3d.geometry.PointCloud(cloud)
+        cloud.rotate(rotation, center=(0.0, 0.0, 0.0))
+    return cloud, {
+        "method": "auto_align_dominant_plane_to_z",
+        "applied": bool(axis_norm > 1e-12),
+        "source_plane_normal": [round(float(value), 8) for value in normal],
+        "rotation_degrees": round(math.degrees(angle), 4),
+    }
+
+
+def _grid_bounds(values: np.ndarray, cell_size: float) -> tuple[float, float, int, int]:
+    minimum = values[:, :2].min(axis=0)
+    maximum = values[:, :2].max(axis=0)
+    width = max(1, int(math.ceil((maximum[0] - minimum[0]) / cell_size)) + 1)
+    height = max(1, int(math.ceil((maximum[1] - minimum[1]) / cell_size)) + 1)
+    if width > 4_096 or height > 4_096:
+        raise ValueError("Point-cloud extent and cell size would create a raster larger than 4096 by 4096.")
+    return float(minimum[0]), float(maximum[1]), width, height
+
+
+def estimate_local_ground(values: np.ndarray, cell_size: float) -> tuple[np.ndarray, np.ndarray, Any]:
+    """Estimate a conservative local ground surface from low points in each XY cell.
+
+    This is intentionally a CPU rule baseline, not a bare-earth DEM algorithm.
+    It behaves more safely than one global plane on a sloped power-line corridor.
+    """
+    origin_x, origin_y, width, height = _grid_bounds(values, cell_size)
+    transform = from_origin(origin_x, origin_y, cell_size, cell_size)
+    columns = np.clip(((values[:, 0] - origin_x) / cell_size).astype(int), 0, width - 1)
+    rows = np.clip(((origin_y - values[:, 1]) / cell_size).astype(int), 0, height - 1)
+    surface = np.full((height, width), np.inf, dtype=np.float64)
+    np.minimum.at(surface, (rows, columns), values[:, 2])
+    valid = surface != np.inf
+    if valid.sum() < 8:
+        raise ValueError("Too few occupied XY cells to estimate a local ground surface.")
+    nearest = ndimage.distance_transform_edt(~valid, return_distances=False, return_indices=True)
+    filled = surface[tuple(nearest)]
+    # A small median window suppresses isolated low outliers while retaining
+    # terrain changes at the metre-scale used by this first baseline.
+    smoothed = ndimage.median_filter(filled, size=3, mode="nearest")
+    point_ground = smoothed[rows, columns]
+    return point_ground, smoothed, transform
+
+
+def _cell_metrics(values: np.ndarray, cell_size: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
+    """Return per-point count/min/max metrics of its horizontal neighbourhood."""
+    minimum = values[:, :2].min(axis=0)
+    cells = np.floor((values[:, :2] - minimum) / cell_size).astype(np.int64)
+    _, inverse = np.unique(cells, axis=0, return_inverse=True)
+    counts = np.bincount(inverse)
+    mins = np.full(len(counts), np.inf)
+    maxs = np.full(len(counts), -np.inf)
+    np.minimum.at(mins, inverse, values[:, 2])
+    np.maximum.at(maxs, inverse, values[:, 2])
+    return counts[inverse], mins[inverse], maxs[inverse]
+
+
+def continuous_wire_candidates(
+    values: np.ndarray,
+    heights: np.ndarray,
+    green_dominant: np.ndarray,
+    compact_counts: np.ndarray,
+    compact_span: np.ndarray,
+) -> np.ndarray:
+    """Keep sparse high line candidates for review without using RGB as a veto."""
+    cell_size = 1.0
+    minimum = values[:, :2].min(axis=0)
+    cells = np.floor((values[:, :2] - minimum) / cell_size).astype(int)
+    width = int(cells[:, 0].max()) + 1
+    height = int(cells[:, 1].max()) + 1
+    flat = cells[:, 1] * width + cells[:, 0]
+    cell_count = width * height
+    # LiDAR RGB is not a material classifier. In the supplied corridor scan
+    # conductors and tower parts are often green, so RGB must not reject them.
+    candidate = (heights >= 4.0) & (compact_span <= 1.20) & (compact_counts <= 100)
+    cell_candidate = np.zeros(cell_count, dtype=bool)
+    np.logical_or.at(cell_candidate, flat, candidate)
+    # Diagonal conductors may leave a one-cell gap after voxel sampling.
+    connected = ndimage.binary_dilation(cell_candidate.reshape(height, width), iterations=2)
+    labels, component_count = ndimage.label(connected, structure=np.ones((3, 3), dtype=np.uint8))
+    point_components = labels[cells[:, 1], cells[:, 0]]
+    accepted = np.zeros(component_count + 1, dtype=bool)
+    for component in range(1, component_count + 1):
+        original_cells = np.flatnonzero((labels.ravel() == component) & cell_candidate)
+        if len(original_cells) < 3:
+            continue
+        xy = np.column_stack((original_cells % width, original_cells // width)).astype(float)
+        centered = xy - xy.mean(axis=0)
+        eigenvalues = np.linalg.eigvalsh(centered.T @ centered)
+        if eigenvalues[-1] <= 0:
+            continue
+        linearity = 1.0 - eigenvalues[0] / eigenvalues[-1]
+        line_length = float(np.ptp(centered @ np.linalg.eigh(centered.T @ centered)[1][:, -1]))
+        if linearity >= 0.94 and line_length >= 5.0:
+            accepted[component] = True
+    # Broken returns and oblique viewing often split one conductor into many
+    # short components. Preserve all thin high samples as *candidates*; the
+    # continuity gate remains a diagnostic for later model/label work.
+    return candidate
+
+
+def local_shape_features(values: np.ndarray, neighbours: int = 16, batch_size: int = 100_000) -> tuple[np.ndarray, np.ndarray]:
+    """Measure local linearity and principal-axis verticality without RGB.
+
+    The work is batched so multi-million-point LAS inputs stay bounded in RAM.
+    """
+    tree = cKDTree(values)
+    linearity = np.zeros(len(values), dtype=np.float32)
+    principal_verticality = np.zeros(len(values), dtype=np.float32)
+    for start in range(0, len(values), batch_size):
+        stop = min(start + batch_size, len(values))
+        _, indices = tree.query(values[start:stop], k=min(neighbours, len(values)), workers=-1)
+        neighbours_xyz = values[np.atleast_2d(indices)]
+        centred = neighbours_xyz - neighbours_xyz.mean(axis=1, keepdims=True)
+        covariance = np.einsum("nij,nik->njk", centred, centred) / max(neighbours_xyz.shape[1] - 1, 1)
+        eigenvalues, eigenvectors = np.linalg.eigh(covariance)
+        largest = np.maximum(eigenvalues[:, 2], 1e-9)
+        linearity[start:stop] = np.clip((eigenvalues[:, 2] - eigenvalues[:, 1]) / largest, 0.0, 1.0)
+        principal_verticality[start:stop] = np.abs(eigenvectors[:, 2, 2])
+    return linearity, principal_verticality
+
+
+def expand_pole_tower_components(values: np.ndarray, heights: np.ndarray, pole_seeds: np.ndarray) -> np.ndarray:
+    """Promote the horizontal and diagonal members around a tower's vertical seeds.
+
+    Lattice towers are not locally vertical at every point.  This object-level
+    step is deliberately limited to compact groups of high-confidence vertical
+    members, so a long vegetation corridor is not promoted wholesale.
+    """
+    expanded = pole_seeds.copy()
+    seed_indices = np.flatnonzero(pole_seeds)
+    if len(seed_indices) < 6:
+        return expanded
+    seed_xy = values[seed_indices, :2]
+    pairs = cKDTree(seed_xy).query_pairs(r=4.5, output_type="ndarray")
+    parent = np.arange(len(seed_indices))
+
+    def find(index: int) -> int:
+        while parent[index] != index:
+            parent[index] = parent[parent[index]]
+            index = int(parent[index])
+        return index
+
+    for left, right in pairs:
+        root_left, root_right = find(int(left)), find(int(right))
+        if root_left != root_right:
+            parent[root_right] = root_left
+    groups: dict[int, list[int]] = {}
+    for index in range(len(seed_indices)):
+        groups.setdefault(find(index), []).append(index)
+    for members in groups.values():
+        if len(members) < 8:
+            continue
+        indices = seed_indices[np.asarray(members)]
+        seed_heights = heights[indices]
+        if float(seed_heights.max() - seed_heights.min()) < 5.0:
+            continue
+        center = np.median(values[indices, :2], axis=0)
+        distances = np.linalg.norm(values[indices, :2] - center, axis=1)
+        if float(np.percentile(distances, 95)) > 4.5:
+            continue
+        radius = min(4.0, max(2.0, float(np.percentile(distances, 90)) + 1.0))
+        lower = max(1.5, float(np.percentile(seed_heights, 5)) - 0.75)
+        upper = float(np.percentile(seed_heights, 95)) + 0.75
+        nearby = np.linalg.norm(values[:, :2] - center, axis=1) <= radius
+        expanded |= nearby & (heights >= lower) & (heights <= upper)
+    return expanded
+
+
+def classify_semantic_points(values: np.ndarray, colors: np.ndarray, heights: np.ndarray, voxel_size: float) -> np.ndarray:
+    """Classify points with transparent RGB/geometry rules on CPU.
+
+    The narrow wire and pole rules deliberately require strong evidence.  Points
+    that do not satisfy a class remain ``other_unknown`` for user review.
+    """
+    classes = np.full(len(values), 1, dtype=np.uint8)
+    ground_limit = max(0.30, voxel_size * 1.5)
+    ground = heights <= ground_limit
+    classes[ground] = 2
+
+    red, green, blue = colors.T
+    green_dominant = (green > red * 1.08) & (green > blue * 1.05) & (green > 0.16)
+    compact_counts, compact_min, compact_max = _cell_metrics(values, max(1.0, voxel_size * 5))
+    compact_span = compact_max - compact_min
+    tower_counts, tower_min, tower_max = _cell_metrics(values, max(1.5, voxel_size * 7.5))
+    tower_span = tower_max - tower_min
+    elevated = heights > 1.0
+
+    local_linearity, local_verticality = local_shape_features(values)
+    # Conductors are horizontally linear; colour must not suppress this signal.
+    wire = (heights >= 4.0) & (local_linearity >= 0.86) & (local_verticality <= 0.40) & (compact_counts <= 180)
+    # Pole/tower members are vertically linear. This excludes most foliage,
+    # which has no stable local principal direction.
+    pole_seeds = elevated & (heights >= 3.0) & (local_linearity >= 0.78) & (local_verticality >= 0.72) & (tower_counts <= 500) & ~wire
+    pole = expand_pole_tower_components(values, heights, pole_seeds) & ~wire
+    # Dense locally planar elevated surfaces are structure candidates.  This
+    # avoids labelling rough foliage as a building merely from its height.
+    structure = elevated & (compact_span <= 0.65) & (compact_counts >= 15) & ~green_dominant & ~wire & ~pole
+    vegetation = elevated & ~wire & ~pole & ~structure & (green_dominant | (compact_span >= 1.15))
+    classes[vegetation] = 5
+    classes[structure] = 6
+    classes[pole] = 15
+    classes[wire] = 16
+    return classes
+
+
+def semantic_raster(values: np.ndarray, classes: np.ndarray, cell_size: float) -> tuple[np.ndarray, Any]:
+    origin_x, origin_y, width, height = _grid_bounds(values, cell_size)
+    transform = from_origin(origin_x, origin_y, cell_size, cell_size)
+    columns = np.clip(((values[:, 0] - origin_x) / cell_size).astype(int), 0, width - 1)
+    rows = np.clip(((origin_y - values[:, 1]) / cell_size).astype(int), 0, height - 1)
+    raster = np.zeros((height, width), dtype=np.uint8)
+    priorities = np.asarray([SEMANTIC_PRIORITY.get(int(value), 0) for value in classes], dtype=np.uint8)
+    flat = rows * width + columns
+    winner = np.zeros(height * width, dtype=np.uint8)
+    np.maximum.at(winner, flat, priorities)
+    priority_to_code = {priority: code for code, priority in SEMANTIC_PRIORITY.items()}
+    for priority, code in priority_to_code.items():
+        raster.flat[winner == priority] = code
+    return raster, transform
+
+
+def write_semantic_preview(raster: np.ndarray, path: Path) -> None:
+    preview = np.full((*raster.shape, 3), 244, dtype=np.uint8)
+    for code, details in SEMANTIC_CLASSES.items():
+        preview[raster == code] = details["color"]
+    Image.fromarray(preview).save(path)
+
+
+def write_semantic_vectors(raster: np.ndarray, transform: Any, path: Path) -> int:
+    records: list[dict[str, Any]] = []
+    for geometry, value in shapes(raster, mask=raster > 0, transform=transform):
+        code = int(value)
+        details = SEMANTIC_CLASSES.get(code)
+        if not details or code == 1:
+            continue
+        polygon = shape(geometry)
+        if polygon.area <= 0:
+            continue
+        records.append({"class_key": details["key"], "class_label": details["label"], "las_class_code": code, "area_local_units2": round(float(polygon.area), 3), "geometry": polygon})
+    frame = gpd.GeoDataFrame(records, geometry="geometry")
+    if frame.empty:
+        frame = gpd.GeoDataFrame({"class_key": [], "class_label": [], "las_class_code": [], "area_local_units2": []}, geometry=[])
+    frame.to_file(path, driver="GeoJSON")
+    return len(frame)
+
+
+def write_semantic_las(values: np.ndarray, colors: np.ndarray, classes: np.ndarray, path: Path) -> None:
+    header = laspy.LasHeader(point_format=3, version="1.2")
+    header.scales = np.array([0.001, 0.001, 0.001])
+    header.offsets = np.floor(values.min(axis=0))
+    output = laspy.LasData(header)
+    output.x, output.y, output.z = values.T
+    output.red, output.green, output.blue = (np.clip(colors, 0.0, 1.0) * 65535).astype(np.uint16).T
+    output.classification = classes
+    output.write(path)
+
+
+def rasterize(
+    values: np.ndarray,
+    heights: np.ndarray,
+    cell_size: float,
+    elevated_threshold: float,
+) -> tuple[np.ndarray, np.ndarray, np.ndarray, Any]:
+    origin_x, origin_y, width, height = _grid_bounds(values, cell_size)
+    transform = from_origin(origin_x, origin_y, cell_size, cell_size)
+    dsm = np.full((height, width), np.nan, dtype=np.float32)
+    canopy_height = np.full((height, width), np.nan, dtype=np.float32)
+    columns = np.clip(((values[:, 0] - origin_x) / cell_size).astype(int), 0, width - 1)
+    rows = np.clip(((origin_y - values[:, 1]) / cell_size).astype(int), 0, height - 1)
+    for row, column, z, above_ground in zip(rows, columns, values[:, 2], heights, strict=True):
+        dsm[row, column] = z if not np.isfinite(dsm[row, column]) else max(dsm[row, column], z)
+        canopy_height[row, column] = above_ground if not np.isfinite(canopy_height[row, column]) else max(canopy_height[row, column], above_ground)
+    labels = np.where(np.isfinite(canopy_height) & (canopy_height >= elevated_threshold), 255, 0).astype(np.uint8)
+    return dsm, canopy_height, labels, transform
+
+
+def _colored_preview(values: np.ndarray, labels: np.ndarray, path: Path) -> None:
+    finite = np.isfinite(values)
+    preview = np.zeros((*values.shape, 3), dtype=np.uint8)
+    if finite.any():
+        low, high = np.percentile(values[finite], [2, 98])
+        normalized = np.nan_to_num(np.clip((values - low) / max(high - low, 1e-6), 0, 1), nan=0.0)
+        preview[..., 0] = (30 + 180 * normalized).astype(np.uint8)
+        preview[..., 1] = (65 + 150 * (1 - normalized)).astype(np.uint8)
+        preview[..., 2] = (205 - 150 * normalized).astype(np.uint8)
+    preview[labels > 0] = [230, 90, 45]
+    Image.fromarray(preview).save(path)
+
+
+def _fallback_vectors(labels: np.ndarray, transform: Any) -> gpd.GeoDataFrame:
+    records: list[dict[str, Any]] = []
+    for geometry, value in shapes(labels, mask=labels.astype(bool), transform=transform):
+        if int(value) != 255:
+            continue
+        polygon = shape(geometry)
+        if polygon.area <= 0:
+            continue
+        records.append({"class_key": "elevated_surface", "geometry": polygon})
+    return gpd.GeoDataFrame(records, geometry="geometry")
+
+
+def vectorize(labels: np.ndarray, transform: Any, raster_path: Path, vector_path: Path) -> tuple[gpd.GeoDataFrame, str]:
+    fallback = _fallback_vectors(labels, transform)
+    vectorizer = "rasterio.features.shapes fallback"
+    try:
+        from geoai import masks_to_vector
+
+        frame = masks_to_vector(str(raster_path), min_object_area=1, simplify_tolerance=0.0)
+        if not frame.empty:
+            frame = frame[["geometry"]].copy()
+            frame["class_key"] = "elevated_surface"
+            geoai_area = float(frame.geometry.area.sum())
+            fallback_area = float(fallback.geometry.area.sum())
+            if len(frame) == len(fallback) and fallback_area and 0.98 <= geoai_area / fallback_area <= 1.02:
+                fallback = frame
+                vectorizer = "geoai.masks_to_vector"
+            else:
+                vectorizer = "geoai.masks_to_vector + rasterio completeness repair"
+    except Exception:
+        pass
+    fallback = fallback.reset_index(drop=True)
+    fallback["feature_id"] = np.arange(1, len(fallback) + 1)
+    fallback["area_local_units2"] = fallback.geometry.area.round(3)
+    fallback.to_file(vector_path, driver="GeoJSON")
+    return fallback, vectorizer
+
+
+def reconstruct_mesh(cloud: o3d.geometry.PointCloud, output_path: Path, voxel_size: float) -> int:
+    working = cloud.voxel_down_sample(max(voxel_size, 0.02))
+    if len(working.points) < 50:
+        return 0
+    working.estimate_normals(o3d.geometry.KDTreeSearchParamHybrid(radius=max(voxel_size * 4, 0.2), max_nn=30))
+    try:
+        mesh = o3d.geometry.TriangleMesh.create_from_point_cloud_alpha_shape(working, max(voxel_size * 4, 0.4))
+    except RuntimeError:
+        return 0
+    if len(mesh.triangles) and o3d.io.write_triangle_mesh(str(output_path), mesh, write_ascii=False):
+        return len(mesh.triangles)
+    return 0
+
+
+def process_point_cloud(
+    input_path: Path,
+    output_dir: Path,
+    voxel_size: float = 0.2,
+    ground_distance: float = 0.15,
+    elevated_threshold: float = 0.75,
+    ground_up_axis: str = "z",
+) -> dict[str, Any]:
+    if voxel_size <= 0 or ground_distance <= 0 or elevated_threshold <= 0:
+        raise ValueError("voxel size, ground distance, and elevated threshold must be positive.")
+    started = time.perf_counter()
+    source_data = read_point_cloud_data(input_path)
+    original_points = len(source_data.points)
+    downsampled_data = voxel_downsample_data(source_data, voxel_size)
+    if len(downsampled_data.points) < 50:
+        raise ValueError("Voxel downsampling retained fewer than 50 points; use a smaller voxel size.")
+    downsampled = o3d.geometry.PointCloud()
+    downsampled.points = o3d.utility.Vector3dVector(downsampled_data.points)
+    downsampled.colors = o3d.utility.Vector3dVector(downsampled_data.colors)
+    downsampled, alignment = orient_ground_up(downsampled, ground_distance, ground_up_axis)
+    values = np.asarray(downsampled.points)
+    # Open3D exposes a mutable view here. Keep an owned RGB copy before the
+    # classified point cloud overwrites its colours with display labels.
+    observed_colors = np.asarray(downsampled.colors).copy()
+    ground_plane_available = True
+    try:
+        plane, inliers, _ = fit_ground(downsampled, ground_distance)
+    except (RuntimeError, ValueError):
+        ground_plane_available = False
+        plane = np.array([0.0, 0.0, 1.0, -float(np.median(values[:, 2]))])
+        inliers = np.array([], dtype=int)
+    local_ground, _, _ = estimate_local_ground(values, max(1.0, voxel_size * 5))
+    heights = values[:, 2] - local_ground
+    inliers = np.flatnonzero(heights <= max(ground_distance, voxel_size * 1.5))
+    dsm, height_raster, labels, transform = rasterize(values, heights, voxel_size, elevated_threshold)
+    output_dir.mkdir(parents=True, exist_ok=True)
+    stem = input_path.stem
+    classified = output_dir / f"{stem}.classified.ply"
+    semantic_preview_cloud = output_dir / f"{stem}.semantic-preview.ply"
+    annotation_source_cloud = output_dir / f"{stem}.semantic-annotation-source.ply"
+    semantic_las = output_dir / f"{stem}.semantic-classified.las"
+    semantic_raster_path = output_dir / f"{stem}.semantic-classes.tif"
+    semantic_preview_path = output_dir / f"{stem}.semantic-classes.preview.png"
+    semantic_vector_path = output_dir / f"{stem}.semantic-footprints.geojson"
+    semantic_summary_path = output_dir / f"{stem}.semantic-summary.csv"
+    mesh = output_dir / f"{stem}.reconstruction.ply"
+    dsm_path = output_dir / f"{stem}.dsm.tif"
+    height_path = output_dir / f"{stem}.height-above-ground.tif"
+    label_path = output_dir / f"{stem}.elevated-surface.tif"
+    preview_path = output_dir / f"{stem}.dsm.preview.png"
+    vector_path = output_dir / f"{stem}.elevated-footprints.geojson"
+    summary_path = output_dir / f"{stem}.summary.csv"
+    semantic_classes = classify_semantic_points(values, observed_colors, heights, voxel_size)
+    semantic_colors = np.asarray([np.asarray(SEMANTIC_CLASSES[int(code)]["color"], dtype=float) / 255.0 for code in semantic_classes])
+    downsampled.colors = o3d.utility.Vector3dVector(semantic_colors)
+    o3d.io.write_point_cloud(str(classified), downsampled, write_ascii=False)
+    # Retain sparse candidate classes in the browser preview. Uniform sampling
+    # made thin conductors disappear even when they were correctly labelled.
+    candidate_indices = np.flatnonzero(np.isin(semantic_classes, [15, 16]))
+    remaining = max(0, min(len(values), 400_000) - len(candidate_indices))
+    other_indices = np.flatnonzero(~np.isin(semantic_classes, [15, 16]))
+    sampled_other = other_indices[np.linspace(0, len(other_indices) - 1, min(remaining, len(other_indices)), dtype=int)] if len(other_indices) and remaining else np.array([], dtype=int)
+    preview_indices = np.sort(np.concatenate((candidate_indices, sampled_other)))
+    preview_cloud = downsampled.select_by_index(preview_indices.tolist())
+    o3d.io.write_point_cloud(str(semantic_preview_cloud), preview_cloud, write_ascii=False)
+    # Annotation must retain observed RGB.  The semantic preview uses rule
+    # colours for review only, and must never become a feature leak for a later
+    # supervised model.
+    annotation_cloud = o3d.geometry.PointCloud()
+    annotation_cloud.points = o3d.utility.Vector3dVector(values[preview_indices])
+    annotation_cloud.colors = o3d.utility.Vector3dVector(observed_colors[preview_indices])
+    o3d.io.write_point_cloud(str(annotation_source_cloud), annotation_cloud, write_ascii=False)
+    write_semantic_las(values, observed_colors, semantic_classes, semantic_las)
+    semantic_labels, semantic_transform = semantic_raster(values, semantic_classes, voxel_size)
+    with rasterio.open(semantic_raster_path, "w", driver="GTiff", height=semantic_labels.shape[0], width=semantic_labels.shape[1], count=1, dtype="uint8", transform=semantic_transform, nodata=0) as destination:
+        destination.write(semantic_labels, 1)
+    write_semantic_preview(semantic_labels, semantic_preview_path)
+    semantic_footprints = write_semantic_vectors(semantic_labels, semantic_transform, semantic_vector_path)
+    semantic_counts = pd.DataFrame([
+        {
+            "las_class_code": code,
+            "class_key": details["key"],
+            "class_label": details["label"],
+            "point_count": int((semantic_classes == code).sum()),
+            "point_ratio": round(float((semantic_classes == code).mean()), 6),
+        }
+        for code, details in SEMANTIC_CLASSES.items()
+    ])
+    semantic_counts.to_csv(semantic_summary_path, index=False, encoding="utf-8-sig")
+    # Alpha-shape meshing is useful for small geometry demos but can dominate a
+    # semantic-classification run without improving its class labels.
+    triangles = reconstruct_mesh(downsampled, mesh, voxel_size) if len(values) <= 250_000 else 0
+    for path, raster, dtype, nodata in ((dsm_path, dsm, "float32", -9999.0), (height_path, height_raster, "float32", -9999.0), (label_path, labels, "uint8", 0)):
+        write_values = np.where(np.isfinite(raster), raster, nodata).astype(dtype)
+        with rasterio.open(path, "w", driver="GTiff", height=raster.shape[0], width=raster.shape[1], count=1, dtype=dtype, transform=transform, nodata=nodata) as dst:
+            dst.write(write_values, 1)
+    _colored_preview(dsm, labels, preview_path)
+    footprints, vectorizer = vectorize(labels, transform, label_path, vector_path)
+    elevated_count = int((heights >= elevated_threshold).sum())
+    summary = pd.DataFrame([{
+        "input_file": input_path.name,
+        "original_points": original_points,
+        "downsampled_points": len(values),
+        "ground_inliers": len(inliers),
+        "elevated_points": elevated_count,
+        "elevated_footprints": len(footprints),
+        "mesh_triangles": triangles,
+        "mesh_skipped_for_large_cloud": bool(len(values) > 250_000),
+        "raster_width": dsm.shape[1],
+        "raster_height": dsm.shape[0],
+        "cell_size_local_units": voxel_size,
+    }])
+    summary.to_csv(summary_path, index=False, encoding="utf-8-sig")
+    return {
+        "file": input_path.name,
+        "original_points": original_points,
+        "downsampled_points": len(values),
+        "ground_inliers": len(inliers),
+        "elevated_points": elevated_count,
+        "elevated_point_ratio": round(elevated_count / len(values), 5),
+        "elevated_footprint_count": len(footprints),
+        "mesh_triangles": triangles,
+        "raster_width": dsm.shape[1],
+        "raster_height": dsm.shape[0],
+        "coordinate_basis": "local_point_cloud_coordinates_ground_aligned" if alignment["applied"] else "local_point_cloud_coordinates",
+        "crs": None,
+        "classified_point_cloud": classified.name,
+        "semantic_preview_point_cloud": semantic_preview_cloud.name,
+        "semantic_annotation_source_point_cloud": annotation_source_cloud.name,
+        "semantic_annotation_source_kind": "deterministic 400000-point RGB/XYZ subset from original input after voxel sampling; no semantic display colours",
+        "semantic_preview_points": int(len(preview_indices)),
+        "semantic_classified_las": semantic_las.name,
+        "semantic_raster_file": semantic_raster_path.name,
+        "semantic_preview_file": semantic_preview_path.name,
+        "semantic_vector_file": semantic_vector_path.name,
+        "semantic_summary_file": semantic_summary_path.name,
+        "semantic_footprint_count": semantic_footprints,
+        "semantic_class_counts": {details["key"]: int((semantic_classes == code).sum()) for code, details in SEMANTIC_CLASSES.items()},
+        "semantic_source_has_rgb": source_data.source_has_rgb,
+        "semantic_method": "CPU RGB and local-geometry rules; narrow line and pole/tower candidates require manual review",
+        "mesh_file": mesh.name if triangles else None,
+        "dsm_file": dsm_path.name,
+        "height_file": height_path.name,
+        "label_raster_file": label_path.name,
+        "preview_file": preview_path.name,
+        "vector_file": vector_path.name,
+        "summary_file": summary_path.name,
+        "vectorizer": vectorizer,
+        "ground_plane": [round(float(value), 8) for value in plane],
+        "ground_plane_available": ground_plane_available,
+        "coordinate_alignment": alignment,
+        "elapsed_seconds": round(time.perf_counter() - started, 3),
+    }
+
+
+def collect_inputs(input_path: Path) -> list[Path]:
+    candidates = [input_path] if input_path.is_file() else sorted(path for path in input_path.iterdir() if path.is_file()) if input_path.is_dir() else []
+    inputs = [path for path in candidates if path.suffix.lower() in SUPPORTED_SUFFIXES]
+    if not inputs:
+        raise ValueError("No supported PLY/PCD/XYZ/LAS/LAZ point-cloud inputs were found.")
+    return inputs
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="Understand an existing point-cloud reconstruction on CPU.")
+    parser.add_argument("--input", type=Path, required=True)
+    parser.add_argument("--output", type=Path, required=True)
+    parser.add_argument("--voxel-size", type=float, default=0.2)
+    parser.add_argument("--ground-distance", type=float, default=0.15)
+    parser.add_argument("--elevated-threshold", type=float, default=0.75)
+    parser.add_argument("--ground-up-axis", choices={"z", "auto"}, default="z", help="Use source Z as up, or rotate a processing copy so its dominant plane is horizontal.")
+    args = parser.parse_args()
+    if args.output.exists() and any(args.output.iterdir()):
+        raise SystemExit("Output directory is not empty; use a new run directory.")
+    args.output.mkdir(parents=True, exist_ok=True)
+    started = time.perf_counter()
+    images = [process_point_cloud(path, args.output, args.voxel_size, args.ground_distance, args.elevated_threshold, args.ground_up_axis) for path in collect_inputs(args.input)]
+    metadata = {
+        "capability": "05-3d-pointcloud",
+        "classification": "B",
+        "created_at": datetime.now(UTC).isoformat(),
+        "versions": {"geoai-py": importlib_metadata.version("geoai-py"), "open3d": o3d.__version__, "laspy": importlib_metadata.version("laspy")},
+        "method": "Open3D voxel downsampling, local-ground/RGB geometry rules, alpha-shape mesh reconstruction, GeoAI elevated-footprint vectorization, and Rasterio semantic-footprint vectorization",
+        "model": "none (explainable CPU geometry/RGB classification baseline)",
+        "device": "CPU",
+        "thresholds": {"voxel_size_local_units": args.voxel_size, "ground_plane_distance": args.ground_distance, "elevated_height": args.elevated_threshold, "ground_up_axis": args.ground_up_axis},
+        "input_count": len(images),
+        "processed_point_clouds": len(images),
+        "elapsed_seconds": round(time.perf_counter() - started, 3),
+        "point_clouds": images,
+        "limitations": [
+            "This Demo consumes an existing point cloud; it does not reconstruct a point cloud from photographs or provide photogrammetric camera calibration.",
+            "Semantic labels are explainable CPU rules, not a trained point-cloud model. Building/structure, vegetation, power-line and pole/tower outputs are review candidates, not verified asset inventory or inspection conclusions.",
+            "Power-line and pole/tower rules require thin/linear or tall/vertical local evidence. Wires hidden by vegetation, bundled conductors, tree trunks, roof edges and lattice structures can be missed or falsely labelled.",
+            "Inputs without declared CRS remain in local point-cloud coordinates. The GeoTIFF and GeoJSON do not represent latitude/longitude or surveyed map coordinates.",
+            "RANSAC assumes a dominant approximately horizontal ground plane; slopes, cliffs, dense vegetation, water, or large vertical structures can cause misses and false positives.",
+            "Mesh triangles are an inspectable alpha-shape approximation, not a watertight or accuracy-validated reconstruction.",
+        ] + (["At least one source had no plausible dominant horizontal RANSAC plane. Its local low-point surface is a fallback only, so ground/elevated and semantic classes need heightened manual review."] if any(not item["ground_plane_available"] for item in images) else []),
+    }
+    (args.output / "run_metadata.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
+    print(json.dumps(metadata, ensure_ascii=False, indent=2))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/capabilities/05-3d-pointcloud/tests/test_cpu_dense_reconstruction.py b/capabilities/05-3d-pointcloud/tests/test_cpu_dense_reconstruction.py
new file mode 100644
index 0000000..a9bab65
--- /dev/null
+++ b/capabilities/05-3d-pointcloud/tests/test_cpu_dense_reconstruction.py
@@ -0,0 +1,111 @@
+import importlib.util
+import json
+import struct
+import tempfile
+import unittest
+from argparse import Namespace
+from pathlib import Path
+
+from PIL import Image
+
+
+SCRIPT = Path(__file__).parents[1] / "run_cpu_dense_reconstruction.py"
+SPEC = importlib.util.spec_from_file_location("cpu_dense_reconstruction", SCRIPT)
+MODULE = importlib.util.module_from_spec(SPEC)
+assert SPEC.loader is not None
+SPEC.loader.exec_module(MODULE)
+
+
+def write_test_glb(path: Path) -> None:
+    image = Image.new("RGB", (8, 8), (0, 0, 0))
+    for y in range(4, 8):
+        for x in range(8):
+            image.putpixel((x, y), (20, 180, 60))
+    image.save(path.parent / "atlas.png")
+    positions = struct.pack("<" + "f" * 18, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0)
+    texcoords = struct.pack("<" + "f" * 12, 0.1, 0.1, 0.8, 0.1, 0.1, 0.3, 0.1, 0.7, 0.8, 0.7, 0.1, 0.9)
+    indices = struct.pack("<IIIIII", 0, 1, 2, 3, 4, 5)
+    binary = positions + texcoords + indices
+    document = {
+        "asset": {"version": "2.0"},
+        "buffers": [{"byteLength": len(binary)}],
+        "bufferViews": [
+            {"buffer": 0, "byteOffset": 0, "byteLength": len(positions)},
+            {"buffer": 0, "byteOffset": len(positions), "byteLength": len(texcoords)},
+            {"buffer": 0, "byteOffset": len(positions) + len(texcoords), "byteLength": len(indices)},
+        ],
+        "accessors": [
+            {"bufferView": 0, "componentType": 5126, "count": 6, "type": "VEC3"},
+            {"bufferView": 1, "componentType": 5126, "count": 6, "type": "VEC2"},
+            {"bufferView": 2, "componentType": 5125, "count": 6, "type": "SCALAR"},
+        ],
+        "images": [{"uri": "atlas.png"}],
+        "textures": [{"source": 0}],
+        "materials": [{"pbrMetallicRoughness": {"baseColorTexture": {"index": 0}}}],
+        "meshes": [{"primitives": [{"attributes": {"POSITION": 0, "TEXCOORD_0": 1}, "indices": 2, "material": 0}]}],
+    }
+    json_chunk = json.dumps(document, separators=(",", ":")).encode("utf-8")
+    json_chunk += b" " * ((-len(json_chunk)) % 4)
+    output = bytearray(struct.pack("<4sII", b"glTF", 2, 0))
+    for kind, payload in ((b"JSON", json_chunk), (b"BIN\x00", binary)):
+        output.extend(struct.pack("<I4s", len(payload), kind))
+        output.extend(payload)
+    struct.pack_into("<I", output, 8, len(output))
+    path.write_bytes(output)
+
+
+class ReliableTexturePreviewTests(unittest.TestCase):
+    def test_external_tool_paths_are_absolute(self) -> None:
+        args = Namespace(input=Path("images"), sparse_model=Path("sparse"), output=Path("output"), openmvs_bin=Path("tools"))
+        MODULE.resolve_external_tool_paths(args)
+        self.assertTrue(all(getattr(args, name).is_absolute() for name in ("input", "sparse_model", "output", "openmvs_bin")))
+
+    def test_point_color_preview_reads_openmvs_records(self) -> None:
+        with tempfile.TemporaryDirectory() as temporary:
+            directory = Path(temporary)
+            dense = directory / "dense.ply"
+            mesh = directory / "mesh.ply"
+            destination = directory / "point-colour.glb"
+            dense.write_bytes(
+                b"ply\nformat binary_little_endian 1.0\nelement vertex 4\nproperty float32 x\nproperty float32 y\nproperty float32 z\nproperty uint8 red\nproperty uint8 green\nproperty uint8 blue\nproperty float32 nx\nproperty float32 ny\nproperty float32 nz\nproperty list uint8 uint32 view_indices\nproperty list uint8 float32 view_weights\nend_header\n"
+                + b"".join(struct.pack("<fffBBBfffBB", *point, 0, 0) for point in ((0, 0, 0, 255, 0, 0, 0, 0, 1), (1, 0, 0, 0, 255, 0, 0, 0, 1), (0, 1, 0, 0, 0, 255, 0, 0, 1), (0, 0, 1, 255, 255, 255, 0, 0, 1)))
+            )
+            mesh.write_text(
+                "ply\nformat ascii 1.0\nelement vertex 4\nproperty float x\nproperty float y\nproperty float z\nelement face 4\nproperty list uchar int vertex_indices\nend_header\n"
+                "0 0 0\n1 0 0\n0 1 0\n0 0 1\n3 0 1 2\n3 0 1 3\n3 0 2 3\n3 1 2 3\n",
+                encoding="ascii",
+            )
+            self.assertEqual(MODULE.build_point_color_preview_glb(dense, mesh, destination), 4)
+            self.assertTrue(destination.is_file())
+
+    def test_geometry_preview_keeps_all_mesh_faces(self) -> None:
+        with tempfile.TemporaryDirectory() as temporary:
+            directory = Path(temporary)
+            source = directory / "mesh.ply"
+            destination = directory / "geometry.glb"
+            source.write_text(
+                "ply\nformat ascii 1.0\nelement vertex 4\nproperty float x\nproperty float y\nproperty float z\nelement face 4\nproperty list uchar int vertex_indices\nend_header\n"
+                "0 0 0\n1 0 0\n0 1 0\n0 0 1\n3 0 1 2\n3 0 1 3\n3 0 2 3\n3 1 2 3\n",
+                encoding="ascii",
+            )
+            self.assertEqual(MODULE.build_geometry_preview_glb(source, destination), 4)
+            self.assertTrue(destination.is_file())
+
+    def test_placeholder_colours_are_rejected(self) -> None:
+        self.assertFalse(MODULE.reliable_texture_pixel((0, 0, 0)))
+        self.assertFalse(MODULE.reliable_texture_pixel((255, 127, 39)))
+        self.assertTrue(MODULE.reliable_texture_pixel((20, 180, 60)))
+
+    def test_preview_keeps_only_faces_with_reliable_samples(self) -> None:
+        with tempfile.TemporaryDirectory() as temporary:
+            directory = Path(temporary)
+            source = directory / "source.glb"
+            destination = directory / "preview.glb"
+            report_path = directory / "report.json"
+            write_test_glb(source)
+            report = MODULE.build_filtered_preview_glb(source, destination, report_path)
+            self.assertEqual(report["input_faces"], 2)
+            self.assertEqual(report["kept_faces"], 1)
+            self.assertEqual(report["rejected_black_faces"], 1)
+            self.assertTrue(destination.is_file())
+            self.assertEqual(json.loads(report_path.read_text(encoding="utf-8"))["kept_faces"], 1)
diff --git a/capabilities/05-3d-pointcloud/tests/test_photo_pose_alignment.py b/capabilities/05-3d-pointcloud/tests/test_photo_pose_alignment.py
new file mode 100644
index 0000000..e628d4c
--- /dev/null
+++ b/capabilities/05-3d-pointcloud/tests/test_photo_pose_alignment.py
@@ -0,0 +1,37 @@
+from __future__ import annotations
+
+import importlib.util
+import unittest
+from pathlib import Path
+
+import numpy as np
+
+
+SCRIPT = Path(__file__).parents[1] / "diagnose_photo_pose_alignment.py"
+SPEC = importlib.util.spec_from_file_location("photo_pose_alignment", SCRIPT)
+MODULE = importlib.util.module_from_spec(SPEC)
+assert SPEC.loader is not None
+SPEC.loader.exec_module(MODULE)
+
+
+class PhotoPoseAlignmentTests(unittest.TestCase):
+    def test_similarity_alignment_recovers_metric_transform(self) -> None:
+        source = np.asarray([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [0.0, 3.0, 1.0], [1.0, 2.0, 4.0]])
+        expected_rotation = np.asarray([[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]])
+        target = (2.5 * (expected_rotation @ source.T)).T + np.asarray([10.0, -4.0, 7.0])
+        scale, rotation, translation = MODULE.similarity_alignment(source, target)
+        actual = (scale * (rotation @ source.T)).T + translation
+        self.assertAlmostEqual(scale, 2.5, places=8)
+        np.testing.assert_allclose(actual, target, atol=1e-8)
+
+    def test_wgs84_to_enu_keeps_origin_at_zero(self) -> None:
+        origin = np.asarray([25.0, 113.0, 100.0])
+        enu = MODULE.wgs84_to_enu(np.asarray([origin, [25.0001, 113.0001, 105.0]]), origin)
+        np.testing.assert_allclose(enu[0], np.zeros(3), atol=1e-6)
+        self.assertGreater(enu[1, 0], 0.0)
+        self.assertGreater(enu[1, 1], 0.0)
+        self.assertGreater(enu[1, 2], 4.9)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/capabilities/05-3d-pointcloud/tests/test_photo_reconstruction.py b/capabilities/05-3d-pointcloud/tests/test_photo_reconstruction.py
new file mode 100644
index 0000000..9128779
--- /dev/null
+++ b/capabilities/05-3d-pointcloud/tests/test_photo_reconstruction.py
@@ -0,0 +1,45 @@
+from __future__ import annotations
+
+import tempfile
+import unittest
+from pathlib import Path
+
+from PIL import Image
+
+import sys
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+from run_photo_reconstruction import collect_images, reconstruct_photos, validate_camera_configuration, validate_matching_configuration  # noqa: E402
+
+
+class PhotoReconstructionTests(unittest.TestCase):
+    def test_collect_images_requires_two_jpegs(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            Image.new("RGB", (32, 32), "white").save(root / "one.jpg")
+            with self.assertRaises(ValueError):
+                collect_images(root)
+            Image.new("RGB", (32, 32), "black").save(root / "two.jpeg")
+            self.assertEqual([item.name for item in collect_images(root)], ["one.jpg", "two.jpeg"])
+
+    def test_invalid_processing_limits_fail_before_sfm(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            Image.new("RGB", (32, 32), "white").save(root / "one.jpg")
+            Image.new("RGB", (32, 32), "black").save(root / "two.jpg")
+            with self.assertRaises(ValueError):
+                reconstruct_photos(root, root / "result", max_image_size=320)
+
+    def test_matching_configuration_rejects_unknown_or_empty_pairing(self) -> None:
+        with self.assertRaises(ValueError):
+            validate_matching_configuration("unsupported", 4)
+        with self.assertRaises(ValueError):
+            validate_matching_configuration("spatial", 0)
+
+    def test_opencv_camera_model_rejects_simple_radial_focal_override(self) -> None:
+        with self.assertRaises(ValueError):
+            validate_camera_configuration("OPENCV", 2795.0)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/capabilities/05-3d-pointcloud/tests/test_pointcloud_understanding.py b/capabilities/05-3d-pointcloud/tests/test_pointcloud_understanding.py
new file mode 100644
index 0000000..bf91aa2
--- /dev/null
+++ b/capabilities/05-3d-pointcloud/tests/test_pointcloud_understanding.py
@@ -0,0 +1,65 @@
+from __future__ import annotations
+
+import tempfile
+import unittest
+from pathlib import Path
+
+import numpy as np
+import open3d as o3d
+
+import sys
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+from run_pointcloud_understanding import classify_semantic_points, process_point_cloud  # noqa: E402
+
+
+def write_cloud(path: Path, points: np.ndarray) -> None:
+    cloud = o3d.geometry.PointCloud()
+    cloud.points = o3d.utility.Vector3dVector(points)
+    assert o3d.io.write_point_cloud(str(path), cloud)
+
+
+class PointCloudUnderstandingTests(unittest.TestCase):
+    def test_raster_vector_and_mesh_are_created(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            rng = np.random.default_rng(2)
+            ground = np.column_stack((rng.uniform(-4, 4, (600, 2)), rng.normal(0, 0.01, 600)))
+            roof = np.column_stack((rng.uniform(-1, 1, (350, 2)), rng.normal(2, 0.02, 350)))
+            source = root / "site.ply"
+            write_cloud(source, np.vstack((ground, roof)))
+            result = process_point_cloud(source, root / "output", voxel_size=0.2, ground_distance=0.1, elevated_threshold=0.6)
+            self.assertGreater(result["elevated_points"], 0)
+            self.assertGreater(result["elevated_footprint_count"], 0)
+            self.assertEqual(result["coordinate_basis"], "local_point_cloud_coordinates")
+            for field in ("dsm_file", "label_raster_file", "preview_file", "vector_file", "summary_file", "classified_point_cloud"):
+                self.assertTrue((root / "output" / result[field]).is_file())
+
+    def test_small_cloud_fails(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            source = root / "small.ply"
+            write_cloud(source, np.zeros((4, 3)))
+            with self.assertRaises(ValueError):
+                process_point_cloud(source, root / "output")
+
+    def test_semantic_rules_keep_unknown_and_identify_strong_candidates(self) -> None:
+        ground = np.column_stack((np.linspace(0, 4, 20), np.zeros(20), np.zeros(20)))
+        structure = np.column_stack((10 + np.linspace(0, 0.8, 20), np.zeros(20), np.full(20, 3.0)))
+        vegetation = np.column_stack((20 + np.linspace(0, 0.8, 20), np.zeros(20), np.linspace(2.0, 3.5, 20)))
+        wire = np.column_stack((30 + np.arange(20), np.zeros(20), np.linspace(6.0, 6.1, 20)))
+        pole = np.column_stack((40 + np.zeros(20), np.zeros(20), np.linspace(1.0, 10.0, 20)))
+        points = np.vstack((ground, structure, vegetation, wire, pole))
+        heights = points[:, 2].copy()
+        colors = np.full((len(points), 3), 0.5)
+        colors[len(ground) + len(structure):len(ground) + len(structure) + len(vegetation)] = [0.15, 0.7, 0.15]
+        labels = classify_semantic_points(points, colors, heights, voxel_size=0.2)
+        self.assertIn(2, labels)
+        self.assertIn(5, labels)
+        self.assertIn(6, labels)
+        self.assertIn(15, labels)
+        self.assertIn(16, labels)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/capabilities/05-3d-pointcloud/tests/test_prepare_odx_reconstruction_inputs.py b/capabilities/05-3d-pointcloud/tests/test_prepare_odx_reconstruction_inputs.py
new file mode 100644
index 0000000..2b5b4d4
--- /dev/null
+++ b/capabilities/05-3d-pointcloud/tests/test_prepare_odx_reconstruction_inputs.py
@@ -0,0 +1,66 @@
+from __future__ import annotations
+
+import importlib.util
+import json
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+
+SCRIPT = Path(__file__).parents[1] / "prepare_odx_reconstruction_inputs.py"
+SPEC = importlib.util.spec_from_file_location("prepare_odx_reconstruction_inputs", SCRIPT)
+MODULE = importlib.util.module_from_spec(SPEC)
+assert SPEC.loader is not None
+sys.modules[SPEC.name] = MODULE
+SPEC.loader.exec_module(MODULE)
+
+
+class OdxInputPreparationTests(unittest.TestCase):
+    def test_dji_orientation_matches_referenced_odx_normalization(self) -> None:
+        self.assertEqual(MODULE.odx_orientation_from_dji(-25.9, -37.7, 0.0), (-25.9, 52.3, 0.0))
+
+    def test_geo_txt_uses_webodm_epsg4326_field_order(self) -> None:
+        record = MODULE.OdxPhoto(
+            image="DJI_0001.JPG", longitude=113.317951157, latitude=25.660699129, altitude_m=1201.372,
+            yaw_deg=-25.9, pitch_deg=52.3, roll_deg=0.0, horizontal_accuracy_m=0.0046, vertical_accuracy_m=0.0112,
+            width=4000, height=3000, sha256="a" * 64, rtk_std_lat_raw=0.0023, rtk_std_lon_raw=0.00201, rtk_std_hgt_raw=0.0056,
+        )
+        with tempfile.TemporaryDirectory() as temporary:
+            path = Path(temporary) / "geo.txt"
+            MODULE.write_geo_txt([record], path)
+            lines = path.read_text(encoding="utf-8").splitlines()
+        self.assertEqual(lines[0], "EPSG:4326")
+        fields = lines[1].split()
+        self.assertEqual(fields[0], "DJI_0001.JPG")
+        self.assertEqual(len(fields), 9)
+        self.assertEqual(float(fields[1]), record.longitude)
+        self.assertEqual(float(fields[2]), record.latitude)
+        self.assertEqual(float(fields[5]), record.pitch_deg)
+
+    def test_camera_positions_are_valid_geojson(self) -> None:
+        record = MODULE.OdxPhoto(
+            image="DJI_0001.JPG", longitude=113.0, latitude=25.0, altitude_m=100.0,
+            yaw_deg=0.0, pitch_deg=90.0, roll_deg=0.0, horizontal_accuracy_m=0.02, vertical_accuracy_m=0.03,
+            width=1, height=1, sha256="b" * 64, rtk_std_lat_raw=0.01, rtk_std_lon_raw=0.01, rtk_std_hgt_raw=0.015,
+        )
+        with tempfile.TemporaryDirectory() as temporary:
+            path = Path(temporary) / "positions.geojson"
+            MODULE.write_camera_positions_geojson([record], path)
+            payload = json.loads(path.read_text(encoding="utf-8"))
+        self.assertEqual(payload["type"], "FeatureCollection")
+        self.assertEqual(payload["features"][0]["geometry"]["coordinates"], [113.0, 25.0, 100.0])
+
+    def test_recommendations_do_not_override_per_photo_accuracy(self) -> None:
+        record = MODULE.OdxPhoto(
+            image="DJI_0001.JPG", longitude=113.0, latitude=25.0, altitude_m=100.0,
+            yaw_deg=0.0, pitch_deg=90.0, roll_deg=0.0, horizontal_accuracy_m=0.02, vertical_accuracy_m=0.03,
+            width=1, height=1, sha256="c" * 64, rtk_std_lat_raw=0.01, rtk_std_lon_raw=0.01, rtk_std_hgt_raw=0.015,
+        )
+        options = MODULE.odx_option_recommendations([record])
+        self.assertNotIn("gps-accuracy-m", options["recommendations"])
+        self.assertIn("Leave unset", options["conditional_options"]["gps-accuracy"])
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/capabilities/05-3d-pointcloud/tests/test_rtk_lrf_pose_triangulation.py b/capabilities/05-3d-pointcloud/tests/test_rtk_lrf_pose_triangulation.py
new file mode 100644
index 0000000..eef56ec
--- /dev/null
+++ b/capabilities/05-3d-pointcloud/tests/test_rtk_lrf_pose_triangulation.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+import importlib.util
+import sys
+import unittest
+from pathlib import Path
+
+import numpy as np
+
+
+SCRIPT = Path(__file__).parents[1] / "triangulate_with_rtk_lrf_poses.py"
+sys.path.insert(0, str(SCRIPT.parent))
+SPEC = importlib.util.spec_from_file_location("rtk_lrf_pose_triangulation", SCRIPT)
+MODULE = importlib.util.module_from_spec(SPEC)
+assert SPEC.loader is not None
+SPEC.loader.exec_module(MODULE)
+
+
+class RtkLrfPoseTriangulationTests(unittest.TestCase):
+    def test_upright_pose_looks_at_target(self) -> None:
+        centre = np.asarray([10.0, 20.0, 30.0])
+        target = np.asarray([30.0, 60.0, 10.0])
+        pose = MODULE.upright_cam_from_world(centre, target)
+        np.testing.assert_allclose(pose.inverse().translation, centre, atol=1e-10)
+        forward = pose.rotation.matrix().T @ np.asarray([0.0, 0.0, 1.0])
+        expected = (target - centre) / np.linalg.norm(target - centre)
+        np.testing.assert_allclose(forward, expected, atol=1e-10)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/capabilities/05-3d-pointcloud/train_pointcloud_semantic_model.py b/capabilities/05-3d-pointcloud/train_pointcloud_semantic_model.py
new file mode 100644
index 0000000..05ef409
--- /dev/null
+++ b/capabilities/05-3d-pointcloud/train_pointcloud_semantic_model.py
@@ -0,0 +1,191 @@
+"""Train a local CPU/GPU portable point-wise semantic classifier from human labels.
+
+The training input is an immutable annotation revision created in the workbench.
+It deliberately does not read the rule-classification labels as training truth.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import time
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+import open3d as o3d
+import torch
+from sklearn.metrics import classification_report, confusion_matrix
+
+
+CLASS_SCHEMA = {
+    1: {"key": "other_unknown", "label": "Other / unknown", "color": [128, 128, 128]},
+    2: {"key": "ground", "label": "Ground", "color": [151, 111, 51]},
+    5: {"key": "vegetation", "label": "Vegetation", "color": [59, 163, 87]},
+    6: {"key": "building_structure", "label": "Building / structure", "color": [224, 115, 55]},
+    15: {"key": "pole_tower", "label": "Pole / tower", "color": [149, 89, 210]},
+    16: {"key": "power_line", "label": "Power line", "color": [231, 196, 61]},
+}
+MIN_POINTS_PER_CLASS = 500
+
+
+class PointWiseNet(torch.nn.Module):
+    """Small PointNet-style shared MLP; same state dict runs on CPU or CUDA."""
+
+    def __init__(self, class_count: int) -> None:
+        super().__init__()
+        self.layers = torch.nn.Sequential(
+            torch.nn.Linear(6, 96), torch.nn.ReLU(), torch.nn.BatchNorm1d(96),
+            torch.nn.Linear(96, 128), torch.nn.ReLU(), torch.nn.Dropout(0.15),
+            torch.nn.Linear(128, 96), torch.nn.ReLU(), torch.nn.Linear(96, class_count),
+        )
+
+    def forward(self, values: torch.Tensor) -> torch.Tensor:
+        return self.layers(values)
+
+
+def sha256(path: Path) -> str:
+    digest = hashlib.sha256()
+    with path.open("rb") as stream:
+        for chunk in iter(lambda: stream.read(8 * 1024 * 1024), b""):
+            digest.update(chunk)
+    return digest.hexdigest()
+
+
+def load_features(source: Path) -> tuple[np.ndarray, dict[str, Any]]:
+    cloud = o3d.io.read_point_cloud(str(source))
+    xyz = np.asarray(cloud.points, dtype=np.float32)
+    rgb = np.asarray(cloud.colors, dtype=np.float32)
+    if len(xyz) < 1 or xyz.shape != rgb.shape:
+        raise ValueError("Annotation source must be a coloured point-cloud PLY.")
+    center = xyz.mean(axis=0)
+    scale = float(np.maximum(np.abs(xyz - center).max(), 1e-6))
+    return np.column_stack(((xyz - center) / scale, np.clip(rgb, 0.0, 1.0))).astype(np.float32), {
+        "xyz_center": center.tolist(), "xyz_scale": scale,
+    }
+
+
+def spatial_split(xyz: np.ndarray, indices: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
+    """Split complete XY blocks so adjacent tower/line points cannot leak."""
+    block_size = max(float(np.ptp(xyz[:, 0])) / 8.0, float(np.ptp(xyz[:, 1])) / 8.0, 1.0)
+    blocks = np.floor(xyz[indices, :2] / block_size).astype(np.int64)
+    hashes = (blocks[:, 0] * 73856093 + blocks[:, 1] * 19349663) % 10
+    train, validation, test = indices[hashes < 7], indices[(hashes >= 7) & (hashes < 9)], indices[hashes >= 9]
+    if not len(validation) or not len(test) or not len(train):
+        ordered = np.sort(indices)
+        train, validation, test = ordered[::3], ordered[1::3], ordered[2::3]
+    return train, validation, test
+
+
+def metrics(y_true: np.ndarray, y_pred: np.ndarray, classes: list[int]) -> dict[str, Any]:
+    names = [CLASS_SCHEMA[code]["key"] for code in classes]
+    return {
+        "class_codes": classes,
+        "class_keys": names,
+        "confusion_matrix": confusion_matrix(y_true, y_pred, labels=classes).tolist(),
+        "report": classification_report(y_true, y_pred, labels=classes, target_names=names, output_dict=True, zero_division=0),
+        "sample_count": int(len(y_true)),
+    }
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="Train semantic point-cloud model from human-confirmed labels.")
+    parser.add_argument("--annotation", type=Path, required=True)
+    parser.add_argument("--output", type=Path, required=True)
+    parser.add_argument("--device", choices={"auto", "cpu", "cuda"}, default="auto")
+    parser.add_argument("--epochs", type=int, default=40)
+    parser.add_argument("--batch-size", type=int, default=4096)
+    args = parser.parse_args()
+    if args.output.exists() and any(args.output.iterdir()):
+        raise SystemExit("Output directory must be new or empty.")
+    annotation = json.loads(args.annotation.read_text(encoding="utf-8"))
+    if annotation.get("schema_version") != 1:
+        raise SystemExit("Unsupported annotation schema.")
+    source = Path(str(annotation.get("source_path") or ""))
+    if not source.is_file() or sha256(source) != annotation.get("source_sha256"):
+        raise SystemExit("Annotation source is unavailable or its checksum changed.")
+    labels = annotation.get("labels")
+    if not isinstance(labels, list):
+        raise SystemExit("Annotation labels are missing.")
+    features, normalizer = load_features(source)
+    source_cloud = o3d.io.read_point_cloud(str(source))
+    xyz = np.asarray(source_cloud.points, dtype=np.float32)
+    assigned: dict[int, int] = {}
+    for item in labels:
+        if not isinstance(item, list) or len(item) != 2 or not all(isinstance(value, int) for value in item):
+            raise SystemExit("Annotation contains an invalid point label.")
+        index, code = item
+        if not 0 <= index < len(features) or code not in CLASS_SCHEMA:
+            raise SystemExit("Annotation contains an out-of-range point label.")
+        assigned[index] = code
+    indices = np.asarray(sorted(assigned), dtype=np.int64)
+    target = np.asarray([assigned[int(index)] for index in indices], dtype=np.int64)
+    class_codes = sorted(set(target.tolist()))
+    per_class = {code: int((target == code).sum()) for code in class_codes}
+    if len(class_codes) < 2 or any(count < MIN_POINTS_PER_CLASS for count in per_class.values()):
+        raise SystemExit(f"Need at least two classes and {MIN_POINTS_PER_CLASS} confirmed points per class; current={per_class}.")
+    train_idx, validation_idx, test_idx = spatial_split(xyz, indices)
+    if min(len(train_idx), len(validation_idx), len(test_idx)) < len(class_codes):
+        raise SystemExit("Labels do not cover enough spatial blocks for train/validation/test evaluation.")
+    code_to_class = {code: offset for offset, code in enumerate(class_codes)}
+    label_by_index = {int(index): code_to_class[int(code)] for index, code in zip(indices, target, strict=True)}
+    device_name = "cuda" if args.device == "cuda" or (args.device == "auto" and torch.cuda.is_available()) else "cpu"
+    if args.device == "cuda" and not torch.cuda.is_available():
+        raise SystemExit("CUDA was requested but is unavailable.")
+    device = torch.device(device_name)
+    torch.manual_seed(42)
+    model = PointWiseNet(len(class_codes)).to(device)
+    optimizer = torch.optim.AdamW(model.parameters(), lr=0.001, weight_decay=1e-4)
+    weight = torch.tensor([len(train_idx) / max(1, sum(label_by_index[int(i)] == group for i in train_idx)) for group in range(len(class_codes))], dtype=torch.float32, device=device)
+    criterion = torch.nn.CrossEntropyLoss(weight=weight)
+    train_labels = np.asarray([label_by_index[int(index)] for index in train_idx], dtype=np.int64)
+    started = time.perf_counter()
+    for _ in range(args.epochs):
+        order = np.random.default_rng(42).permutation(len(train_idx))
+        model.train()
+        for start in range(0, len(order), args.batch_size):
+            subset = train_idx[order[start:start + args.batch_size]]
+            x = torch.from_numpy(features[subset]).to(device)
+            y = torch.from_numpy(np.asarray([label_by_index[int(index)] for index in subset], dtype=np.int64)).to(device)
+            optimizer.zero_grad(set_to_none=True)
+            criterion(model(x), y).backward()
+            optimizer.step()
+    def predict(indices_to_predict: np.ndarray) -> np.ndarray:
+        model.eval(); parts: list[np.ndarray] = []
+        with torch.no_grad():
+            for start in range(0, len(indices_to_predict), args.batch_size):
+                logits = model(torch.from_numpy(features[indices_to_predict[start:start + args.batch_size]]).to(device))
+                parts.append(logits.argmax(dim=1).cpu().numpy())
+        return np.concatenate(parts)
+    validation_true = np.asarray([target[np.searchsorted(indices, index)] for index in validation_idx], dtype=np.int64)
+    test_true = np.asarray([target[np.searchsorted(indices, index)] for index in test_idx], dtype=np.int64)
+    validation_pred = np.asarray([class_codes[value] for value in predict(validation_idx)], dtype=np.int64)
+    test_pred = np.asarray([class_codes[value] for value in predict(test_idx)], dtype=np.int64)
+    all_pred = np.asarray([class_codes[value] for value in predict(np.arange(len(features), dtype=np.int64))], dtype=np.uint8)
+    args.output.mkdir(parents=True, exist_ok=True)
+    predicted = o3d.geometry.PointCloud()
+    predicted.points = o3d.utility.Vector3dVector(xyz)
+    predicted.colors = o3d.utility.Vector3dVector(np.asarray([CLASS_SCHEMA[int(code)]["color"] for code in all_pred], dtype=np.float64) / 255.0)
+    o3d.io.write_point_cloud(str(args.output / "predicted-semantic-preview.ply"), predicted, write_ascii=False)
+    payload = {
+        "capability": "05-3d-pointcloud", "classification": "B", "created_at": datetime.now(UTC).isoformat(),
+        "model": "PointWiseNet shared MLP (human-confirmed point labels)", "device": device_name,
+        "torch_version": torch.__version__, "annotation": str(args.annotation), "source": str(source),
+        "source_sha256": annotation["source_sha256"], "classes": {str(code): CLASS_SCHEMA[code] for code in class_codes},
+        "label_counts": {str(code): count for code, count in per_class.items()},
+        "split_counts": {"train": int(len(train_idx)), "validation": int(len(validation_idx)), "test": int(len(test_idx))},
+        "validation": metrics(validation_true, validation_pred, class_codes), "test": metrics(test_true, test_pred, class_codes),
+        "normalizer": normalizer, "epochs": args.epochs, "batch_size": args.batch_size,
+        "elapsed_seconds": round(time.perf_counter() - started, 3),
+        "limitations": ["Metrics cover only human-confirmed points in this annotation revision.", "Spatial blocks reduce leakage but one small source cannot establish field-wide generalization.", "Rule candidate colours were not used as labels or input features."],
+    }
+    torch.save({"state_dict": model.cpu().state_dict(), "class_codes": class_codes, "normalizer": normalizer, "schema_version": 1}, args.output / "model.pt")
+    (args.output / "metrics.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+    print(json.dumps(payload, ensure_ascii=False))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/capabilities/05-3d-pointcloud/triangulate_with_rtk_lrf_poses.py b/capabilities/05-3d-pointcloud/triangulate_with_rtk_lrf_poses.py
new file mode 100644
index 0000000..7783fa9
--- /dev/null
+++ b/capabilities/05-3d-pointcloud/triangulate_with_rtk_lrf_poses.py
@@ -0,0 +1,119 @@
+"""Triangulate known photo matches from fixed DJI RTK/LRF exterior poses.
+
+This is a sparse, diagnostic reconstruction.  It fixes each optical centre to
+the RTK coordinate and its forward axis to the RTK laser target coordinate.
+The camera roll is made upright against ENU up; this is appropriate only while
+the H30T gimbal roll is close to zero.  No dense MVS runs from this model until
+the sparse-model acceptance metrics are reviewed.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import shutil
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+import pycolmap
+
+from diagnose_photo_pose_alignment import wgs84_to_enu, xmp_record
+
+
+def upright_cam_from_world(center: np.ndarray, target: np.ndarray) -> pycolmap.Rigid3d:
+    """Return COLMAP's right/down/forward pose from ENU centre and target."""
+    forward = target - center
+    forward /= np.linalg.norm(forward)
+    world_up = np.asarray([0.0, 0.0, 1.0])
+    down = -(world_up - np.dot(world_up, forward) * forward)
+    down /= np.linalg.norm(down)
+    right = np.cross(down, forward)
+    right /= np.linalg.norm(right)
+    rotation = np.vstack((right, down, forward))
+    return pycolmap.Rigid3d(pycolmap.Rotation3d(rotation), -(rotation @ center))
+
+
+def triangulate_with_fixed_poses(input_dir: Path, sparse_model: Path, database: Path, output_dir: Path) -> dict[str, Any]:
+    if output_dir.exists() and any(output_dir.iterdir()):
+        raise ValueError("Output directory is not empty; use a new output directory.")
+    if not database.is_file():
+        raise ValueError(f"COLMAP database does not exist: {database}")
+    output_dir.mkdir(parents=True, exist_ok=True)
+    for path in sparse_model.iterdir():
+        if path.is_file():
+            shutil.copy2(path, output_dir / path.name)
+    reconstruction = pycolmap.Reconstruction(output_dir)
+    images = sorted(reconstruction.images.values(), key=lambda image: image.name)
+    sources = {path.name: path for path in input_dir.iterdir() if path.suffix.lower() in {".jpg", ".jpeg"}}
+    missing = [image.name for image in images if image.name not in sources]
+    if missing:
+        raise ValueError(f"Source image files are missing: {', '.join(missing[:3])}")
+    records = [xmp_record(sources[image.name]) for image in images]
+    if any(abs(float(record["GimbalRollDegree"] or 0.0)) > 2.0 for record in records):
+        raise ValueError("Fixed RTK/LRF pose trial requires gimbal roll within 2 degrees; provide a calibrated roll transform instead.")
+    camera_wgs = np.asarray([[record["GpsLatitude"], record["GpsLongitude"], record["AbsoluteAltitude"]] for record in records])
+    target_wgs = np.asarray([[record["LRFTargetLat"], record["LRFTargetLon"], record["LRFTargetAbsAlt"]] for record in records])
+    centers = wgs84_to_enu(camera_wgs, camera_wgs[0])
+    targets = wgs84_to_enu(target_wgs, camera_wgs[0])
+    for image, center, target in zip(images, centers, targets, strict=True):
+        image.frame.set_cam_from_world(image.camera_id, upright_cam_from_world(center, target))
+    reconstruction.write(output_dir)
+
+    # The high-level triangulate_points helper always performs local/global BA.
+    # Use COLMAP's triangulator directly so the externally supplied poses stay fixed.
+    for point_id in list(reconstruction.points3D.keys()):
+        reconstruction.delete_point3D(point_id)
+    database_handle = pycolmap.Database.open(database)
+    cache_options = pycolmap.DatabaseCacheOptions()
+    cache_options.load_all_images = True
+    cache = pycolmap.DatabaseCache.create(database_handle, cache_options)
+    database_handle.close()
+    observation_manager = pycolmap.ObservationManager(reconstruction, cache.correspondence_graph)
+    triangulator = pycolmap.IncrementalTriangulator(cache.correspondence_graph, reconstruction, observation_manager)
+    triangulation_options = pycolmap.IncrementalTriangulatorOptions()
+    for image in images:
+        triangulator.triangulate_image(triangulation_options, image.image_id)
+    triangulator.complete_all_tracks(triangulation_options)
+    reconstruction.update_point_3d_errors()
+    result = reconstruction
+    result.write(output_dir)
+    result_images = sorted(result.images.values(), key=lambda image: image.name)
+    fixed_centers = np.asarray([image.projection_center() for image in result_images], dtype=float)
+    center_errors = np.linalg.norm(fixed_centers - centers, axis=1)
+    report = {
+        "classification": "B",
+        "method": "CPU fixed-exterior-pose sparse triangulation: RTK optical centres, LRF target forward axes, ENU upright roll reference, and COLMAP feature matches",
+        "registered_images": len(result.images),
+        "sparse_points": len(result.points3D),
+        "mean_reprojection_error_pixels": round(float(result.compute_mean_reprojection_error()), 4),
+        "fixed_camera_center_error_m": {"median": round(float(np.median(center_errors)), 6), "max": round(float(np.max(center_errors)), 6)},
+        "intrinsics": {"model": result.cameras[images[0].camera_id].model.name, "params": result.cameras[images[0].camera_id].params.tolist()},
+        "assumptions": [
+            "The DJI RTK coordinate is treated as the optical centre; no antenna-to-camera lever arm is applied.",
+            "The DJI LRF target is treated as the optical-axis intersection in each photo.",
+            "Gimbal roll is near zero and ENU up provides image upright orientation.",
+        ],
+        "acceptance": "Use only if all photos register, fixed camera centres remain unchanged, and sparse reprojection error/point count are comparable to the RTK-constrained SfM baseline. This is not a survey-grade calibration or a dense-model acceptance.",
+    }
+    (output_dir / "fixed_pose_metadata.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
+    return report
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="Triangulate a sparse model from fixed DJI RTK/LRF poses.")
+    parser.add_argument("--input", type=Path, required=True, help="Original DJI JPG/JPEG directory.")
+    parser.add_argument("--sparse-model", type=Path, required=True, help="Existing COLMAP sparse-model directory used for intrinsics and image records.")
+    parser.add_argument("--database", type=Path, required=True, help="Matching COLMAP feature/match database.")
+    parser.add_argument("--output", type=Path, required=True, help="New output directory.")
+    args = parser.parse_args()
+    try:
+        result = triangulate_with_fixed_poses(args.input, args.sparse_model, args.database, args.output)
+    except (ValueError, RuntimeError, OSError) as error:
+        raise SystemExit(str(error)) from error
+    print(json.dumps(result, ensure_ascii=False, indent=2))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/capabilities/07-risk-rule-engine/README.md b/capabilities/07-risk-rule-engine/README.md
index f22d6db..bf41698 100644
--- a/capabilities/07-risk-rule-engine/README.md
+++ b/capabilities/07-risk-rule-engine/README.md
@@ -1,6 +1,67 @@
-# Risk Rule Engine
+# 空间规则与风险评分
 
-- 输入:空间对象、属性、时间和可配置规则。
-- 输出:风险等级、命中原因和事件建议。
-- 首个 Demo:10 条可解释规则,支持结果回放。
+## 能力边界
 
+- 分类:C,产品/服务能力。
+- `geoai-py` 角色:可作为上游检测、分割或量测工作流的栅格/矢量成果来源;本 Demo 不调用 `geoai-py`,也不把规则或评分描述为它的内置 API。
+- 实现:GeoPandas、Shapely 和 Rasterio 在 CPU 上对空间对象执行可配置规则、计算风险分数并输出可复核的栅格与矢量成果。
+
+## 可运行 Demo
+
+输入必须是带有效 CRS 的三个文件:
+
+1. `observations.geojson`:待评分要素,必须有唯一的 `object_id` 和数值 `confidence` 字段。
+2. `zones.geojson`:风险分区,必须有 `zone_type` 字段。
+3. `rules.json`:规则、等级阈值和分数上限。
+
+输入 CRS 可以不同,分区会仅在内存中对齐到观测对象 CRS;无 CRS、地理坐标 CRS 或无法转换的输入会失败,避免把经纬度面积误作米制面积。首个 Demo 使用 EPSG:3857 的合成可追溯案例;它只验证流程逻辑,不代表现场风险精度。
+
+```powershell
+$py = .\.venvs\07-risk-rule-engine\Scripts\python.exe
+& $py .\capabilities\07-risk-rule-engine\generate_validation_inputs.py --output .\shared\data\raw\07-risk-rule-engine\validation-20260820
+& $py .\capabilities\07-risk-rule-engine\run_risk_rule_engine.py --observations .\shared\data\raw\07-risk-rule-engine\validation-20260820\normal\observations.geojson --zones .\shared\data\raw\07-risk-rule-engine\validation-20260820\normal\zones.geojson --rules .\shared\data\raw\07-risk-rule-engine\validation-20260820\normal\rules.json --output .\shared\outputs\07-risk-rule-engine\validation-normal-20260820
+```
+
+输出目录不可复用,避免覆盖历史结果:
+
+- `risk_score.tif`:风险分区及观测对象得分的单波段 GeoTIFF。
+- `risk_preview.png`:同一栅格的颜色预览,供人工视觉检查。
+- `risk_scores.geojson`:每个对象的分值、等级、命中规则、贡献及建议。
+- `risk_scores.csv`、`risk_summary.json` 与 `run_metadata.json`:可机读明细、汇总与运行证据。
+
+## 规则格式
+
+规则支持 `zone_intersection` 与 `attribute_threshold` 两类。分区规则以 `zone_type` 匹配,属性规则目前支持数值 `lt` / `lte` / `gt` / `gte` 比较。对象总分为全部命中规则分数之和,再按 `score_cap` 截断;等级阈值默认是 `medium=30`、`high=60`、`critical=85`。同一对象的每个命中规则都保留在输出中,不会被合并隐藏。
+
+## 验收集
+
+`generate_validation_inputs.py` 生成以下合成输入:
+
+| 样本 | 目的 | 可复核预期 |
+| --- | --- | --- |
+| `normal` | 无分区、普通关注分区、限制分区 | 0、25、90 分,分别验证无命中、单规则和重叠分区。 |
+| `difficult` | 分区边界、低置信度、分数上限 | 边界点命中两个分区得 90;低置信度限制区对象在 100 分上限截断;区外低置信度对象得 20。 |
+
+```powershell
+& $py -m unittest discover -s .\capabilities\07-risk-rule-engine\tests -v
+& $py -m pip check
+```
+
+## 本地实验控制台
+
+控制台页遵循“新建运行 → 案例库 → 结果工作区”。上传仅接收上述三份 `.geojson` / `.json` 文件,服务器生成新的运行 ID,并把源文件复制到 `shared/data/raw/07-risk-rule-engine/runs/<run-id>/`,处理副本放到 `shared/data/processed/07-risk-rule-engine/<run-id>/`,结果放到 `shared/outputs/07-risk-rule-engine/runs/<run-id>/`。它不访问无人机产品、帐号、产品 API 或数据库。
+
+```powershell
+Set-Location .\apps\workbench-console
+npm run build
+Set-Location ..\..
+py -3.12 .\scripts\serve_workbench_console.py --port 6187
+```
+
+打开 `http://127.0.0.1:6187`,进入“空间规则与风险评分”。
+
+## 限制与许可
+
+- 风险等级是规则产物,不是事件事实、执法结论或自动处置指令;规则参数和分区权威性需要业务方验证。
+- 合成验收集只验证分数、空间关系、输出合同和异常处理;尚无人工标注的真实风险案例,不能报告精度、召回率或生产可用性。
+- 本 Demo 没有模型权重。`geoai-py` 为 MIT(仅上游兼容定位);GeoPandas 为 BSD 3-Clause,Rasterio 为 BSD 3-Clause,Shapely 为 BSD 3-Clause。发布或商用前仍应按锁定版本核查全部间接依赖和输入数据许可。
diff --git a/capabilities/07-risk-rule-engine/generate_validation_inputs.py b/capabilities/07-risk-rule-engine/generate_validation_inputs.py
new file mode 100644
index 0000000..1626175
--- /dev/null
+++ b/capabilities/07-risk-rule-engine/generate_validation_inputs.py
@@ -0,0 +1,70 @@
+"""Create small, georeferenced validation cases for the risk-rule Demo."""
+
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+
+import geopandas as gpd
+from shapely.geometry import Point, box
+
+
+CRS = "EPSG:3857"
+
+
+def rules_payload() -> dict:
+    return {
+        "schema_version": 1,
+        "score_cap": 100,
+        "levels": {"medium": 30, "high": 60, "critical": 85},
+        "rules": [
+            {"id": "restricted_zone", "type": "zone_intersection", "zone_type": "restricted", "score": 65, "suggestion": "暂停自动处置,复核限制区边界和对象位置。"},
+            {"id": "caution_zone", "type": "zone_intersection", "zone_type": "caution", "score": 25, "suggestion": "安排人工复核并确认现场作业条件。"},
+            {"id": "low_confidence", "type": "attribute_threshold", "field": "confidence", "operator": "lt", "value": 0.6, "score": 20, "suggestion": "补充观测证据,不以低置信度对象单独定性。"},
+        ],
+    }
+
+
+def write_case(root: Path, name: str, records: list[dict]) -> None:
+    directory = root / name
+    directory.mkdir(parents=True, exist_ok=False)
+    zones = gpd.GeoDataFrame(
+        [
+            {"zone_id": "caution-a", "zone_type": "caution", "geometry": box(0, 0, 600, 600)},
+            {"zone_id": "restricted-a", "zone_type": "restricted", "geometry": box(200, 200, 400, 400)},
+        ],
+        crs=CRS,
+    )
+    gpd.GeoDataFrame(records, crs=CRS).to_file(directory / "observations.geojson", driver="GeoJSON")
+    zones.to_file(directory / "zones.geojson", driver="GeoJSON")
+    (directory / "rules.json").write_text(json.dumps(rules_payload(), ensure_ascii=False, indent=2), encoding="utf-8")
+
+
+def write_validation_inputs(output: Path) -> None:
+    if output.exists() and any(output.iterdir()):
+        raise ValueError(f"Validation output is not empty: {output}")
+    output.mkdir(parents=True, exist_ok=True)
+    write_case(output, "normal", [
+        {"object_id": "normal-outside", "confidence": 0.92, "geometry": Point(750, 750)},
+        {"object_id": "normal-caution", "confidence": 0.88, "geometry": Point(120, 120)},
+        {"object_id": "normal-restricted", "confidence": 0.85, "geometry": Point(300, 300)},
+    ])
+    write_case(output, "difficult", [
+        {"object_id": "boundary-overlap", "confidence": 0.82, "geometry": Point(200, 300)},
+        {"object_id": "low-confidence-restricted", "confidence": 0.45, "geometry": Point(300, 300)},
+        {"object_id": "low-confidence-outside", "confidence": 0.50, "geometry": Point(780, 720)},
+    ])
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="Create risk-rule validation GeoJSON and rules.")
+    parser.add_argument("--output", type=Path, required=True)
+    args = parser.parse_args()
+    write_validation_inputs(args.output)
+    print(args.output)
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/capabilities/07-risk-rule-engine/requirements.txt b/capabilities/07-risk-rule-engine/requirements.txt
index b4d019e..d5ad114 100644
--- a/capabilities/07-risk-rule-engine/requirements.txt
+++ b/capabilities/07-risk-rule-engine/requirements.txt
@@ -1,6 +1,4 @@
 -r ../../requirements/base.txt
 geopandas>=1,<2
-fastapi>=0.115,<1
-uvicorn[standard]>=0.30,<1
-sqlalchemy>=2,<3
-
+rasterio>=1,<2
+matplotlib>=3.8,<4
diff --git a/capabilities/07-risk-rule-engine/run_risk_rule_engine.py b/capabilities/07-risk-rule-engine/run_risk_rule_engine.py
new file mode 100644
index 0000000..b5d3ac5
--- /dev/null
+++ b/capabilities/07-risk-rule-engine/run_risk_rule_engine.py
@@ -0,0 +1,240 @@
+"""Evaluate auditable spatial rules and produce raster and vector risk outputs on CPU."""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import time
+from datetime import UTC, datetime
+from importlib import metadata as importlib_metadata
+from pathlib import Path
+from typing import Any
+
+import geopandas as gpd
+import matplotlib
+import numpy as np
+import pandas as pd
+import rasterio
+from rasterio.features import rasterize
+from rasterio.transform import from_origin
+
+matplotlib.use("Agg")
+from matplotlib import pyplot as plt  # noqa: E402
+
+
+SUPPORTED_RULE_TYPES = {"zone_intersection", "attribute_threshold"}
+SUPPORTED_OPERATORS = {"lt", "lte", "gt", "gte"}
+DEFAULT_LEVELS = {"medium": 30.0, "high": 60.0, "critical": 85.0}
+
+
+def file_sha256(path: Path) -> str:
+    digest = hashlib.sha256()
+    with path.open("rb") as stream:
+        for chunk in iter(lambda: stream.read(1024 * 1024), b""):
+            digest.update(chunk)
+    return digest.hexdigest()
+
+
+def read_rules(path: Path) -> dict[str, Any]:
+    try:
+        payload = json.loads(path.read_text(encoding="utf-8"))
+    except (OSError, json.JSONDecodeError) as exc:
+        raise ValueError(f"Rules file is not valid UTF-8 JSON: {path.name}") from exc
+    if not isinstance(payload, dict) or not isinstance(payload.get("rules"), list) or not payload["rules"]:
+        raise ValueError("Rules JSON requires a non-empty rules array.")
+    score_cap = payload.get("score_cap", 100)
+    if isinstance(score_cap, bool) or not isinstance(score_cap, (int, float)) or not 1 <= float(score_cap) <= 1000:
+        raise ValueError("score_cap must be a number from 1 to 1000.")
+    levels = payload.get("levels", DEFAULT_LEVELS)
+    if not isinstance(levels, dict) or any(key not in levels for key in DEFAULT_LEVELS):
+        raise ValueError("levels must define medium, high and critical thresholds.")
+    parsed_levels = {key: float(levels[key]) for key in DEFAULT_LEVELS}
+    if not 0 <= parsed_levels["medium"] <= parsed_levels["high"] <= parsed_levels["critical"] <= float(score_cap):
+        raise ValueError("Risk level thresholds must be ascending and not exceed score_cap.")
+    seen: set[str] = set()
+    for rule in payload["rules"]:
+        if not isinstance(rule, dict) or not isinstance(rule.get("id"), str) or not rule["id"] or rule["id"] in seen:
+            raise ValueError("Every rule needs a unique non-empty id.")
+        seen.add(rule["id"])
+        if rule.get("type") not in SUPPORTED_RULE_TYPES:
+            raise ValueError(f"Rule {rule['id']} has an unsupported type.")
+        if isinstance(rule.get("score"), bool) or not isinstance(rule.get("score"), (int, float)) or float(rule["score"]) < 0:
+            raise ValueError(f"Rule {rule['id']} needs a non-negative numeric score.")
+        if rule["type"] == "zone_intersection" and not isinstance(rule.get("zone_type"), str):
+            raise ValueError(f"Zone rule {rule['id']} needs zone_type.")
+        if rule["type"] == "attribute_threshold":
+            if not isinstance(rule.get("field"), str) or rule.get("operator") not in SUPPORTED_OPERATORS:
+                raise ValueError(f"Attribute rule {rule['id']} needs field and a supported operator.")
+            if isinstance(rule.get("value"), bool) or not isinstance(rule.get("value"), (int, float)):
+                raise ValueError(f"Attribute rule {rule['id']} needs a numeric value.")
+    return {**payload, "score_cap": float(score_cap), "levels": parsed_levels}
+
+
+def read_inputs(observations_path: Path, zones_path: Path) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame, bool]:
+    observations = gpd.read_file(observations_path)
+    zones = gpd.read_file(zones_path)
+    if observations.empty or zones.empty:
+        raise ValueError("Observation and zone GeoJSON inputs must contain features.")
+    if observations.crs is None or zones.crs is None:
+        raise ValueError("Both observation and zone GeoJSON inputs require a valid CRS.")
+    if observations.crs.is_geographic:
+        raise ValueError("Observation CRS must be projected for metre-based raster resolution and spatial scoring.")
+    for column in ("object_id", "confidence"):
+        if column not in observations.columns:
+            raise ValueError(f"Observation GeoJSON requires '{column}' field.")
+    if observations["object_id"].isna().any() or observations["object_id"].astype(str).duplicated().any():
+        raise ValueError("object_id values must be present and unique.")
+    confidence = pd.to_numeric(observations["confidence"], errors="coerce")
+    if confidence.isna().any() or ((confidence < 0) | (confidence > 1)).any():
+        raise ValueError("confidence values must be numeric between 0 and 1.")
+    if "zone_type" not in zones.columns:
+        raise ValueError("Zone GeoJSON requires 'zone_type' field.")
+    reprojected = zones.crs != observations.crs
+    if reprojected:
+        zones = zones.to_crs(observations.crs)
+    if observations.geometry.is_empty.any() or zones.geometry.is_empty.any():
+        raise ValueError("Input geometries must not be empty.")
+    observations = observations.copy()
+    observations["confidence"] = confidence.astype(float)
+    return observations, zones, reprojected
+
+
+def compare(value: float, operator: str, expected: float) -> bool:
+    return {"lt": value < expected, "lte": value <= expected, "gt": value > expected, "gte": value >= expected}[operator]
+
+
+def risk_level(score: float, levels: dict[str, float]) -> str:
+    if score >= levels["critical"]:
+        return "critical"
+    if score >= levels["high"]:
+        return "high"
+    if score >= levels["medium"]:
+        return "medium"
+    return "low"
+
+
+def evaluate_rules(observations: gpd.GeoDataFrame, zones: gpd.GeoDataFrame, rules: dict[str, Any]) -> tuple[gpd.GeoDataFrame, list[dict[str, Any]]]:
+    results: list[dict[str, Any]] = []
+    csv_rows: list[dict[str, Any]] = []
+    for _, object_row in observations.iterrows():
+        hits: list[dict[str, Any]] = []
+        for rule in rules["rules"]:
+            if rule["type"] == "zone_intersection":
+                matching = zones[zones["zone_type"].astype(str) == rule["zone_type"]]
+                hit_zone_ids = [str(item.get("zone_id", item.name)) for _, item in matching[matching.geometry.intersects(object_row.geometry)].iterrows()]
+                evidence = {"zone_type": rule["zone_type"], "zone_ids": hit_zone_ids}
+                matched = bool(hit_zone_ids)
+            else:
+                value = float(object_row[rule["field"]]) if rule["field"] in object_row else None
+                evidence = {"field": rule["field"], "operator": rule["operator"], "observed": value, "threshold": rule["value"]}
+                matched = value is not None and compare(value, rule["operator"], float(rule["value"]))
+            if matched:
+                hits.append({"rule_id": rule["id"], "score": float(rule["score"]), "suggestion": str(rule.get("suggestion", "人工复核。")), "evidence": evidence})
+        raw_score = sum(hit["score"] for hit in hits)
+        score = min(raw_score, rules["score_cap"])
+        suggestions = list(dict.fromkeys(hit["suggestion"] for hit in hits))
+        properties = {
+            "object_id": str(object_row["object_id"]), "confidence": float(object_row["confidence"]),
+            "raw_score": round(raw_score, 3), "risk_score": round(score, 3), "risk_level": risk_level(score, rules["levels"]),
+            "score_capped": raw_score > rules["score_cap"], "hit_rule_ids": [hit["rule_id"] for hit in hits],
+            "rule_contributions": hits, "suggestions": suggestions,
+        }
+        results.append({**properties, "geometry": object_row.geometry})
+        csv_rows.append({**properties, "hit_rule_ids": " | ".join(properties["hit_rule_ids"]), "suggestions": " | ".join(suggestions), "rule_contributions": json.dumps(hits, ensure_ascii=False)})
+    return gpd.GeoDataFrame(results, geometry="geometry", crs=observations.crs), csv_rows
+
+
+def create_raster(scored: gpd.GeoDataFrame, zones: gpd.GeoDataFrame, rules: dict[str, Any], output_path: Path, resolution: float) -> tuple[int, int, list[float]]:
+    combined = pd.concat([zones.geometry, scored.geometry], ignore_index=True)
+    min_x, min_y, max_x, max_y = combined.total_bounds
+    padding = max(resolution * 2, 10.0)
+    min_x -= padding; min_y -= padding; max_x += padding; max_y += padding
+    width = max(1, int(np.ceil((max_x - min_x) / resolution)))
+    height = max(1, int(np.ceil((max_y - min_y) / resolution)))
+    if width * height > 16_000_000:
+        raise ValueError("Output raster would exceed 16 million cells; increase raster resolution.")
+    transform = from_origin(min_x, max_y, resolution, resolution)
+    scores_by_zone = {str(rule["zone_type"]): float(rule["score"]) for rule in rules["rules"] if rule["type"] == "zone_intersection"}
+    raster = np.zeros((height, width), dtype=np.float32)
+    for _, zone in zones.iterrows():
+        score = scores_by_zone.get(str(zone["zone_type"]), 0.0)
+        if score:
+            raster += rasterize([(zone.geometry, score)], out_shape=raster.shape, transform=transform, fill=0, all_touched=True, dtype="float32")
+    for _, observation in scored.iterrows():
+        footprint = observation.geometry.buffer(max(resolution * 0.75, 1.0))
+        object_raster = rasterize([(footprint, float(observation["risk_score"]))], out_shape=raster.shape, transform=transform, fill=0, all_touched=True, dtype="float32")
+        raster = np.maximum(raster, object_raster)
+    raster = np.minimum(raster, rules["score_cap"])
+    with rasterio.open(output_path, "w", driver="GTiff", height=height, width=width, count=1, dtype="float32", crs=scored.crs, transform=transform, nodata=0.0, compress="deflate") as dst:
+        dst.write(raster, 1)
+    return width, height, [round(min_x, 3), round(min_y, 3), round(max_x, 3), round(max_y, 3)]
+
+
+def create_preview(raster_path: Path, preview_path: Path, score_cap: float) -> None:
+    with rasterio.open(raster_path) as source:
+        values = source.read(1)
+    figure, axis = plt.subplots(figsize=(8, 6), dpi=150)
+    image = axis.imshow(values, cmap="YlOrRd", vmin=0, vmax=score_cap)
+    axis.set_axis_off()
+    figure.colorbar(image, ax=axis, fraction=0.045, pad=0.03, label="Risk score")
+    figure.tight_layout(pad=0)
+    figure.savefig(preview_path, bbox_inches="tight", pad_inches=0)
+    plt.close(figure)
+
+
+def run(observations_path: Path, zones_path: Path, rules_path: Path, output: Path, resolution: float) -> dict[str, Any]:
+    if resolution <= 0:
+        raise ValueError("Raster resolution must be greater than zero.")
+    if output.exists() and any(output.iterdir()):
+        raise ValueError(f"Output directory is not empty: {output}")
+    output.mkdir(parents=True, exist_ok=True)
+    started = time.perf_counter()
+    rules = read_rules(rules_path)
+    observations, zones, reprojected = read_inputs(observations_path, zones_path)
+    scored, csv_rows = evaluate_rules(observations, zones, rules)
+    raster_path, preview_path = output / "risk_score.tif", output / "risk_preview.png"
+    vector_path, csv_path, summary_path = output / "risk_scores.geojson", output / "risk_scores.csv", output / "risk_summary.json"
+    width, height, bounds = create_raster(scored, zones, rules, raster_path, resolution)
+    create_preview(raster_path, preview_path, rules["score_cap"])
+    scored.to_file(vector_path, driver="GeoJSON")
+    pd.DataFrame(csv_rows).to_csv(csv_path, index=False, encoding="utf-8-sig")
+    counts = scored["risk_level"].value_counts().to_dict()
+    summary = {"object_count": int(len(scored)), "risk_level_counts": {level: int(counts.get(level, 0)) for level in ("low", "medium", "high", "critical")}, "maximum_score": float(scored["risk_score"].max()), "mean_score": round(float(scored["risk_score"].mean()), 3), "hit_rule_counts": {rule["id"]: sum(rule["id"] in value for value in scored["hit_rule_ids"]) for rule in rules["rules"]}}
+    summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
+    metadata = {
+        "schema_version": 1, "capability": "07-risk-rule-engine", "classification": "C", "created_at": datetime.now(UTC).isoformat(),
+        "method": "deterministic spatial zone intersection and attribute threshold rules", "model": "none (rule configuration only)", "device": "CPU",
+        "geoai_role": "Not invoked. geoai-py outputs may be used as upstream observation or zone inputs.",
+        "versions": {"geopandas": importlib_metadata.version("geopandas"), "rasterio": importlib_metadata.version("rasterio"), "shapely": importlib_metadata.version("shapely")},
+        "input_files": {"observations": observations_path.name, "zones": zones_path.name, "rules": rules_path.name},
+        "input_sha256": {"observations": file_sha256(observations_path), "zones": file_sha256(zones_path), "rules": file_sha256(rules_path)},
+        "crs": observations.crs.to_string(), "zones_reprojected_to_observation_crs": reprojected,
+        "parameters": {"score_cap": rules["score_cap"], "levels": rules["levels"], "raster_resolution": resolution, "rule_count": len(rules["rules"])},
+        "raster": {"width": width, "height": height, "bounds": bounds, "coordinate_basis": "projected_crs"}, "summary": summary,
+        "artifacts": {"risk_raster": raster_path.name, "risk_preview": preview_path.name, "risk_vector": vector_path.name, "risk_scores_csv": csv_path.name, "summary": summary_path.name},
+        "elapsed_seconds": round(time.perf_counter() - started, 3),
+        "limitations": ["Scores and levels are rule outputs, not verified events, legal conclusions, or automatic action instructions.", "The synthetic validation cases verify the spatial and scoring contract only; no real labelled risk accuracy is available.", "Zone authority, temporal validity, observation positioning error, confidence semantics and thresholds require business validation before operational use."],
+    }
+    (output / "run_metadata.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
+    return metadata
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="Run auditable spatial rules and risk scoring on CPU.")
+    parser.add_argument("--observations", type=Path, required=True)
+    parser.add_argument("--zones", type=Path, required=True)
+    parser.add_argument("--rules", type=Path, required=True)
+    parser.add_argument("--output", type=Path, required=True)
+    parser.add_argument("--raster-resolution", type=float, default=10.0)
+    args = parser.parse_args()
+    try:
+        metadata = run(args.observations, args.zones, args.rules, args.output, args.raster_resolution)
+    except ValueError as exc:
+        raise SystemExit(str(exc)) from exc
+    print(json.dumps(metadata, ensure_ascii=False, indent=2))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/capabilities/07-risk-rule-engine/tests/test_risk_rule_engine.py b/capabilities/07-risk-rule-engine/tests/test_risk_rule_engine.py
new file mode 100644
index 0000000..5b8f8ad
--- /dev/null
+++ b/capabilities/07-risk-rule-engine/tests/test_risk_rule_engine.py
@@ -0,0 +1,57 @@
+from __future__ import annotations
+
+import json
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+from generate_validation_inputs import write_validation_inputs  # noqa: E402
+from run_risk_rule_engine import run  # noqa: E402
+
+
+class RiskRuleEngineTests(unittest.TestCase):
+    def run_case(self, root: Path, name: str) -> dict:
+        source = root / "inputs" / name
+        return run(source / "observations.geojson", source / "zones.geojson", source / "rules.json", root / f"output-{name}", 10)
+
+    def test_normal_case_has_expected_scores_and_artifacts(self):
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            write_validation_inputs(root / "inputs")
+            metadata = self.run_case(root, "normal")
+            payload = json.loads((root / "output-normal" / "risk_scores.geojson").read_text(encoding="utf-8"))
+            scores = {feature["properties"]["object_id"]: feature["properties"]["risk_score"] for feature in payload["features"]}
+            self.assertEqual(scores, {"normal-outside": 0.0, "normal-caution": 25.0, "normal-restricted": 90.0})
+            self.assertEqual(metadata["summary"]["risk_level_counts"], {"low": 2, "medium": 0, "high": 0, "critical": 1})
+            for name in ("risk_score.tif", "risk_preview.png", "risk_scores.geojson", "risk_scores.csv", "risk_summary.json", "run_metadata.json"):
+                self.assertTrue((root / "output-normal" / name).is_file())
+
+    def test_difficult_case_captures_boundary_overlap_and_score_cap(self):
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            write_validation_inputs(root / "inputs")
+            self.run_case(root, "difficult")
+            payload = json.loads((root / "output-difficult" / "risk_scores.geojson").read_text(encoding="utf-8"))
+            scores = {feature["properties"]["object_id"]: feature["properties"] for feature in payload["features"]}
+            self.assertEqual(scores["boundary-overlap"]["risk_score"], 90.0)
+            self.assertEqual(set(scores["boundary-overlap"]["hit_rule_ids"]), {"restricted_zone", "caution_zone"})
+            self.assertEqual(scores["low-confidence-restricted"]["risk_score"], 100.0)
+            self.assertTrue(scores["low-confidence-restricted"]["score_capped"])
+            self.assertEqual(scores["low-confidence-outside"]["risk_score"], 20.0)
+
+    def test_rejects_missing_crs(self):
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            write_validation_inputs(root / "inputs")
+            observations = root / "inputs" / "normal" / "observations.geojson"
+            payload = json.loads(observations.read_text(encoding="utf-8"))
+            payload.pop("crs", None)
+            observations.write_text(json.dumps(payload), encoding="utf-8")
+            with self.assertRaisesRegex(ValueError, "CRS"):
+                self.run_case(root, "normal")
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/scripts/serve_workbench_console.py b/scripts/serve_workbench_console.py
index d32a5b9..94ba5cb 100644
--- a/scripts/serve_workbench_console.py
+++ b/scripts/serve_workbench_console.py
@@ -30,6 +30,10 @@
 MAX_IMAGES_PER_RUN = 12
 MAX_SEGMENTATION_IMAGES_PER_RUN = 6
 MAX_MEASUREMENT_RASTERS_PER_RUN = 4
+MAX_POINTCLOUDS_PER_RUN = 2
+MAX_PHOTO_RECONSTRUCTION_IMAGES_PER_RUN = 30
+PHOTO_RECONSTRUCTION_TIMEOUT = 7200
+RISK_RULE_REQUIRED_FILES = {"observations", "zones", "rules"}
 MAX_ANOMALY_IMAGES_PER_ROLE = 6
 CHANGE_THRESHOLD_DEFAULT = 0.5
 CHANGE_THRESHOLD_MIN = 0.01
@@ -53,6 +57,7 @@
     "shared/data/raw/01-object-detection",
     "shared/data/raw/02-semantic-mapping",
     "shared/data/raw/09-anomaly-detection",
+    "shared/data/raw/05-3d-pointcloud",
 )
 SAFE_FILE_NAME = re.compile(r"[^\w.-]+", re.UNICODE)
 SAFE_UPLOAD_ID = re.compile(r"^[0-9a-f]{32}$")
@@ -63,6 +68,14 @@
 SCAN_JOBS_LOCK = threading.Lock()
 ANOMALY_JOB_LOCK = threading.Lock()
 ANOMALY_JOBS: dict[str, dict[str, Any]] = {}
+PHOTO_RECONSTRUCTION_JOBS: dict[str, dict[str, Any]] = {}
+PHOTO_RECONSTRUCTION_JOBS_LOCK = threading.Lock()
+POINTCLOUD_TRAINING_JOBS: dict[str, dict[str, Any]] = {}
+POINTCLOUD_TRAINING_JOBS_LOCK = threading.Lock()
+POINTCLOUD_INFERENCE_JOBS: dict[str, dict[str, Any]] = {}
+POINTCLOUD_INFERENCE_JOBS_LOCK = threading.Lock()
+MAX_ANNOTATION_LABELS = 400_000
+POINTCLOUD_CLASS_CODES = {1, 2, 5, 6, 15, 16}
 
 
 class ApiError(ValueError):
@@ -197,9 +210,12 @@
             continue
         if not before_path.is_file() or not after_path.is_file():
             continue
+        registered_before_name = str(artifacts.get("before_processed_preview") or "")
+        registered_after_name = str(artifacts.get("after_registered_preview") or "")
+        registered_before_path = artifact / registered_before_name if registered_before_name else None
+        registered_after_path = artifact / registered_after_name if registered_after_name else None
         run_id = artifact.name
-        records.append(
-            {
+        record = {
                 "id": run_id,
                 "label": run_id,
                 "note": "ChangeStar CPU 变化栅格与 GeoAI 像素坐标图斑;结果需人工复核。",
@@ -208,7 +224,15 @@
                 "afterImage": relative_path(root, after_path),
                 "createdAt": str(metadata.get("created_at") or ""),
             }
-        )
+        if registered_before_path and registered_after_path and registered_before_path.is_file() and registered_after_path.is_file():
+            record["registeredBeforeImage"] = relative_path(root, registered_before_path)
+            record["registeredAfterImage"] = relative_path(root, registered_after_path)
+            # Existing console consumers use afterImage as the vector-overlay
+            # base. Point it at the registered grid so polygons and highlights
+            # share the same pixel coordinates as the model output.
+            record["rawAfterImage"] = record["afterImage"]
+            record["afterImage"] = record["registeredAfterImage"]
+        records.append(record)
     return sorted(records, key=lambda item: (item["createdAt"], item["id"]), reverse=True)
 
 
@@ -319,6 +343,209 @@
     return sorted(records, key=lambda item: (item["createdAt"], item["id"]), reverse=True)
 
 
+def pointcloud_runs(root: Path) -> list[dict[str, Any]]:
+    output_root = root / "shared" / "outputs" / "05-3d-pointcloud"
+    records: list[dict[str, Any]] = []
+    for metadata_path in output_root.rglob("run_metadata.json"):
+        artifact = metadata_path.parent
+        metadata = load_json(metadata_path)
+        dense_photo_reconstruction = metadata.get("dense_photo_reconstruction")
+        if metadata.get("capability") == "05-3d-pointcloud" and isinstance(dense_photo_reconstruction, dict):
+            textured_model = dense_photo_reconstruction.get("textured_model_file")
+            dense_point_cloud = dense_photo_reconstruction.get("dense_point_cloud_file")
+            mesh = dense_photo_reconstruction.get("mesh_file")
+            if all(isinstance(item, str) and (artifact / item).is_file() for item in (textured_model, dense_point_cloud, mesh)):
+                run_id = artifact.name
+                records.append(
+                    {
+                        "id": run_id,
+                        "label": str(metadata.get("display_name") or run_id),
+                        "note": "CPU 稠密 MVS:显示经过深度融合、网格化和纹理化的局部模型;不是测绘级坐标、DSM、正射图或语义识别结论。",
+                        "artifactRoot": relative_path(root, artifact),
+                        "createdAt": str(metadata.get("created_at") or ""),
+                    }
+                )
+                continue
+        photo_reconstruction = metadata.get("photo_reconstruction")
+        if metadata.get("capability") == "05-3d-pointcloud" and isinstance(photo_reconstruction, dict):
+            preview = photo_reconstruction.get("preview_file")
+            point_cloud = photo_reconstruction.get("point_cloud_file")
+            if isinstance(preview, str) and isinstance(point_cloud, str) and (artifact / preview).is_file() and (artifact / point_cloud).is_file():
+                run_id = artifact.name
+                records.append(
+                    {
+                        "id": run_id,
+                        "label": str(metadata.get("display_name") or run_id),
+                        "note": "CPU 稀疏 SfM:显示可复核点云、相机位姿与误差;不是稠密重建、DSM、语义识别或测绘精度结论。",
+                        "artifactRoot": relative_path(root, artifact),
+                        "createdAt": str(metadata.get("created_at") or ""),
+                    }
+                )
+                continue
+        point_clouds = metadata.get("point_clouds")
+        if metadata.get("capability") != "05-3d-pointcloud" or not isinstance(point_clouds, list) or not point_clouds:
+            continue
+        if any(not isinstance(item, dict) or not (artifact / str(item.get("preview_file") or "")).is_file() or not (artifact / str(item.get("vector_file") or "")).is_file() for item in point_clouds):
+            continue
+        run_id = artifact.name
+        records.append(
+            {
+                "id": run_id,
+                "label": str(metadata.get("display_name") or run_id),
+                "note": "CPU 语义规则基线:地面、植被、构筑物以及电线/杆塔候选,需要人工复核;不提供测绘精度或资产台账结论。" if any(isinstance(item, dict) and item.get("semantic_summary_file") for item in point_clouds) else "CPU 几何基线:地面/高出地物分离、DSM、近似网格和 GeoAI 足迹;不提供语义类别或测绘精度结论。",
+                "artifactRoot": relative_path(root, artifact),
+                "createdAt": str(metadata.get("created_at") or ""),
+            }
+        )
+    return sorted(records, key=lambda item: (item["createdAt"], item["id"]), reverse=True)
+
+
+def pointcloud_annotation_sources(root: Path) -> list[dict[str, Any]]:
+    """Expose only generated, fixed preview PLYs suitable for manual labels."""
+    sources: list[dict[str, Any]] = []
+    for case in pointcloud_runs(root):
+        artifact = root / str(case["artifactRoot"])
+        metadata = load_json(artifact / "run_metadata.json")
+        for cloud in metadata.get("point_clouds", []):
+            if not isinstance(cloud, dict):
+                continue
+            name = cloud.get("semantic_annotation_source_point_cloud")
+            if not isinstance(name, str) or Path(name).name != name:
+                continue
+            path = artifact / name
+            if not path.is_file() or path.suffix.lower() != ".ply":
+                continue
+            sources.append({
+                "id": f"{case['id']}:{name}", "runId": case["id"], "label": f"{case['label']} / {cloud.get('file', name)}",
+                "artifactRoot": case["artifactRoot"], "file": name, "url": f"/{case['artifactRoot']}/{name}",
+                "sha256": file_sha256(path), "pointCount": int(cloud.get("semantic_preview_points") or 0),
+                "sourceKind": str(cloud.get("semantic_annotation_source_kind") or "generated point-cloud preview"),
+            })
+    return sources
+
+
+def pointcloud_annotations(root: Path) -> list[dict[str, Any]]:
+    output = root / "shared" / "outputs" / "05-3d-pointcloud" / "annotations"
+    records: list[dict[str, Any]] = []
+    for path in output.glob("*/annotation.json"):
+        data = load_json(path)
+        if data.get("schema_version") != 1 or not isinstance(data.get("id"), str):
+            continue
+        records.append({"id": data["id"], "sourceId": data.get("source_id"), "createdAt": data.get("created_at"), "labelCount": len(data.get("labels", [])), "classCounts": data.get("class_counts", {}), "path": relative_path(root, path)})
+    return sorted(records, key=lambda item: (str(item["createdAt"]), str(item["id"])), reverse=True)
+
+
+def pointcloud_training_job(job_id: str) -> dict[str, Any] | None:
+    with POINTCLOUD_TRAINING_JOBS_LOCK:
+        value = POINTCLOUD_TRAINING_JOBS.get(job_id)
+        return dict(value) if value else None
+
+
+def execute_pointcloud_training_job(root: Path, job_id: str, annotation: Path, output: Path, device: str) -> None:
+    with POINTCLOUD_TRAINING_JOBS_LOCK:
+        POINTCLOUD_TRAINING_JOBS[job_id].update({"status": "running", "stage": "training", "startedAt": datetime.now(UTC).isoformat()})
+    python = root / ".venvs" / "05-3d-pointcloud" / "Scripts" / "python.exe"
+    command = [str(python), str(root / "capabilities" / "05-3d-pointcloud" / "train_pointcloud_semantic_model.py"), "--annotation", str(annotation), "--output", str(output), "--device", device]
+    try:
+        with RUN_LOCK:
+            completed = subprocess.run(command, cwd=root, capture_output=True, text=True, timeout=14_400, check=False)
+        if completed.returncode:
+            message = (completed.stderr or completed.stdout or "Unknown training error.").strip().splitlines()[-1]
+            raise ApiError(message[:600])
+        metrics = output / "metrics.json"
+        model = output / "model.pt"
+        preview = output / "predicted-semantic-preview.ply"
+        if not all(path.is_file() for path in (metrics, model, preview)):
+            raise ApiError("Training finished without model, metrics, and predicted preview artifacts.")
+        with POINTCLOUD_TRAINING_JOBS_LOCK:
+            POINTCLOUD_TRAINING_JOBS[job_id].update({"status": "complete", "stage": "complete", "completedAt": datetime.now(UTC).isoformat(), "artifactRoot": relative_path(root, output), "metrics": relative_path(root, metrics), "model": relative_path(root, model), "preview": relative_path(root, preview)})
+    except Exception as exc:
+        with POINTCLOUD_TRAINING_JOBS_LOCK:
+            POINTCLOUD_TRAINING_JOBS[job_id].update({"status": "failed", "stage": "failed", "completedAt": datetime.now(UTC).isoformat(), "error": str(exc)[:700]})
+
+
+def pointcloud_semantic_models(root: Path) -> list[dict[str, Any]]:
+    """Expose only complete locally trained models, never arbitrary model paths."""
+    output_root = root / "shared" / "outputs" / "05-3d-pointcloud" / "training-runs"
+    records: list[dict[str, Any]] = []
+    for model_path in output_root.glob("*/model.pt"):
+        metrics_path = model_path.with_name("metrics.json")
+        metrics = load_json(metrics_path)
+        classes = metrics.get("classes")
+        if metrics.get("capability") != "05-3d-pointcloud" or metrics.get("classification") != "B" or not isinstance(classes, dict):
+            continue
+        class_codes = sorted(str(code) for code in classes if str(code).isdigit())
+        if len(class_codes) < 2:
+            continue
+        test = metrics.get("test") if isinstance(metrics.get("test"), dict) else {}
+        report = test.get("report") if isinstance(test.get("report"), dict) else {}
+        summary: dict[str, float] = {}
+        for code in class_codes:
+            definition = classes.get(code)
+            key = definition.get("key") if isinstance(definition, dict) else None
+            score = report.get(key) if isinstance(key, str) else None
+            if isinstance(score, dict) and isinstance(score.get("f1-score"), (int, float)):
+                summary[key] = round(float(score["f1-score"]), 3)
+        records.append({"id": model_path.parent.name, "label": model_path.parent.name, "artifactRoot": relative_path(root, model_path.parent), "model": relative_path(root, model_path), "metrics": relative_path(root, metrics_path), "createdAt": str(metrics.get("created_at") or ""), "classes": classes, "testF1": summary})
+    return sorted(records, key=lambda item: (item["createdAt"], item["id"]), reverse=True)
+
+
+def pointcloud_inference_job(job_id: str) -> dict[str, Any] | None:
+    with POINTCLOUD_INFERENCE_JOBS_LOCK:
+        value = POINTCLOUD_INFERENCE_JOBS.get(job_id)
+        return dict(value) if value else None
+
+
+def execute_pointcloud_inference_job(root: Path, job_id: str, model: Path, source: Path, output: Path) -> None:
+    with POINTCLOUD_INFERENCE_JOBS_LOCK:
+        POINTCLOUD_INFERENCE_JOBS[job_id].update({"status": "running", "stage": "inference", "startedAt": datetime.now(UTC).isoformat()})
+    python = root / ".venvs" / "05-3d-pointcloud" / "Scripts" / "python.exe"
+    command = [str(python), str(root / "capabilities" / "05-3d-pointcloud" / "apply_pointcloud_semantic_model.py"), "--model", str(model), "--input", str(source), "--output", str(output), "--device", "cpu"]
+    try:
+        with RUN_LOCK:
+            completed = subprocess.run(command, cwd=root, capture_output=True, text=True, timeout=14_400, check=False)
+        if completed.returncode:
+            message = (completed.stderr or completed.stdout or "Unknown model inference error.").strip().splitlines()[-1]
+            raise ApiError(message[:600])
+        metadata = output / "run_metadata.json"
+        preview = output / "predicted-semantic-preview.ply"
+        classified_las = output / "predicted-semantic-classified.las"
+        counts = output / "class-counts.csv"
+        summary = output / "prediction-summary.json"
+        if not all(path.is_file() for path in (metadata, preview, classified_las, counts, summary)):
+            raise ApiError("Model inference finished without all expected prediction artifacts.")
+        with POINTCLOUD_INFERENCE_JOBS_LOCK:
+            POINTCLOUD_INFERENCE_JOBS[job_id].update({"status": "complete", "stage": "complete", "completedAt": datetime.now(UTC).isoformat(), "artifactRoot": relative_path(root, output), "metadata": relative_path(root, metadata), "preview": relative_path(root, preview), "classifiedLas": relative_path(root, classified_las), "classCounts": relative_path(root, counts), "summary": relative_path(root, summary)})
+    except Exception as exc:
+        with POINTCLOUD_INFERENCE_JOBS_LOCK:
+            POINTCLOUD_INFERENCE_JOBS[job_id].update({"status": "failed", "stage": "failed", "completedAt": datetime.now(UTC).isoformat(), "error": str(exc)[:700]})
+
+
+def risk_rule_runs(root: Path) -> list[dict[str, Any]]:
+    output_root = root / "shared" / "outputs" / "07-risk-rule-engine"
+    records: list[dict[str, Any]] = []
+    for metadata_path in output_root.rglob("run_metadata.json"):
+        artifact = metadata_path.parent
+        metadata = load_json(metadata_path)
+        artifacts = metadata.get("artifacts")
+        if metadata.get("capability") != "07-risk-rule-engine" or not isinstance(artifacts, dict):
+            continue
+        required = ("risk_raster", "risk_preview", "risk_vector", "risk_scores_csv", "summary")
+        if any(not isinstance(artifacts.get(key), str) or not (artifact / artifacts[key]).is_file() for key in required):
+            continue
+        run_id = artifact.name
+        records.append(
+            {
+                "id": run_id,
+                "label": str(metadata.get("display_name") or run_id),
+                "note": "可审计空间规则评分,仅供人工复核,不构成事件或处置结论。",
+                "artifactRoot": relative_path(root, artifact),
+                "createdAt": str(metadata.get("created_at") or ""),
+            }
+        )
+    return sorted(records, key=lambda item: (item["createdAt"], item["id"]), reverse=True)
+
+
 def anomaly_runs(root: Path) -> list[dict[str, Any]]:
     output_root = root / "shared" / "outputs" / "09-anomaly-detection"
     allowed_raw = (root / "shared" / "data" / "raw" / "09-anomaly-detection").resolve()
@@ -363,6 +590,82 @@
     with ANOMALY_JOB_LOCK:
         value = ANOMALY_JOBS.get(job_id)
         return dict(value) if value else None
+
+
+def photo_reconstruction_job(job_id: str) -> dict[str, Any] | None:
+    with PHOTO_RECONSTRUCTION_JOBS_LOCK:
+        value = PHOTO_RECONSTRUCTION_JOBS.get(job_id)
+        return dict(value) if value else None
+
+
+def run_background_command(command: list[str], root: Path, timeout: int) -> None:
+    completed = subprocess.run(command, cwd=root, capture_output=True, text=True, timeout=timeout, check=False)
+    if completed.returncode:
+        message = (completed.stderr or completed.stdout or "Unknown script error.").strip().splitlines()[-1]
+        raise ApiError(f"Processing failed: {message[:600]}")
+
+
+def execute_photo_reconstruction_job(
+    root: Path,
+    job_id: str,
+    run_id: str,
+    raw_root: Path,
+    processed_root: Path,
+    sparse_output: Path,
+    output: Path,
+    source_sha256: dict[str, str],
+    source_bytes: dict[str, int],
+    use_position_priors: bool,
+) -> None:
+    python = root / ".venvs" / "05-3d-pointcloud" / "Scripts" / "python.exe"
+    sparse_command = [
+        str(python), str(root / "capabilities" / "05-3d-pointcloud" / "run_photo_reconstruction.py"),
+        "--input", str(processed_root), "--output", str(sparse_output),
+        "--max-image-size", "2000", "--max-features", "18000",
+        "--camera-model", "OPENCV",
+    ]
+    if use_position_priors:
+        sparse_command.extend(["--matching-mode", "spatial", "--matching-neighbors", "4", "--use-position-priors", "--prior-position-loss-scale-m", "0.05"])
+    else:
+        sparse_command.extend(["--matching-mode", "exhaustive"])
+    dense_command = [
+        str(python), str(root / "capabilities" / "05-3d-pointcloud" / "run_cpu_dense_reconstruction.py"),
+        "--input", str(processed_root), "--sparse-model", str(sparse_output / "sparse_model" / "0"),
+        "--output", str(output),
+        "--openmvs-bin", str(root / "shared" / "tools" / "openmvs-2.4.0" / "vc17" / "x64" / "Release"),
+        "--threads", "12", "--max-resolution", "2400", "--dense-resolution-level", "0",
+        "--dense-number-views", "8", "--dense-number-views-fuse", "2", "--target-faces", "800000",
+    ]
+    try:
+        with PHOTO_RECONSTRUCTION_JOBS_LOCK:
+            PHOTO_RECONSTRUCTION_JOBS[job_id].update({"status": "running", "stage": "sparse_sfm", "startedAt": datetime.now(UTC).isoformat()})
+        with RUN_LOCK:
+            run_background_command(sparse_command, root, 1800)
+            sparse_metadata = load_json(sparse_output / "run_metadata.json")
+            if not (sparse_output / "sparse_model" / "0").is_dir():
+                raise ApiError("Sparse photo reconstruction finished without the expected COLMAP model.")
+            with PHOTO_RECONSTRUCTION_JOBS_LOCK:
+                PHOTO_RECONSTRUCTION_JOBS[job_id].update({"stage": "dense_mvs"})
+            run_background_command(dense_command, root, PHOTO_RECONSTRUCTION_TIMEOUT)
+        metadata_path = output / "run_metadata.json"
+        if not metadata_path.is_file():
+            raise ApiError("CPU dense reconstruction finished without the expected result metadata.")
+        metadata = load_json(metadata_path)
+        metadata["photo_reconstruction"] = sparse_metadata.get("photo_reconstruction", {})
+        metadata["input_dir"] = relative_path(root, processed_root)
+        metadata["raw_input_dir"] = relative_path(root, raw_root)
+        metadata["source_sha256"] = source_sha256
+        metadata["source_bytes"] = source_bytes
+        metadata["console_photo_reconstruction"] = {"use_position_priors": use_position_priors, "matching_mode": "spatial" if use_position_priors else "exhaustive"}
+        metadata["display_name"] = f"用户照片 CPU 稠密重建({len(source_sha256)} 图)"
+        metadata["case_note"] = "用户上传的同架次 JPG/JPEG 照片经 CPU SfM/MVS 重建;需要人工检查几何与纹理质量,不是测绘级成果。"
+        metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
+        definition = next(item for item in pointcloud_runs(root) if item["id"] == run_id)
+        with PHOTO_RECONSTRUCTION_JOBS_LOCK:
+            PHOTO_RECONSTRUCTION_JOBS[job_id].update({"status": "complete", "stage": "complete", "run": definition, "finishedAt": datetime.now(UTC).isoformat()})
+    except Exception as exc:  # pragma: no cover - background boundary
+        with PHOTO_RECONSTRUCTION_JOBS_LOCK:
+            PHOTO_RECONSTRUCTION_JOBS[job_id].update({"status": "failed", "stage": "failed", "error": str(exc), "finishedAt": datetime.now(UTC).isoformat()})
 
 
 def validate_anomaly_parameters(payload: dict[str, Any]) -> tuple[int, int, float, int]:
@@ -482,6 +785,36 @@
         if path == "/api/spatial-measurement/runs":
             self.send_json(HTTPStatus.OK, {"runs": measurement_runs(self.root)})
             return
+        if path == "/api/3d-pointcloud/runs":
+            self.send_json(HTTPStatus.OK, {"runs": pointcloud_runs(self.root)})
+            return
+        if path == "/api/3d-pointcloud/annotation-sources":
+            self.send_json(HTTPStatus.OK, {"sources": pointcloud_annotation_sources(self.root)})
+            return
+        if path == "/api/3d-pointcloud/annotations":
+            self.send_json(HTTPStatus.OK, {"annotations": pointcloud_annotations(self.root)})
+            return
+        if path == "/api/3d-pointcloud/semantic-models":
+            self.send_json(HTTPStatus.OK, {"models": pointcloud_semantic_models(self.root)})
+            return
+        if path.startswith("/api/3d-pointcloud/model-inference-jobs/"):
+            job_id = path.rstrip("/").rsplit("/", 1)[-1]
+            job = pointcloud_inference_job(job_id)
+            self.send_json(HTTPStatus.OK if job else HTTPStatus.NOT_FOUND, {"job": job} if job else {"error": "Unknown point-cloud model inference job."})
+            return
+        if path.startswith("/api/3d-pointcloud/training-jobs/"):
+            job_id = path.rstrip("/").rsplit("/", 1)[-1]
+            job = pointcloud_training_job(job_id)
+            self.send_json(HTTPStatus.OK if job else HTTPStatus.NOT_FOUND, {"job": job} if job else {"error": "Unknown point-cloud training job."})
+            return
+        if path.startswith("/api/3d-pointcloud/photo-reconstruction-jobs/"):
+            job_id = path.rstrip("/").rsplit("/", 1)[-1]
+            job = photo_reconstruction_job(job_id)
+            self.send_json(HTTPStatus.OK if job else HTTPStatus.NOT_FOUND, {"job": job} if job else {"error": "Unknown photo-reconstruction job."})
+            return
+        if path == "/api/risk-rule-engine/runs":
+            self.send_json(HTTPStatus.OK, {"runs": risk_rule_runs(self.root)})
+            return
         if path == "/api/anomaly-detection/runs":
             self.send_json(HTTPStatus.OK, {"runs": anomaly_runs(self.root)})
             return
@@ -523,6 +856,24 @@
             if path == "/api/spatial-measurement/runs":
                 self.send_json(HTTPStatus.CREATED, {"run": self.create_measurement_run(payload)})
                 return
+            if path == "/api/3d-pointcloud/runs":
+                self.send_json(HTTPStatus.CREATED, {"run": self.create_pointcloud_run(payload)})
+                return
+            if path == "/api/3d-pointcloud/annotations":
+                self.send_json(HTTPStatus.CREATED, {"annotation": self.create_pointcloud_annotation(payload)})
+                return
+            if path == "/api/3d-pointcloud/training-runs":
+                self.send_json(HTTPStatus.ACCEPTED, {"job": self.create_pointcloud_training_run(payload)})
+                return
+            if path == "/api/3d-pointcloud/model-inference-runs":
+                self.send_json(HTTPStatus.ACCEPTED, {"job": self.create_pointcloud_model_inference_run(payload)})
+                return
+            if path == "/api/3d-pointcloud/photo-reconstruction-runs":
+                self.send_json(HTTPStatus.ACCEPTED, {"job": self.create_photo_reconstruction_run(payload)})
+                return
+            if path == "/api/risk-rule-engine/runs":
+                self.send_json(HTTPStatus.CREATED, {"run": self.create_risk_rule_run(payload)})
+                return
             if path == "/api/anomaly-detection/runs":
                 self.send_json(HTTPStatus.ACCEPTED, {"job": self.create_anomaly_run(payload)})
                 return
@@ -535,14 +886,46 @@
             self.log_error("local run failed: %s", exc)
             self.send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "Local run failed. Check the console terminal for details."})
 
+    def do_DELETE(self) -> None:  # noqa: N802 - annotation revisions are explicitly user-removable
+        path = urlsplit(self.path).path
+        prefix = "/api/3d-pointcloud/annotations/"
+        if not path.startswith(prefix):
+            self.send_json(HTTPStatus.NOT_FOUND, {"error": "Unknown local API endpoint."})
+            return
+        try:
+            annotation_id = path[len(prefix):]
+            if not annotation_id or "/" in annotation_id or SAFE_FILE_NAME.search(annotation_id) or len(annotation_id) > 120:
+                raise ApiError("Invalid annotation id.")
+            record = next((item for item in pointcloud_annotations(self.root) if item["id"] == annotation_id), None)
+            if not record:
+                raise ApiError("The selected annotation revision is unavailable.")
+            annotation_root = (self.root / "shared" / "outputs" / "05-3d-pointcloud" / "annotations").resolve()
+            location = (annotation_root / annotation_id).resolve()
+            location.relative_to(annotation_root)
+            if not (location / "annotation.json").is_file():
+                raise ApiError("The selected annotation revision is incomplete.")
+            shutil.rmtree(location)
+            self.send_json(HTTPStatus.OK, {"deletedId": annotation_id})
+        except ApiError as exc:
+            self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
+        except ValueError:
+            self.send_json(HTTPStatus.BAD_REQUEST, {"error": "Invalid annotation location."})
+        except Exception as exc:  # pragma: no cover - defensive server boundary
+            self.log_error("annotation deletion failed: %s", exc)
+            self.send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "Annotation deletion failed. Check the console terminal for details."})
+
     def do_PUT(self) -> None:  # noqa: N802 - binary upload endpoint
         path = urlsplit(self.path).path
-        if not path.startswith("/api/change-detection/uploads/") and not path.startswith("/api/anomaly-detection/uploads/"):
+        if not path.startswith("/api/change-detection/uploads/") and not path.startswith("/api/anomaly-detection/uploads/") and not path.startswith("/api/3d-pointcloud/photo-uploads/") and not path.startswith("/api/3d-pointcloud/pointcloud-uploads/"):
             self.send_json(HTTPStatus.NOT_FOUND, {"error": "Unknown local API endpoint."})
             return
         try:
             if path.startswith("/api/anomaly-detection/uploads/"):
                 result = self.receive_anomaly_upload(path)
+            elif path.startswith("/api/3d-pointcloud/photo-uploads/"):
+                result = self.receive_photo_reconstruction_upload(path)
+            elif path.startswith("/api/3d-pointcloud/pointcloud-uploads/"):
+                result = self.receive_pointcloud_upload(path)
             else:
                 result = self.receive_change_upload(path)
             self.send_json(HTTPStatus.CREATED, result)
@@ -554,7 +937,7 @@
 
     def do_OPTIONS(self) -> None:  # noqa: N802
         self.send_response(HTTPStatus.NO_CONTENT)
-        self.send_header("Allow", "GET, POST, PUT, OPTIONS")
+        self.send_header("Allow", "GET, POST, PUT, DELETE, OPTIONS")
         self.end_headers()
 
     def read_json_body(self) -> dict[str, Any]:
@@ -581,16 +964,23 @@
             raise ApiError(f"Processing failed: {message[:600]}")
 
     def receive_change_upload(self, path: str) -> dict[str, Any]:
-        return self.receive_binary_upload(path, "00-change-detection", {"before", "after"}, "change-detection")
+        return self.receive_binary_upload(path, "00-change-detection", {"before", "after"}, {".jpg", ".jpeg", ".png", ".tif", ".tiff"}, "change-detection")
 
     def receive_anomaly_upload(self, path: str) -> dict[str, Any]:
-        return self.receive_binary_upload(path, "09-anomaly-detection", {"reference", "input"}, "anomaly-detection")
+        return self.receive_binary_upload(path, "09-anomaly-detection", {"reference", "input"}, {".jpg", ".jpeg", ".png", ".tif", ".tiff"}, "anomaly-detection")
+
+    def receive_photo_reconstruction_upload(self, path: str) -> dict[str, Any]:
+        return self.receive_binary_upload(path, "05-3d-pointcloud", {"photo"}, {".jpg", ".jpeg"}, "photo reconstruction")
+
+    def receive_pointcloud_upload(self, path: str) -> dict[str, Any]:
+        return self.receive_binary_upload(path, "05-3d-pointcloud", {"pointcloud"}, {".ply", ".pcd", ".xyz", ".xyzn", ".xyzrgb", ".las", ".laz"}, "point-cloud")
 
     def receive_binary_upload(
         self,
         path: str,
         capability: str,
         allowed_roles: set[str],
+        suffixes: set[str],
         label: str,
     ) -> dict[str, Any]:
         upload_id = path.rstrip("/").rsplit("/", 1)[-1]
@@ -607,7 +997,7 @@
             name = unquote(encoded_name, encoding="utf-8", errors="strict")
         except UnicodeError as exc:
             raise ApiError("Upload name is not valid UTF-8 percent encoding.") from exc
-        safe_name = safe_file_name(name, {".jpg", ".jpeg", ".png", ".tif", ".tiff"})
+        safe_name = safe_file_name(name, suffixes)
         content_length = self.headers.get("Content-Length")
         if content_length is None or not content_length.isdigit():
             raise ApiError("Binary upload requires a Content-Length header.")
@@ -643,6 +1033,16 @@
     def resolve_anomaly_upload(self, payload: Any, role: str) -> tuple[str, Path, str]:
         name, path = self.resolve_binary_upload(payload, role, "09-anomaly-detection", "anomaly-detection")
         manifest = load_json(path.parent / f"{role}.json")
+        return name, path, str(manifest.get("sha256") or "")
+
+    def resolve_photo_reconstruction_upload(self, payload: Any) -> tuple[str, Path, str]:
+        name, path = self.resolve_binary_upload(payload, "photo", "05-3d-pointcloud", "photo reconstruction")
+        manifest = load_json(path.parent / "photo.json")
+        return name, path, str(manifest.get("sha256") or "")
+
+    def resolve_pointcloud_upload(self, payload: Any) -> tuple[str, Path, str]:
+        name, path = self.resolve_binary_upload(payload, "pointcloud", "05-3d-pointcloud", "point-cloud")
+        manifest = load_json(path.parent / "pointcloud.json")
         return name, path, str(manifest.get("sha256") or "")
 
     def resolve_binary_upload(self, payload: Any, role: str, capability: str, label: str) -> tuple[str, Path]:
@@ -843,6 +1243,10 @@
         for source_name, destination_name in (
             ("change_probability.tif", "change_probability.tif"),
             ("change_mask.tif", "change_mask.tif"),
+            ("generic_difference_mask.tif", "generic_difference_mask.tif"),
+            ("change_model_overlay.jpg", "change_model_overlay.jpg"),
+            ("before_processed_preview.jpg", "before_processed_preview.jpg"),
+            ("after_registered_preview.jpg", "after_registered_preview.jpg"),
             ("changes.geojson", "changes.geojson"),
             ("changes_rectangles.geojson", "changes_rectangles.geojson"),
             ("changes_rectangles_wgs84.geojson", "changes_rectangles_wgs84.geojson"),
@@ -851,6 +1255,8 @@
             if source.is_file():
                 shutil.copyfile(source, output / destination_name)
         overlay_source = result_dir / "overlay_preview.jpg"
+        if (inference_output / "change_overlay.jpg").is_file() and float(summary.get("threshold", 0.5)) == float(inference_metadata.get("thresholds", {}).get("change_probability", 0.5)):
+            overlay_source = inference_output / "change_overlay.jpg"
         if not overlay_source.is_file():
             raise ApiError("The selected scan preview is unavailable.")
         shutil.copyfile(overlay_source, output / "change_overlay.jpg")
@@ -876,11 +1282,17 @@
                 "raw_input_dir": relative_path(self.root, scan_raw_root),
                 "raw_before": relative_path(self.root, before_raw),
                 "raw_after": relative_path(self.root, after_raw),
+                "processed_input_dir": relative_path(self.root, self.root / "shared" / "data" / "processed" / "00-change-detection" / scan_id),
+                "generic_difference": inference_metadata.get("generic_difference"),
                 "artifacts": {
                     "probability_raster": "change_probability.tif",
                     "raw_mask_raster": "change_mask.tif",
                     "mask_raster": "change_mask.tif",
+                    "generic_difference_mask": "generic_difference_mask.tif" if (output / "generic_difference_mask.tif").is_file() else None,
                     "overlay": "change_overlay.jpg",
+                    "model_overlay": "change_model_overlay.jpg" if (output / "change_model_overlay.jpg").is_file() else None,
+                    "before_processed_preview": "before_processed_preview.jpg" if (output / "before_processed_preview.jpg").is_file() else None,
+                    "after_registered_preview": "after_registered_preview.jpg" if (output / "after_registered_preview.jpg").is_file() else None,
                     "vector": "changes.geojson",
                     "rectangle_vector": "changes_rectangles.geojson",
                     "rectangle_vector_wgs84": "changes_rectangles_wgs84.geojson" if (output / "changes_rectangles_wgs84.geojson").is_file() else None,
@@ -1155,6 +1567,270 @@
         metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
         return next(item for item in measurement_runs(self.root) if item["id"] == run_id)
 
+    def create_pointcloud_run(self, payload: dict[str, Any]) -> dict[str, Any]:
+        uploads = payload.get("pointClouds")
+        source_dense_run_id = payload.get("sourceDenseRunId")
+        if uploads is None and not isinstance(source_dense_run_id, str):
+            raise ApiError("3D point-cloud request must include at least one PLY, PCD, XYZ, LAS, or LAZ file.")
+        if uploads is not None and (not isinstance(uploads, list) or not uploads):
+            raise ApiError("3D point-cloud request must include at least one PLY, PCD, XYZ, LAS, or LAZ file.")
+        if isinstance(uploads, list) and len(uploads) > MAX_POINTCLOUDS_PER_RUN:
+            raise ApiError(f"A 3D point-cloud run accepts at most {MAX_POINTCLOUDS_PER_RUN} files.")
+        suffixes = {".ply", ".pcd", ".xyz", ".xyzn", ".xyzrgb", ".las", ".laz"}
+        staged_upload_dirs: list[Path] = []
+        if isinstance(source_dense_run_id, str):
+            if SAFE_FILE_NAME.search(source_dense_run_id) or len(source_dense_run_id) > 120:
+                raise ApiError("Invalid dense point-cloud source run id.")
+            source_case = next((item for item in pointcloud_runs(self.root) if item["id"] == source_dense_run_id), None)
+            if not source_case:
+                raise ApiError("The selected dense point-cloud source is unavailable.")
+            source_artifact = self.root / str(source_case["artifactRoot"])
+            source_metadata = load_json(source_artifact / "run_metadata.json")
+            dense = source_metadata.get("dense_photo_reconstruction")
+            source_file = dense.get("dense_point_cloud_file") if isinstance(dense, dict) else None
+            source_path = source_artifact / str(source_file or "")
+            if not isinstance(source_file, str) or source_path.suffix.lower() != ".ply" or not source_path.is_file():
+                raise ApiError("The selected run has no available dense PLY output.")
+            decoded = [(f"{source_dense_run_id}-dense.ply", source_path, file_sha256(source_path))]
+        elif all(isinstance(item, dict) and isinstance(item.get("content"), str) for item in uploads):
+            decoded = [(name, content, "") for name, content in (decode_upload(item, suffixes) for item in uploads)]
+        else:
+            decoded = [self.resolve_pointcloud_upload(item) for item in uploads]
+            staged_upload_dirs = [path.parent for _, path, _ in decoded]
+        if len({name.casefold() for name, _, _ in decoded}) != len(decoded):
+            raise ApiError("Uploaded point-cloud names must be unique within one run.")
+        run_id = make_run_id("pointcloud")
+        raw_root = self.root / "shared" / "data" / "raw" / "05-3d-pointcloud" / "runs" / run_id
+        processed_root = self.root / "shared" / "data" / "processed" / "05-3d-pointcloud" / run_id
+        raw_root.mkdir(parents=True, exist_ok=False)
+        processed_root.mkdir(parents=True, exist_ok=False)
+        source_sha256: dict[str, str] = {}
+        source_bytes: dict[str, int] = {}
+        for name, staged_or_content, expected_sha256 in decoded:
+            raw_path = raw_root / name
+            if isinstance(staged_or_content, bytes):
+                raw_path.write_bytes(staged_or_content)
+            else:
+                shutil.copyfile(staged_or_content, raw_path)
+            if expected_sha256 and file_sha256(raw_path) != expected_sha256:
+                raise ApiError(f"Uploaded point-cloud checksum changed while staging: {name}.")
+            source_sha256[name] = file_sha256(raw_path)
+            source_bytes[name] = raw_path.stat().st_size
+            shutil.copyfile(raw_path, processed_root / name)
+        output = self.root / "shared" / "outputs" / "05-3d-pointcloud" / "runs" / run_id
+        python = self.root / ".venvs" / "05-3d-pointcloud" / "Scripts" / "python.exe"
+        if not python.is_file():
+            raise ApiError("3D point-cloud virtual environment is unavailable. Run the capability setup first.")
+        command = [str(python), str(self.root / "capabilities" / "05-3d-pointcloud" / "run_pointcloud_understanding.py"), "--input", str(processed_root), "--output", str(output), "--ground-up-axis", "z"]
+        with RUN_LOCK:
+            self.run_command(command, 900)
+        metadata_path = output / "run_metadata.json"
+        if not metadata_path.is_file():
+            raise ApiError("3D point-cloud script finished without the expected result metadata.")
+        metadata = load_json(metadata_path)
+        metadata["input_dir"] = relative_path(self.root, processed_root)
+        metadata["raw_input_dir"] = relative_path(self.root, raw_root)
+        metadata["source_sha256"] = source_sha256
+        metadata["source_bytes"] = source_bytes
+        metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
+        for staging in staged_upload_dirs:
+            shutil.rmtree(staging)
+        return next(item for item in pointcloud_runs(self.root) if item["id"] == run_id)
+
+    def create_pointcloud_annotation(self, payload: dict[str, Any]) -> dict[str, Any]:
+        source_id = payload.get("sourceId")
+        labels = payload.get("labels")
+        if not isinstance(source_id, str) or not isinstance(labels, list):
+            raise ApiError("Annotation request must include a sourceId and labels array.")
+        source = next((item for item in pointcloud_annotation_sources(self.root) if item["id"] == source_id), None)
+        if not source:
+            raise ApiError("The selected generated annotation source is unavailable.")
+        if len(labels) > MAX_ANNOTATION_LABELS:
+            raise ApiError(f"An annotation revision accepts at most {MAX_ANNOTATION_LABELS} labelled points.")
+        compact: dict[int, int] = {}
+        for item in labels:
+            if not isinstance(item, list) or len(item) != 2 or not all(isinstance(value, int) for value in item):
+                raise ApiError("Each annotation label must be [pointIndex, classCode].")
+            index, code = item
+            if index < 0 or index >= int(source["pointCount"]) or code not in POINTCLOUD_CLASS_CODES:
+                raise ApiError("Annotation contains an out-of-range point index or unsupported class code.")
+            compact[index] = code
+        if not compact:
+            raise ApiError("Save at least one user-confirmed point label.")
+        annotation_id = make_run_id("annotation")
+        location = self.root / "shared" / "outputs" / "05-3d-pointcloud" / "annotations" / annotation_id
+        location.mkdir(parents=True, exist_ok=False)
+        source_path = self.root / str(source["artifactRoot"]) / str(source["file"])
+        class_counts = {str(code): sum(value == code for value in compact.values()) for code in sorted(POINTCLOUD_CLASS_CODES)}
+        document = {
+            "schema_version": 1, "id": annotation_id, "created_at": datetime.now(UTC).isoformat(),
+            "source_id": source_id, "source_path": str(source_path.resolve()), "source_sha256": source["sha256"],
+            "source_run_id": source["runId"], "point_count": int(source["pointCount"]),
+            "labels": [[index, code] for index, code in sorted(compact.items())], "class_counts": class_counts,
+            "class_schema": {str(code): {"code": code} for code in sorted(POINTCLOUD_CLASS_CODES)},
+            "provenance": "human_confirmed_point_labels_only",
+        }
+        path = location / "annotation.json"
+        path.write_text(json.dumps(document, ensure_ascii=False, indent=2), encoding="utf-8")
+        return {"id": annotation_id, "sourceId": source_id, "path": relative_path(self.root, path), "labelCount": len(compact), "classCounts": class_counts, "createdAt": document["created_at"]}
+
+    def create_pointcloud_training_run(self, payload: dict[str, Any]) -> dict[str, Any]:
+        annotation_id = payload.get("annotationId")
+        device = payload.get("device", "auto")
+        if not isinstance(annotation_id, str) or SAFE_FILE_NAME.search(annotation_id) or len(annotation_id) > 120:
+            raise ApiError("Invalid annotation id.")
+        if device not in {"auto", "cpu", "cuda"}:
+            raise ApiError("Training device must be auto, cpu, or cuda.")
+        annotation = self.root / "shared" / "outputs" / "05-3d-pointcloud" / "annotations" / annotation_id / "annotation.json"
+        record = load_json(annotation)
+        if record.get("schema_version") != 1:
+            raise ApiError("The selected annotation revision is unavailable.")
+        python = self.root / ".venvs" / "05-3d-pointcloud" / "Scripts" / "python.exe"
+        if not python.is_file():
+            raise ApiError("3D point-cloud virtual environment is unavailable. Run the capability setup first.")
+        job_id = uuid4().hex
+        output = self.root / "shared" / "outputs" / "05-3d-pointcloud" / "training-runs" / make_run_id("semantic-model")
+        job = {"id": job_id, "annotationId": annotation_id, "status": "queued", "stage": "queued", "device": device, "createdAt": datetime.now(UTC).isoformat()}
+        with POINTCLOUD_TRAINING_JOBS_LOCK:
+            POINTCLOUD_TRAINING_JOBS[job_id] = job
+        thread = threading.Thread(target=execute_pointcloud_training_job, args=(self.root, job_id, annotation, output, device), daemon=True, name=f"pointcloud-training-{job_id[:8]}")
+        thread.start()
+        return dict(job)
+
+    def create_pointcloud_model_inference_run(self, payload: dict[str, Any]) -> dict[str, Any]:
+        model_id = payload.get("modelId")
+        upload = payload.get("pointCloud")
+        if not isinstance(model_id, str) or SAFE_FILE_NAME.search(model_id) or len(model_id) > 120:
+            raise ApiError("Invalid trained model id.")
+        model_record = next((item for item in pointcloud_semantic_models(self.root) if item["id"] == model_id), None)
+        if not model_record:
+            raise ApiError("The selected trained model is unavailable or incomplete.")
+        name, staged_path, expected_sha256 = self.resolve_pointcloud_upload(upload)
+        suffixes = {".ply", ".pcd", ".xyz", ".xyzn", ".xyzrgb", ".las", ".laz"}
+        if Path(name).suffix.lower() not in suffixes:
+            raise ApiError("Model inference requires a PLY, PCD, XYZ, LAS, or LAZ point cloud.")
+        python = self.root / ".venvs" / "05-3d-pointcloud" / "Scripts" / "python.exe"
+        if not python.is_file():
+            raise ApiError("3D point-cloud virtual environment is unavailable. Run the capability setup first.")
+        run_id = make_run_id("semantic-inference")
+        raw_root = self.root / "shared" / "data" / "raw" / "05-3d-pointcloud" / "model-inference-runs" / run_id
+        processed_root = self.root / "shared" / "data" / "processed" / "05-3d-pointcloud" / "model-inference-runs" / run_id
+        output = self.root / "shared" / "outputs" / "05-3d-pointcloud" / "model-inference-runs" / run_id
+        raw_root.mkdir(parents=True, exist_ok=False)
+        processed_root.mkdir(parents=True, exist_ok=False)
+        raw_path = raw_root / name
+        shutil.copyfile(staged_path, raw_path)
+        actual_sha256 = file_sha256(raw_path)
+        if expected_sha256 and actual_sha256 != expected_sha256:
+            raise ApiError("Uploaded point-cloud checksum changed while staging.")
+        processed_path = processed_root / name
+        shutil.copyfile(raw_path, processed_path)
+        # Only remove the staging copy after its immutable raw copy was verified.
+        shutil.rmtree(staged_path.parent)
+        model_path = self.root / str(model_record["model"])
+        training_root = (self.root / "shared" / "outputs" / "05-3d-pointcloud" / "training-runs").resolve()
+        try:
+            model_path.resolve().relative_to(training_root)
+        except ValueError as exc:
+            raise ApiError("Selected model is outside the allowed training output directory.") from exc
+        job_id = uuid4().hex
+        job = {"id": job_id, "runId": run_id, "modelId": model_id, "inputName": name, "status": "queued", "stage": "queued", "device": "cpu", "createdAt": datetime.now(UTC).isoformat(), "sourceSha256": actual_sha256, "rawInput": relative_path(self.root, raw_path), "processedInput": relative_path(self.root, processed_path)}
+        with POINTCLOUD_INFERENCE_JOBS_LOCK:
+            POINTCLOUD_INFERENCE_JOBS[job_id] = job
+        thread = threading.Thread(target=execute_pointcloud_inference_job, args=(self.root, job_id, model_path, processed_path, output), daemon=True, name=f"pointcloud-inference-{job_id[:8]}")
+        thread.start()
+        return dict(job)
+
+    def create_photo_reconstruction_run(self, payload: dict[str, Any]) -> dict[str, Any]:
+        uploads = payload.get("photos")
+        if not isinstance(uploads, list) or len(uploads) < 3:
+            raise ApiError("Photo reconstruction needs at least three JPG/JPEG photos from one coherent flight or camera sequence.")
+        if len(uploads) > MAX_PHOTO_RECONSTRUCTION_IMAGES_PER_RUN:
+            raise ApiError(f"A photo reconstruction run accepts at most {MAX_PHOTO_RECONSTRUCTION_IMAGES_PER_RUN} photos.")
+        use_position_priors = payload.get("usePositionPriors", False)
+        if not isinstance(use_position_priors, bool):
+            raise ApiError("Photo reconstruction usePositionPriors must be true or false.")
+        photos = [self.resolve_photo_reconstruction_upload(value) for value in uploads]
+        if len({name.casefold() for name, _, _ in photos}) != len(photos):
+            raise ApiError("Uploaded photo names must be unique within one run.")
+        python = self.root / ".venvs" / "05-3d-pointcloud" / "Scripts" / "python.exe"
+        openmvs = self.root / "shared" / "tools" / "openmvs-2.4.0" / "vc17" / "x64" / "Release"
+        if not python.is_file() or not (openmvs / "DensifyPointCloud.exe").is_file():
+            raise ApiError("Photo-reconstruction CPU environment is unavailable. Run the capability setup first.")
+
+        run_id = make_run_id("photo-reconstruction")
+        job_id = uuid4().hex
+        raw_root = self.root / "shared" / "data" / "raw" / "05-3d-pointcloud" / "runs" / run_id
+        processed_root = self.root / "shared" / "data" / "processed" / "05-3d-pointcloud" / run_id
+        sparse_output = processed_root / "sparse_sfm"
+        output = self.root / "shared" / "outputs" / "05-3d-pointcloud" / "runs" / run_id
+        raw_root.mkdir(parents=True, exist_ok=False)
+        processed_root.mkdir(parents=True, exist_ok=False)
+        source_sha256: dict[str, str] = {}
+        source_bytes: dict[str, int] = {}
+        for name, staged_path, expected_sha256 in photos:
+            raw_path = raw_root / name
+            shutil.copyfile(staged_path, raw_path)
+            actual_sha256 = file_sha256(raw_path)
+            if expected_sha256 and actual_sha256 != expected_sha256:
+                raise ApiError(f"Uploaded file checksum changed while staging: {name}.")
+            shutil.copyfile(raw_path, processed_root / name)
+            source_sha256[name] = actual_sha256
+            source_bytes[name] = raw_path.stat().st_size
+        for _, staged_path, _ in photos:
+            shutil.rmtree(staged_path.parent)
+
+        job = {"id": job_id, "runId": run_id, "status": "queued", "stage": "queued", "createdAt": datetime.now(UTC).isoformat(), "inputImages": len(photos), "usePositionPriors": use_position_priors}
+        with PHOTO_RECONSTRUCTION_JOBS_LOCK:
+            PHOTO_RECONSTRUCTION_JOBS[job_id] = job
+        thread = threading.Thread(
+            target=execute_photo_reconstruction_job,
+            args=(self.root, job_id, run_id, raw_root, processed_root, sparse_output, output, source_sha256, source_bytes, use_position_priors),
+            daemon=True,
+            name=f"photo-reconstruction-{run_id}",
+        )
+        thread.start()
+        return dict(job)
+
+    def create_risk_rule_run(self, payload: dict[str, Any]) -> dict[str, Any]:
+        files = payload.get("files")
+        if not isinstance(files, dict) or set(files) != RISK_RULE_REQUIRED_FILES:
+            raise ApiError("Risk-rule request must contain observations, zones and rules files.")
+        observations_name, observations_bytes = decode_upload(files["observations"], {".geojson"})
+        zones_name, zones_bytes = decode_upload(files["zones"], {".geojson"})
+        rules_name, rules_bytes = decode_upload(files["rules"], {".json"})
+        if len({observations_name.casefold(), zones_name.casefold(), rules_name.casefold()}) != 3:
+            raise ApiError("Risk-rule uploaded file names must be unique.")
+        run_id = make_run_id("risk")
+        raw_root = self.root / "shared" / "data" / "raw" / "07-risk-rule-engine" / "runs" / run_id
+        processed_root = self.root / "shared" / "data" / "processed" / "07-risk-rule-engine" / run_id
+        raw_root.mkdir(parents=True, exist_ok=False)
+        processed_root.mkdir(parents=True, exist_ok=False)
+        staged = ((observations_name, observations_bytes), (zones_name, zones_bytes), (rules_name, rules_bytes))
+        for name, content in staged:
+            (raw_root / name).write_bytes(content)
+            (processed_root / name).write_bytes(content)
+        output = self.root / "shared" / "outputs" / "07-risk-rule-engine" / "runs" / run_id
+        python = self.root / ".venvs" / "07-risk-rule-engine" / "Scripts" / "python.exe"
+        if not python.is_file():
+            raise ApiError("Risk-rule virtual environment is unavailable. Run the capability setup first.")
+        command = [
+            str(python), str(self.root / "capabilities" / "07-risk-rule-engine" / "run_risk_rule_engine.py"),
+            "--observations", str(processed_root / observations_name), "--zones", str(processed_root / zones_name),
+            "--rules", str(processed_root / rules_name), "--output", str(output),
+        ]
+        with RUN_LOCK:
+            self.run_command(command, 600)
+        metadata_path = output / "run_metadata.json"
+        if not metadata_path.is_file():
+            raise ApiError("Risk-rule script finished without the expected result metadata.")
+        metadata = load_json(metadata_path)
+        metadata["input_dir"] = relative_path(self.root, processed_root)
+        metadata["raw_input_dir"] = relative_path(self.root, raw_root)
+        metadata["source_bytes"] = {name: len(content) for name, content in staged}
+        metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
+        return next(item for item in risk_rule_runs(self.root) if item["id"] == run_id)
+
     def send_json(self, status: HTTPStatus, payload: dict[str, Any]) -> None:
         body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
         self.send_response(status)
diff --git a/tests/test_serve_workbench_console.py b/tests/test_serve_workbench_console.py
index bb14f49..674c884 100644
--- a/tests/test_serve_workbench_console.py
+++ b/tests/test_serve_workbench_console.py
@@ -6,6 +6,7 @@
 import tempfile
 import unittest
 import json
+from http import HTTPStatus
 from urllib.parse import quote
 from pathlib import Path
 
@@ -96,6 +97,41 @@
             self.assertEqual(staged.read_bytes(), b"reference-bytes")
             self.assertEqual(result["sha256"], MODULE.file_sha256(staged))
 
+    def test_binary_photo_reconstruction_upload_preserves_jpeg_bytes(self) -> None:
+        with tempfile.TemporaryDirectory() as temp_dir:
+            handler = object.__new__(MODULE.WorkbenchConsoleHandler)
+            handler.directory = temp_dir
+            handler.path = "/api/3d-pointcloud/photo-uploads/4123456789abcdef0123456789abcdef?role=photo"
+            handler.headers = {"Content-Length": "10", "X-Upload-Name": "../flight.JPG"}
+            handler.rfile = io.BytesIO(b"jpeg-bytes")
+            result = handler.receive_photo_reconstruction_upload("/api/3d-pointcloud/photo-uploads/4123456789abcdef0123456789abcdef")
+            staged = Path(temp_dir) / "shared" / "data" / "raw" / "05-3d-pointcloud" / "uploads" / result["uploadId"] / "photo.jpg"
+            self.assertEqual(result["role"], "photo")
+            self.assertEqual(result["name"], "flight.jpg")
+            self.assertEqual(staged.read_bytes(), b"jpeg-bytes")
+
+    def test_binary_pointcloud_upload_preserves_las_bytes(self) -> None:
+        with tempfile.TemporaryDirectory() as temp_dir:
+            handler = object.__new__(MODULE.WorkbenchConsoleHandler)
+            handler.directory = temp_dir
+            handler.path = "/api/3d-pointcloud/pointcloud-uploads/5123456789abcdef0123456789abcdef?role=pointcloud"
+            handler.headers = {"Content-Length": "9", "X-Upload-Name": "../corridor.LAS"}
+            handler.rfile = io.BytesIO(b"las-bytes")
+            result = handler.receive_pointcloud_upload("/api/3d-pointcloud/pointcloud-uploads/5123456789abcdef0123456789abcdef")
+            staged = Path(temp_dir) / "shared" / "data" / "raw" / "05-3d-pointcloud" / "uploads" / result["uploadId"] / "pointcloud.las"
+            self.assertEqual(result["role"], "pointcloud")
+            self.assertEqual(result["name"], "corridor.las")
+            self.assertEqual(staged.read_bytes(), b"las-bytes")
+
+    def test_photo_reconstruction_request_requires_three_to_thirty_photos(self) -> None:
+        handler = self.make_handler()
+        with self.assertRaisesRegex(MODULE.ApiError, "at least three"):
+            handler.create_photo_reconstruction_run({"photos": []})
+        with self.assertRaisesRegex(MODULE.ApiError, "at most"):
+            handler.create_photo_reconstruction_run({"photos": [{}] * (MODULE.MAX_PHOTO_RECONSTRUCTION_IMAGES_PER_RUN + 1)})
+        with self.assertRaisesRegex(MODULE.ApiError, "true or false"):
+            handler.create_photo_reconstruction_run({"photos": [{}, {}, {}], "usePositionPriors": "yes"})
+
     def test_binary_anomaly_upload_decodes_chinese_file_name(self) -> None:
         with tempfile.TemporaryDirectory() as temp_dir:
             handler = object.__new__(MODULE.WorkbenchConsoleHandler)
@@ -158,6 +194,78 @@
         validation = next(item for item in runs if item["id"] == "validation-normal-20260817-v4")
         self.assertTrue(validation["artifactRoot"].startswith("shared/outputs/04-spatial-measurement/"))
 
+    def test_pointcloud_validation_run_is_discovered(self) -> None:
+        runs = MODULE.pointcloud_runs(ROOT)
+        validation = next(item for item in runs if item["id"] == "validation-normal-20260821-v2")
+        self.assertTrue(validation["artifactRoot"].startswith("shared/outputs/05-3d-pointcloud/"))
+
+    def test_pointcloud_request_requires_allowlisted_files(self) -> None:
+        handler = self.make_handler()
+        with self.assertRaisesRegex(MODULE.ApiError, "PLY, PCD, XYZ, LAS, or LAZ"):
+            handler.create_pointcloud_run({"pointClouds": []})
+        with self.assertRaisesRegex(MODULE.ApiError, "Unsupported file type"):
+            handler.create_pointcloud_run({"pointClouds": [{"name": "unsafe.exe", "content": "eA=="}]})
+
+    def test_semantic_model_discovery_requires_complete_training_artifacts(self) -> None:
+        with tempfile.TemporaryDirectory() as temp_dir:
+            root = Path(temp_dir)
+            complete = root / "shared" / "outputs" / "05-3d-pointcloud" / "training-runs" / "semantic-good"
+            incomplete = complete.parent / "semantic-incomplete"
+            complete.mkdir(parents=True)
+            incomplete.mkdir(parents=True)
+            (complete / "model.pt").write_bytes(b"weights")
+            (complete / "metrics.json").write_text(json.dumps({"capability": "05-3d-pointcloud", "classification": "B", "created_at": "2026-08-24", "classes": {"5": {"key": "vegetation", "label": "Vegetation", "color": [1, 2, 3]}, "16": {"key": "power_line", "label": "Power line", "color": [4, 5, 6]}}, "test": {"report": {"vegetation": {"f1-score": 0.9}}}}), encoding="utf-8")
+            (incomplete / "model.pt").write_bytes(b"weights")
+            models = MODULE.pointcloud_semantic_models(root)
+            self.assertEqual([item["id"] for item in models], ["semantic-good"])
+            self.assertEqual(models[0]["testF1"], {"vegetation": 0.9})
+
+    def test_model_inference_rejects_unsafe_or_unknown_model_id(self) -> None:
+        handler = self.make_handler()
+        with self.assertRaisesRegex(MODULE.ApiError, "Invalid trained model id"):
+            handler.create_pointcloud_model_inference_run({"modelId": "../model", "pointCloud": {}})
+        with self.assertRaisesRegex(MODULE.ApiError, "unavailable or incomplete"):
+            handler.create_pointcloud_model_inference_run({"modelId": "does-not-exist", "pointCloud": {}})
+
+    def test_annotation_delete_removes_only_selected_revision(self) -> None:
+        with tempfile.TemporaryDirectory() as temp_dir:
+            root = Path(temp_dir)
+            annotation_id = "annotation-20260822-091458-b72099"
+            revision = root / "shared" / "outputs" / "05-3d-pointcloud" / "annotations" / annotation_id
+            revision.mkdir(parents=True)
+            (revision / "annotation.json").write_text(json.dumps({
+                "schema_version": 1,
+                "id": annotation_id,
+                "source_id": "source:preview.ply",
+                "created_at": "2026-08-22T09:14:58+00:00",
+                "labels": [[1, 15]],
+            }), encoding="utf-8")
+            source_sentinel = root / "shared" / "data" / "raw" / "05-3d-pointcloud" / "part_01.las"
+            source_sentinel.parent.mkdir(parents=True)
+            source_sentinel.write_bytes(b"source-must-remain")
+
+            handler = object.__new__(MODULE.WorkbenchConsoleHandler)
+            handler.directory = str(root)
+            handler.path = f"/api/3d-pointcloud/annotations/{annotation_id}"
+            responses: list[tuple[HTTPStatus, dict[str, object]]] = []
+            handler.send_json = lambda status, body: responses.append((status, body))
+
+            handler.do_DELETE()
+
+            self.assertEqual(responses, [(HTTPStatus.OK, {"deletedId": annotation_id})])
+            self.assertFalse(revision.exists())
+            self.assertEqual(source_sentinel.read_bytes(), b"source-must-remain")
+
+    def test_risk_rule_validation_run_is_discovered(self) -> None:
+        runs = MODULE.risk_rule_runs(ROOT)
+        validation = next(item for item in runs if item["id"] == "validation-normal-20260820")
+        self.assertTrue(validation["artifactRoot"].startswith("shared/outputs/07-risk-rule-engine/"))
+
+    def test_risk_rule_request_requires_fixed_three_file_contract(self) -> None:
+        handler = self.make_handler()
+        with self.assertRaisesRegex(MODULE.ApiError, "observations, zones and rules"):
+            handler.create_risk_rule_run({"files": {"observations": {}}})
+
     def test_change_run_requires_both_allowlisted_images(self) -> None:
         handler = self.make_handler()
         with self.assertRaisesRegex(MODULE.ApiError, "name and Base64"):

--
Gitblit v1.9.3