use glam::IVec3;
use roxlap_formats::vxl::{self, ParseError, Vxl};
use serde::{Deserialize, Serialize};
use crate::{Grid, GridId, GridTransform, LodThresholds, Scene, StreamRadius};
fn compact_serialize_chunk(vxl: &Vxl) -> Vec<u8> {
let n_cols = (vxl.vsid as usize) * (vxl.vsid as usize);
let mut data: Vec<u8> = Vec::new();
let mut column_offset: Vec<u32> = Vec::with_capacity(n_cols + 1);
for i in 0..n_cols {
column_offset.push(u32::try_from(data.len()).expect("offset fits in u32"));
data.extend_from_slice(vxl.column_data(i));
}
column_offset.push(u32::try_from(data.len()).expect("offset fits in u32"));
let compact = Vxl {
vsid: vxl.vsid,
ipo: vxl.ipo,
ist: vxl.ist,
ihe: vxl.ihe,
ifo: vxl.ifo,
data: data.into_boxed_slice(),
column_offset: column_offset.into_boxed_slice(),
mip_base_offsets: Box::new([0, n_cols + 1]),
vbit: Box::new([]),
vbiti: 0,
};
vxl::serialize(&compact)
}
const RESTORE_EDIT_HEADROOM_PER_COLUMN: usize = 256;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SceneSnapshot {
pub next_grid_id: u32,
pub grids: Vec<(GridId, GridSnapshot)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GridSnapshot {
pub transform: GridTransform,
pub chunks: Vec<(IVec3, Vec<u8>)>,
#[serde(default)]
pub chunk_versions: Vec<(IVec3, u64)>,
#[serde(default)]
pub name: Option<String>,
#[serde(default = "default_render_sky")]
pub render_sky: bool,
#[serde(default)]
pub mip_levels_override: Option<u32>,
#[serde(default = "LodThresholds::always_near")]
pub lod_thresholds: LodThresholds,
#[serde(default)]
pub stream_radius: StreamRadius,
#[serde(default = "one_f64")]
pub voxel_world_size: f64,
#[serde(default)]
pub water_volumes: Vec<crate::WaterVolume>,
}
fn one_f64() -> f64 {
1.0
}
#[derive(Deserialize)]
struct SceneSnapshotV1 {
next_grid_id: u32,
grids: Vec<(GridId, GridSnapshotV1)>,
}
#[derive(Deserialize)]
struct GridSnapshotV1 {
transform: GridTransform,
chunks: Vec<(IVec3, Vec<u8>)>,
#[serde(default)]
chunk_versions: Vec<(IVec3, u64)>,
#[serde(default)]
name: Option<String>,
#[serde(default = "default_render_sky")]
render_sky: bool,
#[serde(default)]
mip_levels_override: Option<u32>,
#[serde(default = "LodThresholds::always_near")]
lod_thresholds: LodThresholds,
#[serde(default)]
stream_radius: StreamRadius,
}
impl From<SceneSnapshotV1> for SceneSnapshot {
fn from(v1: SceneSnapshotV1) -> Self {
Self {
next_grid_id: v1.next_grid_id,
grids: v1
.grids
.into_iter()
.map(|(id, g)| (id, GridSnapshotV2::from(g).into()))
.collect(),
}
}
}
impl From<GridSnapshotV1> for GridSnapshotV2 {
fn from(g: GridSnapshotV1) -> Self {
Self {
transform: g.transform,
chunks: g.chunks,
chunk_versions: g.chunk_versions,
name: g.name,
render_sky: g.render_sky,
mip_levels_override: g.mip_levels_override,
lod_thresholds: g.lod_thresholds,
stream_radius: g.stream_radius,
voxel_world_size: 1.0,
}
}
}
#[derive(Deserialize)]
struct SceneSnapshotV2 {
next_grid_id: u32,
grids: Vec<(GridId, GridSnapshotV2)>,
}
#[derive(Deserialize)]
struct GridSnapshotV2 {
transform: GridTransform,
chunks: Vec<(IVec3, Vec<u8>)>,
#[serde(default)]
chunk_versions: Vec<(IVec3, u64)>,
#[serde(default)]
name: Option<String>,
#[serde(default = "default_render_sky")]
render_sky: bool,
#[serde(default)]
mip_levels_override: Option<u32>,
#[serde(default = "LodThresholds::always_near")]
lod_thresholds: LodThresholds,
#[serde(default)]
stream_radius: StreamRadius,
#[serde(default = "one_f64")]
voxel_world_size: f64,
}
impl From<SceneSnapshotV2> for SceneSnapshot {
fn from(v2: SceneSnapshotV2) -> Self {
Self {
next_grid_id: v2.next_grid_id,
grids: v2.grids.into_iter().map(|(id, g)| (id, g.into())).collect(),
}
}
}
impl From<GridSnapshotV2> for GridSnapshot {
fn from(g: GridSnapshotV2) -> Self {
Self {
transform: g.transform,
chunks: g.chunks,
chunk_versions: g.chunk_versions,
name: g.name,
render_sky: g.render_sky,
mip_levels_override: g.mip_levels_override,
lod_thresholds: g.lod_thresholds,
stream_radius: g.stream_radius,
voxel_world_size: g.voxel_world_size,
water_volumes: Vec::new(),
}
}
}
fn default_render_sky() -> bool {
true
}
#[derive(Debug)]
pub enum FromSnapshotError {
ChunkParse {
grid: GridId,
chunk: IVec3,
source: ParseError,
},
}
impl std::fmt::Display for FromSnapshotError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ChunkParse {
grid,
chunk,
source,
} => {
write!(
f,
"scene snapshot: grid {} chunk {chunk:?} parse failed: {source:?}",
grid.raw()
)
}
}
}
}
impl std::error::Error for FromSnapshotError {}
impl Scene {
#[must_use]
pub fn to_snapshot(&self) -> SceneSnapshot {
let mut grid_ids: Vec<GridId> = self.grids.keys().copied().collect();
grid_ids.sort_unstable();
let mut grids = Vec::with_capacity(grid_ids.len());
for id in grid_ids {
let grid = &self.grids[&id];
let mut chunk_addrs: Vec<IVec3> = grid.chunks.keys().copied().collect();
chunk_addrs.sort_unstable_by_key(|a| (a.x, a.y, a.z));
let chunks = chunk_addrs
.into_iter()
.map(|addr| (addr, compact_serialize_chunk(&grid.chunks[&addr])))
.collect();
let mut version_addrs: Vec<IVec3> = grid
.chunk_versions
.iter()
.filter_map(|(a, v)| if *v != 0 { Some(*a) } else { None })
.collect();
version_addrs.sort_unstable_by_key(|a| (a.x, a.y, a.z));
let chunk_versions = version_addrs
.into_iter()
.map(|addr| (addr, grid.chunk_versions[&addr]))
.collect();
grids.push((
id,
GridSnapshot {
transform: grid.transform,
chunks,
chunk_versions,
name: grid.name.clone(),
render_sky: grid.render_sky,
mip_levels_override: grid.mip_levels_override,
lod_thresholds: grid.lod_thresholds,
stream_radius: grid.stream_radius,
voxel_world_size: grid.transform.voxel_world_size,
water_volumes: grid.water_volumes.clone(),
},
));
}
SceneSnapshot {
next_grid_id: self.next_grid_id,
grids,
}
}
pub fn from_snapshot(snap: &SceneSnapshot) -> Result<Self, FromSnapshotError> {
let mut scene = Self::new();
scene.next_grid_id = snap.next_grid_id;
for (id, gsnap) in &snap.grids {
let mut transform = gsnap.transform;
let vws = gsnap.voxel_world_size;
transform.voxel_world_size = if vws.is_finite() && (1e-6..=1e6).contains(&vws) {
vws
} else {
log::warn!(
"load_snapshot: grid {id:?} has out-of-range \
voxel_world_size {vws} — restoring at 1.0"
);
1.0
};
let mut grid = Grid::new(transform);
for (addr, bytes) in &gsnap.chunks {
let mut vxl =
vxl::parse(bytes).map_err(|source| FromSnapshotError::ChunkParse {
grid: *id,
chunk: *addr,
source,
})?;
let n_cols = (vxl.vsid as usize) * (vxl.vsid as usize);
vxl.reserve_edit_capacity(n_cols * RESTORE_EDIT_HEADROOM_PER_COLUMN);
grid.chunks.insert(*addr, vxl);
grid.note_chunk_set_changed();
}
for (addr, ver) in &gsnap.chunk_versions {
grid.restore_chunk_version(*addr, *ver);
}
grid.name.clone_from(&gsnap.name);
grid.render_sky = gsnap.render_sky;
grid.mip_levels_override = gsnap.mip_levels_override;
grid.lod_thresholds = gsnap.lod_thresholds;
grid.stream_radius = gsnap.stream_radius;
grid.water_volumes.clone_from(&gsnap.water_volumes);
scene.grids.insert(*id, grid);
}
Ok(scene)
}
}
pub const SNAPSHOT_MAGIC: [u8; 4] = *b"RXSS";
pub const SNAPSHOT_VERSION: u32 = 3;
#[derive(Debug)]
pub enum SnapshotLoadError {
BadMagic,
UnsupportedVersion(u32),
Decode(String),
Restore(FromSnapshotError),
}
impl std::fmt::Display for SnapshotLoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::BadMagic => write!(f, "not a roxlap scene snapshot (bad magic)"),
Self::UnsupportedVersion(v) => {
write!(
f,
"unsupported snapshot version {v} (this build reads <= {SNAPSHOT_VERSION})"
)
}
Self::Decode(msg) => write!(f, "snapshot payload decode failed: {msg}"),
Self::Restore(e) => write!(f, "snapshot restore failed: {e}"),
}
}
}
impl std::error::Error for SnapshotLoadError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Restore(e) => Some(e),
_ => None,
}
}
}
impl Scene {
#[must_use]
pub fn save_snapshot(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(64);
out.extend_from_slice(&SNAPSHOT_MAGIC);
out.extend_from_slice(&SNAPSHOT_VERSION.to_le_bytes());
bincode::serialize_into(&mut out, &self.to_snapshot())
.expect("bincode into Vec<u8> cannot fail");
out
}
pub fn load_snapshot(bytes: &[u8]) -> Result<Self, SnapshotLoadError> {
let (Some(magic), Some(version)) = (bytes.get(..4), bytes.get(4..8)) else {
return Err(SnapshotLoadError::BadMagic);
};
if magic != SNAPSHOT_MAGIC {
return Err(SnapshotLoadError::BadMagic);
}
let version = u32::from_le_bytes(version.try_into().expect("4-byte slice"));
let payload = &bytes[8..];
let snap: SceneSnapshot = match version {
1 => bincode::deserialize::<SceneSnapshotV1>(payload)
.map_err(|e| SnapshotLoadError::Decode(e.to_string()))?
.into(),
2 => bincode::deserialize::<SceneSnapshotV2>(payload)
.map_err(|e| SnapshotLoadError::Decode(e.to_string()))?
.into(),
3 => bincode::deserialize(payload)
.map_err(|e| SnapshotLoadError::Decode(e.to_string()))?,
v => return Err(SnapshotLoadError::UnsupportedVersion(v)),
};
Self::from_snapshot(&snap).map_err(SnapshotLoadError::Restore)
}
}
#[cfg(test)]
#[allow(clippy::cast_possible_wrap, clippy::type_complexity)]
mod tests {
use super::*;
use crate::chunks::tests::voxel_is_solid;
use crate::CHUNK_SIZE_XY;
use glam::DVec3;
use roxlap_formats::color::VoxColor;
impl GridId {
pub(crate) fn from_raw_for_test(raw: u32) -> Self {
Self(raw)
}
}
#[test]
fn sc_snap_scaled_grid_survives_round_trip() {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::at_scale(DVec3::new(5.0, -3.0, 0.0), 0.25));
scene
.grid_mut(id)
.unwrap()
.set_voxel(IVec3::new(1, 2, 100), Some(VoxColor(0x8011_2233)));
let bytes = scene.save_snapshot();
assert_eq!(&bytes[4..8], &3u32.to_le_bytes(), "expected v3 wire");
let restored = Scene::load_snapshot(&bytes).expect("round trip");
let (_, g) = restored.grids().next().expect("one grid");
assert!(
(g.transform.voxel_world_size - 0.25).abs() < 1e-12,
"scale must survive: got {}",
g.transform.voxel_world_size
);
assert_eq!(g.transform.origin, DVec3::new(5.0, -3.0, 0.0));
assert!(g.voxel_solid(IVec3::new(1, 2, 100)));
}
#[test]
fn sc_snap_out_of_range_scale_restores_at_one() {
for bad in [0.0, -2.0, f64::NAN, f64::INFINITY, 1e-300, 1e300] {
let snap = SceneSnapshot {
next_grid_id: 1,
grids: vec![(
GridId::from_raw_for_test(0),
GridSnapshot {
transform: GridTransform::identity(),
chunks: vec![],
chunk_versions: vec![],
name: None,
render_sky: true,
mip_levels_override: None,
lod_thresholds: LodThresholds::always_near(),
stream_radius: StreamRadius::default(),
voxel_world_size: bad,
water_volumes: vec![],
},
)],
};
let scene = Scene::from_snapshot(&snap).expect("restore");
let (_, g) = scene.grids().next().expect("one grid");
assert_eq!(
g.transform.voxel_world_size, 1.0,
"out-of-range vws {bad} must restore at 1.0"
);
}
let ok = SceneSnapshot {
next_grid_id: 1,
grids: vec![(
GridId::from_raw_for_test(0),
GridSnapshot {
transform: GridTransform::identity(),
chunks: vec![],
chunk_versions: vec![],
name: None,
render_sky: true,
mip_levels_override: None,
lod_thresholds: LodThresholds::always_near(),
stream_radius: StreamRadius::default(),
voxel_world_size: 1000.0,
water_volumes: vec![],
},
)],
};
let scene = Scene::from_snapshot(&ok).expect("restore");
assert_eq!(
scene.grids().next().unwrap().1.transform.voxel_world_size,
1000.0
);
}
fn build_two_grid_scene() -> (Scene, Vec<(GridId, IVec3, u32, u32, u32, VoxColor)>) {
let mut scene = Scene::new();
let g0 = scene.add_grid(GridTransform::at(DVec3::new(0.0, 0.0, 0.0)));
let g1 = scene.add_grid(GridTransform::at(DVec3::new(1000.0, 0.0, 0.0)));
let mut expected = Vec::new();
for chz in 0..2 {
for chy in 0..5 {
for chx in 0..5 {
let chunk_idx = IVec3::new(chx, chy, chz);
#[allow(clippy::cast_sign_loss)]
let color = VoxColor(
0x80_00_00_00 | ((chx as u32) << 16) | ((chy as u32) << 8) | (chz as u32),
);
let global_voxel = chunk_idx
* IVec3::new(
CHUNK_SIZE_XY as i32,
CHUNK_SIZE_XY as i32,
crate::CHUNK_SIZE_Z as i32,
)
+ IVec3::new(5, 6, 7);
scene
.grid_mut(g0)
.unwrap()
.set_voxel(global_voxel, Some(color));
expected.push((g0, chunk_idx, 5, 6, 7, color));
}
}
}
for chz in 0..2 {
for chy in 0..5 {
for chx in 0..5 {
let chunk_idx = IVec3::new(chx, chy, chz);
#[allow(clippy::cast_sign_loss)]
let color = VoxColor(
0x80_ff_00_00 | ((chx as u32) << 16) | ((chy as u32) << 8) | (chz as u32),
);
let global_voxel = chunk_idx
* IVec3::new(
CHUNK_SIZE_XY as i32,
CHUNK_SIZE_XY as i32,
crate::CHUNK_SIZE_Z as i32,
)
+ IVec3::new(10, 11, 12);
scene
.grid_mut(g1)
.unwrap()
.set_voxel(global_voxel, Some(color));
expected.push((g1, chunk_idx, 10, 11, 12, color));
}
}
}
(scene, expected)
}
fn assert_voxels_match(scene: &Scene, expected: &[(GridId, IVec3, u32, u32, u32, VoxColor)]) {
for &(grid_id, chunk_idx, vx, vy, vz, _color) in expected {
let grid = scene.grid(grid_id).expect("grid present");
let chunk = grid.chunk(chunk_idx).expect("chunk present");
assert!(
voxel_is_solid(chunk, vx, vy, vz),
"voxel ({vx},{vy},{vz}) in grid={} chunk={chunk_idx:?} not solid post-restore",
grid_id.raw()
);
}
}
#[test]
fn snapshot_round_trip_preserves_two_grid_100_chunk_scene() {
let (scene, expected) = build_two_grid_scene();
assert_eq!(scene.grid_count(), 2);
let total_chunks: usize = scene.grids().map(|(_, g)| g.chunks.len()).sum();
assert_eq!(total_chunks, 100, "test setup should produce 100 chunks");
let snap = scene.to_snapshot();
let bytes = bincode::serialize(&snap).expect("bincode serialize");
let snap_back: SceneSnapshot = bincode::deserialize(&bytes).expect("bincode deserialize");
let restored = Scene::from_snapshot(&snap_back).expect("restore");
assert_eq!(restored.grid_count(), 2);
let total_restored: usize = restored.grids().map(|(_, g)| g.chunks.len()).sum();
assert_eq!(total_restored, 100);
assert_voxels_match(&restored, &expected);
}
#[test]
fn snapshot_preserves_next_grid_id_and_transforms() {
let mut scene = Scene::new();
let g0 = scene.add_grid(GridTransform::at(DVec3::new(10.0, 20.0, 30.0)));
let _g1 = scene.add_grid(GridTransform::at(DVec3::new(40.0, 50.0, 60.0)));
scene.remove_grid(g0); let _g2 = scene.add_grid(GridTransform::at(DVec3::new(70.0, 80.0, 90.0)));
let snap = scene.to_snapshot();
assert_eq!(snap.next_grid_id, 3);
let restored = Scene::from_snapshot(&snap).expect("restore");
assert_eq!(restored.grid_count(), 2);
let mut restored_mut = restored;
let new_id = restored_mut.add_grid(GridTransform::identity());
assert_eq!(new_id.raw(), 3);
}
#[test]
fn restored_scene_is_editable() {
let (scene, _) = build_two_grid_scene();
let snap = scene.to_snapshot();
let mut restored = Scene::from_snapshot(&snap).expect("restore");
let g0 = GridId::from_raw_for_test(0);
let new_voxel = IVec3::new(50, 51, 52);
restored
.grid_mut(g0)
.expect("grid 0 present")
.set_voxel(new_voxel, Some(VoxColor(0x80_de_ad_be)));
let chunk = restored
.grid(g0)
.unwrap()
.chunk(IVec3::ZERO)
.expect("chunk created");
assert!(voxel_is_solid(chunk, 50, 51, 52));
}
#[test]
fn snapshot_round_trip_preserves_chunk_versions() {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
let g = scene.grid_mut(id).unwrap();
g.set_voxel(IVec3::new(0, 0, 0), Some(VoxColor(0x80_aa_bb_cc)));
g.set_voxel(IVec3::new(1, 1, 1), Some(VoxColor(0x80_dd_ee_ff)));
g.set_voxel(IVec3::new(2, 2, 2), Some(VoxColor(0x80_11_22_33)));
g.set_voxel(IVec3::new(128, 0, 0), Some(VoxColor(0x80_44_55_66)));
assert_eq!(g.chunk_version(IVec3::ZERO), 3);
assert_eq!(g.chunk_version(IVec3::new(1, 0, 0)), 1);
let snap = scene.to_snapshot();
let bytes = bincode::serialize(&snap).expect("bincode serialize");
let snap_back: SceneSnapshot = bincode::deserialize(&bytes).expect("bincode deserialize");
let restored = Scene::from_snapshot(&snap_back).expect("restore");
let g = restored.grid(id).expect("grid present");
assert_eq!(g.chunk_version(IVec3::ZERO), 3);
assert_eq!(g.chunk_version(IVec3::new(1, 0, 0)), 1);
assert_eq!(g.chunk_versions.len(), 2);
}
#[test]
fn snapshot_chunk_versions_zero_entries_are_dropped_from_wire() {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
let g = scene.grid_mut(id).unwrap();
g.chunk_versions.insert(IVec3::ZERO, 0);
let snap = scene.to_snapshot();
let g_snap = &snap.grids[0].1;
assert!(g_snap.chunk_versions.is_empty(), "zero entries dropped");
}
#[test]
fn snapshot_is_deterministic() {
let (scene, _) = build_two_grid_scene();
let s1 = bincode::serialize(&scene.to_snapshot()).unwrap();
let s2 = bincode::serialize(&scene.to_snapshot()).unwrap();
assert_eq!(s1, s2, "snapshot bytes should be deterministic");
}
}