Editor/BlenderBridge/BridgePersistence.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Editor;
using Sandbox;
namespace BlenderBridge
{
/// <summary>
/// Persistence layer for bridge mesh data.
/// Writes a sidecar manifest (.bridge.json) alongside the scene file
/// and binary mesh cache files so geometry survives play mode without
/// bloating the scene JSON.
/// </summary>
internal static class BridgePersistence
{
private const string CacheDirName = ".sbox_bridge_cache";
private const int ManifestVersion = 1;
// ── Public API ────────────────────────────────────────────────────────
/// <summary>Save manifest and binary cache after a create or mesh update.</summary>
internal static void SaveAfterChange( Scene scene, string bridgeId, GameObject go )
{
if ( scene == null || go == null ) return;
try
{
var meshComp = go.Components.Get<MeshComponent>();
if ( meshComp?.Mesh == null ) return;
var extracted = BlenderBridgeDispatcher.ExtractMeshData( meshComp.Mesh );
if ( extracted == null ) return;
// Save binary cache
var cachePath = GetCachePath( scene, bridgeId );
if ( cachePath != null )
{
SaveMeshCache( cachePath, extracted.Value.Vertices, extracted.Value.Faces );
}
// Update manifest
SaveManifest( scene );
}
catch ( Exception ex )
{
BlenderBridgeServer.LogInfo( $"Persistence save error: {ex.Message}" );
}
}
/// <summary>Remove cache file for a deleted bridge object.</summary>
internal static void RemoveFromCache( string bridgeId )
{
try
{
var scene = BridgeSceneHelper.ResolveScene();
if ( scene == null ) return;
var cachePath = GetCachePath( scene, bridgeId );
if ( cachePath != null && File.Exists( cachePath ) )
File.Delete( cachePath );
SaveManifest( scene );
}
catch { }
}
/// <summary>Restore mesh data from cache for bridge objects missing geometry.
/// Called on server start and after exiting play mode.</summary>
internal static void RestoreFromCache( Scene scene )
{
if ( scene == null ) return;
var cacheDir = GetCacheDir( scene );
if ( cacheDir == null || !Directory.Exists( cacheDir ) ) return;
int restored = 0;
foreach ( var root in scene.Children )
{
RestoreSubtree( root, cacheDir, ref restored );
}
if ( restored > 0 )
BlenderBridgeServer.LogInfo( $"Restored {restored} mesh(es) from cache" );
}
/// <summary>Check if any bridge state has been saved.</summary>
internal static bool HasSavedState( Scene scene )
{
if ( scene == null ) return false;
var manifestPath = GetManifestPath( scene );
return manifestPath != null && File.Exists( manifestPath );
}
// ── Manifest ──────────────────────────────────────────────────────────
private static void SaveManifest( Scene scene )
{
var manifestPath = GetManifestPath( scene );
if ( manifestPath == null ) return;
var objects = new Dictionary<string, object>();
foreach ( var root in scene.Children )
CollectManifestEntries( root, objects );
var manifest = new
{
version = ManifestVersion,
savedAt = DateTime.UtcNow.ToString( "o" ),
objects
};
var json = JsonSerializer.Serialize( manifest, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
} );
File.WriteAllText( manifestPath, json );
}
private static void CollectManifestEntries( GameObject node, Dictionary<string, object> entries )
{
foreach ( var tag in node.Tags.TryGetAll() )
{
if ( tag.StartsWith( "bridge_" ) && tag != "bridge_group" )
{
var bridgeId = tag.Substring( 7 );
var pos = node.WorldPosition;
var rot = node.WorldRotation.Angles();
var meshComp = node.Components.Get<MeshComponent>();
int vertCount = 0, faceCount = 0;
if ( meshComp?.Mesh != null )
{
vertCount = meshComp.Mesh.VertexHandles?.Count() ?? 0;
faceCount = meshComp.Mesh.FaceHandles?.Count() ?? 0;
}
entries[bridgeId] = new
{
name = node.Name,
vertexCount = vertCount,
faceCount = faceCount,
position = new { x = pos.x, y = pos.y, z = pos.z },
rotation = new { pitch = rot.pitch, yaw = rot.yaw, roll = rot.roll }
};
break;
}
}
foreach ( var child in node.Children )
CollectManifestEntries( child, entries );
}
// ── Binary Cache ──────────────────────────────────────────────────────
/// <summary>Write binary mesh cache: [uint32 vertCount][uint32 faceDataLen][float32[] verts][int32[] faces]</summary>
private static void SaveMeshCache( string path, float[] vertices, int[] faces )
{
var dir = Path.GetDirectoryName( path );
if ( dir != null )
Directory.CreateDirectory( dir );
using var fs = new FileStream( path, FileMode.Create );
using var bw = new BinaryWriter( fs );
bw.Write( (uint)(vertices.Length / 3) );
bw.Write( (uint)faces.Length );
foreach ( var v in vertices )
bw.Write( v );
foreach ( var f in faces )
bw.Write( f );
}
/// <summary>Read binary mesh cache and rebuild PolygonMesh on a GameObject.</summary>
private static bool RestoreMeshFromCache( string cachePath, GameObject go )
{
if ( !File.Exists( cachePath ) ) return false;
try
{
using var fs = new FileStream( cachePath, FileMode.Open );
using var br = new BinaryReader( fs );
var vertCount = br.ReadUInt32();
var faceDataLen = br.ReadUInt32();
var vertices = new Vector3[vertCount];
for ( int i = 0; i < vertCount; i++ )
{
float x = br.ReadSingle();
float y = br.ReadSingle();
float z = br.ReadSingle();
vertices[i] = new Vector3( x, y, z );
}
var faceData = new int[faceDataLen];
for ( int i = 0; i < faceDataLen; i++ )
faceData[i] = br.ReadInt32();
// Parse face groups
var faceGroups = new List<int[]>();
int idx = 0;
while ( idx < faceData.Length )
{
int fvc = faceData[idx++];
if ( idx + fvc > faceData.Length ) break;
var face = new int[fvc];
for ( int i = 0; i < fvc; i++ )
face[i] = faceData[idx++];
faceGroups.Add( face );
}
// Build PolygonMesh
var mesh = new PolygonMesh();
var hVertices = mesh.AddVertices( vertices );
var defaultMat = Material.Load( "materials/dev/reflectivity_30.vmat" );
foreach ( var faceGroup in faceGroups )
{
var faceVerts = faceGroup
.Where( fi => fi >= 0 && fi < hVertices.Length )
.Select( fi => hVertices[fi] )
.ToArray();
if ( faceVerts.Length >= 3 )
{
var hFace = mesh.AddFace( faceVerts );
mesh.SetFaceMaterial( hFace, defaultMat );
}
}
mesh.TextureAlignToGrid( mesh.Transform );
mesh.SetSmoothingAngle( 40.0f );
var existingMr = go.Components.Get<ModelRenderer>();
if ( existingMr != null )
existingMr.Destroy();
var meshComp = go.Components.Get<MeshComponent>();
if ( meshComp == null )
meshComp = go.Components.Create<MeshComponent>();
meshComp.Mesh = mesh;
return true;
}
catch ( Exception ex )
{
BlenderBridgeServer.LogInfo( $"Cache restore failed for {cachePath}: {ex.Message}" );
return false;
}
}
private static void RestoreSubtree( GameObject node, string cacheDir, ref int restored )
{
foreach ( var tag in node.Tags.TryGetAll() )
{
if ( tag.StartsWith( "bridge_" ) && tag != "bridge_group" )
{
var bridgeId = tag.Substring( 7 );
// Only restore if the object has no mesh currently
var meshComp = node.Components.Get<MeshComponent>();
if ( meshComp?.Mesh == null || (meshComp.Mesh.VertexHandles?.Count() ?? 0) == 0 )
{
var cachePath = Path.Combine( cacheDir, $"{bridgeId}.meshcache" );
if ( RestoreMeshFromCache( cachePath, node ) )
restored++;
}
break;
}
}
foreach ( var child in node.Children )
RestoreSubtree( child, cacheDir, ref restored );
}
// ── Path helpers ──────────────────────────────────────────────────────
private static string GetManifestPath( Scene scene )
{
try
{
var scenePath = scene.Source?.ResourcePath;
if ( string.IsNullOrEmpty( scenePath ) ) return null;
var fullPath = Sandbox.FileSystem.Mounted.GetFullPath( scenePath );
if ( string.IsNullOrEmpty( fullPath ) ) return null;
var dir = Path.GetDirectoryName( fullPath );
var name = Path.GetFileNameWithoutExtension( fullPath );
return Path.Combine( dir, $"{name}.bridge.json" );
}
catch
{
return null;
}
}
private static string GetCacheDir( Scene scene )
{
try
{
var assetsDir = BlenderBridgeDispatcher.GetProjectAssetsDir();
if ( assetsDir == null ) return null;
var projectRoot = Path.GetDirectoryName( assetsDir );
if ( projectRoot == null ) return null;
return Path.Combine( projectRoot, CacheDirName );
}
catch
{
return null;
}
}
private static string GetCachePath( Scene scene, string bridgeId )
{
var dir = GetCacheDir( scene );
if ( dir == null ) return null;
return Path.Combine( dir, $"{bridgeId}.meshcache" );
}
}
}