Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ removed no sooner than the next major (see `docs/API_STABILITY.md`).
with one `pnts` tile per node and a per-tile `RTC_CENTER`, without ever
materializing the whole cloud. `max_level` bounds the exported hierarchy,
and the `tiles3d_copc_export` example converts a `.copc.laz` file directly.
LAS `u16` color channels are preserved as 8-bit `pnts` RGB when the source
carries color fields.
- **Epic 147 OGC 3D Tiles 1.1 point-cloud tileset export** (`interchange-tiles3d`):
a dependency-light `pnts` binary codec, a validated `tileset.json` model with
box bounding volumes and geometric error, and a deterministic octree tileset
Expand Down
88 changes: 85 additions & 3 deletions crates/spatialrust-interchange/src/tiles3d/copc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,14 +153,14 @@ pub fn export_copc_tileset(
let (x, y, z) = cloud.positions3().map_err(|error| {
InterchangeError::InvalidConfiguration(format!("COPC schema: {error}"))
})?;
let rgb = extract_rgb(&cloud);
let mut local_positions = Vec::with_capacity(cloud.len() * 3);
for point_index in 0..cloud.len() {
local_positions.push(x[point_index] - center[0] as f32);
local_positions.push(y[point_index] - center[1] as f32);
local_positions.push(z[point_index] - center[2] as f32);
}
let table =
PntsFeatureTable { positions: local_positions, rgb: None, rtc_center: Some(center) };
let table = PntsFeatureTable { positions: local_positions, rgb, rtc_center: Some(center) };
let pnts = encode_pnts(&table)?;
let uri = format!("{}.pnts", tiles.len());
let point_count = table.point_count();
Expand Down Expand Up @@ -239,6 +239,48 @@ fn bounds_diagonal(min: [f64; 3], max: [f64; 3]) -> f64 {
(dx * dx + dy * dy + dz * dz).sqrt()
}

/// Extracts interleaved 8-bit RGB from a cloud's color fields when present.
///
/// LAS/COPC color is stored as `u16` (0–65535); 3D Tiles `pnts` RGB uses
/// `u8` (0–255), so each channel is shifted right by eight bits. Returns
/// `None` when the cloud has no color fields.
fn extract_rgb(cloud: &spatialrust_core::PointCloud) -> Option<Vec<u8>> {
use spatialrust_core::{FieldSemantic, PointBuffer};

let field = |semantic: FieldSemantic| {
let name = match semantic {
FieldSemantic::ColorR => "red",
FieldSemantic::ColorG => "green",
FieldSemantic::ColorB => "blue",
_ => return None,
};
let buffer = cloud.field(name).ok()?;
match buffer {
PointBuffer::U16(values) => Some(values.as_slice()),
_ => None,
}
};

let (r, g, b) = match (
field(FieldSemantic::ColorR),
field(FieldSemantic::ColorG),
field(FieldSemantic::ColorB),
) {
(Some(r), Some(g), Some(b)) => (r, g, b),
_ => return None,
};
if r.len() != cloud.len() || g.len() != cloud.len() || b.len() != cloud.len() {
return None;
}
let mut out = Vec::with_capacity(cloud.len() * 3);
for index in 0..cloud.len() {
out.push((r[index] >> 8) as u8);
out.push((g[index] >> 8) as u8);
out.push((b[index] >> 8) as u8);
}
Some(out)
}

fn io_error(error: std::io::Error) -> InterchangeError {
InterchangeError::InvalidConfiguration(format!("tileset IO failure: {error}"))
}
Expand All @@ -249,7 +291,7 @@ mod tests {
use crate::tiles3d::pnts::decode_pnts;
use crate::tiles3d::tileset::parse_tileset_json;
use spatialrust_core::PointCloudBuilder;
use spatialrust_io::{write_copc_file_with_params, CopcWriterParams};
use spatialrust_io::{write_copc_file, write_copc_file_with_params, CopcWriterParams};

fn dense_grid_cloud(count: usize) -> spatialrust_core::PointCloud {
let mut builder = PointCloudBuilder::xyz();
Expand Down Expand Up @@ -323,4 +365,44 @@ mod tests {
let _ = std::fs::remove_dir_all(&out_dir);
let _ = std::fs::remove_file(&copc_path);
}

#[test]
fn preserves_rgb_from_las_color() {
use spatialrust_core::{PointCloudBuilder, StandardSchemas};

let mut builder = PointCloudBuilder::new(StandardSchemas::point_xyzrgb());
for index in 0..2_000usize {
let x = (index % 31) as f32 - 15.0;
let y = ((index / 31) % 29) as f32 - 14.0;
let z = ((index / (31 * 29)) % 23) as f32 - 11.0;
let r = ((index % 256) << 8) as f32;
let g = (((index * 7) % 256) << 8) as f32;
let b = (((index * 13) % 256) << 8) as f32;
builder.push_point([x, y, z, r, g, b]).unwrap();
}
let cloud = builder.build().unwrap();

let copc_path = std::env::temp_dir()
.join(format!("spatialrust_tiles3d_copc_rgb_{}.copc.laz", std::process::id()));
write_copc_file(&copc_path, &cloud).unwrap();

let out_dir = std::env::temp_dir()
.join(format!("spatialrust_tiles3d_copc_rgb_out_{}", std::process::id()));
let receipt =
export_copc_tileset(&copc_path, &out_dir, &CopcTilesetOptions::default()).unwrap();
assert_eq!(receipt.point_count, cloud.len() as u64);

let mut rgb_points = 0usize;
for tile in 0..receipt.tile_count {
let pnts = std::fs::read(out_dir.join(format!("{tile}.pnts"))).unwrap();
let decoded = decode_pnts(&pnts).unwrap();
let rgb = decoded.rgb.as_ref().expect("color-bearing COPC must write RGB");
assert_eq!(rgb.len(), decoded.point_count() * 3);
rgb_points += decoded.point_count();
}
assert_eq!(rgb_points, cloud.len());

let _ = std::fs::remove_dir_all(&out_dir);
let _ = std::fs::remove_file(&copc_path);
}
}
2 changes: 1 addition & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ on `spatialrust-core`, a GPU backend, or serde.
| 147B | Complete | `tileset.json` model: box bounding volumes, geometric error, refine, content URIs; strict JSON parse/serialize | `tiles3d` |
| 147C | Complete | Deterministic octree tileset builder from interleaved positions with point budgets, per-tile RTC_CENTER, and write receipt | `tiles3d` |
| 147D | Complete | Facade `interchange-tiles3d`, runnable example, FEATURE_MATRIX/CHANGELOG/notes | facade |
| 147E | Complete | Bounded COPC → 3D Tiles exporter: `CopcNodeReader` per-node hierarchy walk in `spatialrust-io` plus `export_copc_tileset` in `spatialrust-interchange` | `tiles3d-copc` |
| 147E | Complete | Bounded COPC → 3D Tiles exporter: `CopcNodeReader` per-node hierarchy walk in `spatialrust-io` plus `export_copc_tileset` in `spatialrust-interchange`, with LAS color preserved as 8-bit `pnts` RGB | `tiles3d-copc` |

The builder splits octants in a fixed bit order and writes one `pnts` payload
per BFS tile id; leaf geometric error is zero and internal errors halve each
Expand Down
9 changes: 5 additions & 4 deletions notes/2026-08-06_epic147_tiles3d.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ differentiator beyond glTF/USDA interchange.
- `crates/spatialrust-interchange/src/tiles3d/copc.rs` — `export_copc_tileset`
(feature `tiles3d-copc`): mirrors the COPC octree into a `tileset.json` plus
one `pnts` tile per node with a per-tile `RTC_CENTER`, bounded by `max_level`,
without materializing the whole cloud.
without materializing the whole cloud. LAS `u16` color channels are shifted
right by eight bits into 8-bit `pnts` RGB when the source has color fields.
- `crates/spatialrust-interchange/src/json.rs` — small dependency-free JSON
parser/serializer (number text preserved, deterministic output).
- Facade features `interchange-tiles3d`/`interchange-tiles3d-copc`,
Expand All @@ -54,7 +55,7 @@ differentiator beyond glTF/USDA interchange.
## Verification

- `cargo test -p spatialrust-interchange --features tiles3d` — 20 tests.
- `cargo test -p spatialrust-interchange --features tiles3d-copc` — 22 tests.
- `cargo test -p spatialrust-interchange --features tiles3d-copc` — 23 tests.
- `cargo test -p spatialrust --features "interchange-tiles3d io-pcd" --test tiles3d_smoke`.
- Manual run on synthetic PCD: 400,000 points → 8 tiles / 399,989 points after
voxel downsample, tileset.json 1,874 B + pnts 4,801,100 B.
Expand All @@ -64,5 +65,5 @@ differentiator beyond glTF/USDA interchange.
## Next slices

Epic 147 is the codec/builder substrate. The COPC exporter (147E) already
streams one node at a time; RGB preservation from LAS color fields and Python
bindings remain as follow-up slices through the meta-crate feature.
streams one node at a time and preserves LAS color; Python bindings remain as a
follow-up slice through the meta-crate feature.
Loading