Editor/BlenderBridge/BlenderBridgeDispatcher.cs

Editor-side dispatcher for the Blender Bridge addon. Parses JSON messages from the Blender addon and applies them to the s&box editor scene: creates/updates/deletes objects and lights, handles chunked mesh uploads, syncs scene state, grid changes, material generation and asset registration, and emits outbound change broadcasts.

File AccessNetworkingNative Interop
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Editor;
using Editor.MeshEditor;
using HalfEdgeMesh;
using Sandbox;

namespace BlenderBridge
{
	/// <summary>
	/// Handles incoming bridge messages from Blender v2 and applies them to the s&amp;box scene.
	/// Sequence-based echo prevention, idempotent creates, light support, chunked mesh, hierarchy grouping.
	/// </summary>
	internal static class BlenderBridgeDispatcher
	{
		// ── Sequence state (echo prevention) ──────────────────────────────────

		/// <summary>Highest Blender seq we have processed.</summary>
		private static int _lastBlenderSeqProcessed = 0;

		/// <summary>bridgeId -> Blender seq that caused the last write. Used to suppress echo in PollForChanges.</summary>
		private static Dictionary<string, int> _lastWriteSeq = new();

		// ── Object caches ─────────────────────────────────────────────────────

		/// <summary>O(1) lookup cache: bridgeId -> GameObject. Rebuilt on cache miss via tree walk.</summary>
		private static Dictionary<string, GameObject> _bridgeObjectCache = new();

		/// <summary>Idempotency keys: key -> bridgeId. Prevents duplicate creation on network retry.</summary>
		private static Dictionary<string, string> _idempotencyKeys = new();

		/// <summary>Last-known transforms for change detection.</summary>
		private static Dictionary<string, (Vector3 pos, Rotation rot)> _lastKnown = new();

		/// <summary>Last-known mesh hash for geometry change detection.</summary>
		private static Dictionary<string, int> _lastMeshHash = new();

		/// <summary>Last-known light property hash for change detection.</summary>
		private static Dictionary<string, int> _lastLightHash = new();

		/// <summary>
		/// Last grid spacing we broadcast to Blender (in source units). Used to suppress
		/// the echo when Blender just pushed a grid_changed that we mirrored into
		/// Gizmo.Settings.GridSpacing — the poll loop would otherwise re-broadcast it.
		/// </summary>
		private static float _lastBroadcastGridSpacing = -1f;

		// ── Chunked mesh accumulator ──────────────────────────────────────────

		private static Dictionary<string, MeshAccumulator> _pendingChunks = new();

		private struct MeshAccumulator
		{
			public List<float> Vertices;
			public int TotalVertices;
			public int ChunksReceived;
			public int ChunkCount;
			public DateTime StartTime;
		}

		/// <summary>
		/// Bridge IDs we've told Blender to delete. If one reappears in a scan
		/// (Ctrl+Z restores the GameObject with its tag intact), it must be
		/// re-announced as a creation — silently re-tracking it would leave the
		/// engine streaming updates for an id Blender no longer maps.
		/// </summary>
		private static HashSet<string> _recentlyDeleted = new();

		// ── Scan amortization ─────────────────────────────────────────────────

		private const int MeshHashBudget = 12;
		private static int _scanTick;

		// ── Play mode ─────────────────────────────────────────────────────────

		private static bool _wasPlaying = false;

		/// <summary>Reset all state. Called on server start and hot reload.</summary>
		internal static void ResetState()
		{
			_lastBlenderSeqProcessed = 0;
			_lastWriteSeq.Clear();
			_bridgeObjectCache.Clear();
			_idempotencyKeys.Clear();
			_lastKnown.Clear();
			_lastMeshHash.Clear();
			_lastLightHash.Clear();
			_pendingChunks.Clear();
			_recentlyDeleted.Clear();
			_wasPlaying = false;
			_lastBroadcastGridSpacing = -1f;
			BridgePersistence.ClearPendingSaves();
		}

		// ── Dispatch ──────────────────────────────────────────────────────────

		/// <summary>Handle an incoming message. Returns a JSON response string. Must be called on main thread.</summary>
		internal static string Dispatch( JsonElement root )
		{
			var type = root.TryGetProperty( "type", out var t ) ? t.GetString() : null;

			// Extract and update sequence tracking
			if ( root.TryGetProperty( "seq", out var seqEl ) && seqEl.ValueKind == JsonValueKind.Number )
			{
				var seq = seqEl.GetInt32();
				if ( seq > _lastBlenderSeqProcessed )
					_lastBlenderSeqProcessed = seq;
			}

			try
			{
				return type switch
				{
					"create" => HandleCreate( root ),
					"update_transform" => HandleUpdateTransform( root ),
					"update_mesh" => HandleUpdateMesh( root ),
					"delete" => HandleDelete( root ),
					"set_visibility" => HandleSetVisibility( root ),
					"sync" => HandleSync( root ),
					"update_scene_transform" => HandleUpdateSceneTransform( root ),
					"create_light" => HandleCreateLight( root ),
					"update_light" => HandleUpdateLight( root ),
					"mesh_begin" => HandleMeshBegin( root ),
					"mesh_chunk" => HandleMeshChunk( root ),
					"mesh_end" => HandleMeshEnd( root ),
					"get_project_info" => HandleGetProjectInfo( root ),
					"grid_changed" => HandleGridChanged( root ),
					_ => "{\"ok\":true}"
				};
			}
			catch ( Exception ex )
			{
				BlenderBridgeServer.LogError( $"Dispatch error ({type}): {ex.Message}" );
				// Serialize — ex.Message often contains Windows paths whose
				// backslashes would otherwise produce invalid JSON, making
				// exactly these errors invisible to the addon's error surface.
				return JsonSerializer.Serialize( new { error = ex.Message }, BlenderBridgeServer.JsonOptions );
			}
		}

		// ── create ────────────────────────────────────────────────────────────

		private static string HandleCreate( JsonElement root )
		{
			var name = GetString( root, "name", "Blender Object" );
			var blenderSeq = GetInt( root, "seq", 0 );

			// Idempotency check: if this key was already used, return existing bridgeId
			var idemKey = GetString( root, "idempotencyKey" );
			if ( !string.IsNullOrEmpty( idemKey ) && _idempotencyKeys.TryGetValue( idemKey, out var existingId ) )
			{
				// Verify the object still exists
				var existingGo = FindByBridgeTag( existingId );
				if ( existingGo != null )
				{
					if ( BridgeLockPolicy.AllowsInbound( existingGo ) )
					{
						if ( BridgeLockPolicy.AllowsTransformChange( existingGo ) )
							ApplyTransform( existingGo, root );
						if ( BridgeLockPolicy.AllowsGeometryChange( existingGo ) && root.TryGetProperty( "meshData", out var md ) )
							ApplyMeshData( existingGo, md );
						// Only arm the echo guard when something was actually
						// applied — a lock-rejected retry has no echo to suppress.
						_lastWriteSeq[existingId] = blenderSeq;
						_lastKnown[existingId] = (existingGo.WorldPosition, existingGo.WorldRotation);
						StampAppliedMeshHash( existingId, existingGo );
					}
					return JsonSerializer.Serialize( new { bridgeId = existingId }, BlenderBridgeServer.JsonOptions );
				}
				_idempotencyKeys.Remove( idemKey );
			}

			var scene = BridgeSceneHelper.ResolveScene();
			if ( scene == null ) return "{\"error\":\"no scene\"}";

			// Anti-duplication guard: if a GameObject in this scene already
			// shares this name AND is locked from inbound Blender updates
			// (e.g. a Terrain that was sent as a one-way proxy), assume Blender
			// lost the bridge_id on round-trip and is trying to re-create it.
			// Quietly map the new request onto the existing object instead of
			// spawning a duplicate.
			var nameClash = BridgeSceneHelper
				.WalkAll( scene )
				.FirstOrDefault( g => g.Name == name && !BridgeLockPolicy.AllowsInbound( g ) );
			if ( nameClash != null )
			{
				var existingTag = nameClash.Tags.TryGetAll().FirstOrDefault( IsBridgeIdentityTag );
				var resolvedId = existingTag != null ? existingTag.Substring( 7 ) : "locked";
				BlenderBridgeServer.LogInfo( $"Suppressed duplicate create for locked '{name}' (re-binding to {resolvedId})" );
				// Locked object, nothing applied — no echo to suppress, so the
				// guard stays unarmed.
				if ( !string.IsNullOrEmpty( idemKey ) )
					_idempotencyKeys[idemKey] = resolvedId;
				return JsonSerializer.Serialize( new { bridgeId = resolvedId, locked = true }, BlenderBridgeServer.JsonOptions );
			}

			// Honor a Blender-minted ID. The .blend is the durable side of the
			// bridge — engine-minted IDs died with every unsaved engine scene,
			// reissuing identity on each reconnect and orphaning mesh caches.
			// If the id already exists here, treat the create as an idempotent
			// upsert so re-sends after a session mismatch converge onto the
			// existing object instead of duplicating it.
			var requestedId = GetString( root, "bridgeId" );
			if ( IsValidClientBridgeId( requestedId ) )
			{
				var existingByReq = FindByBridgeTag( requestedId );
				if ( existingByReq != null )
				{
					if ( BridgeLockPolicy.AllowsInbound( existingByReq ) )
					{
						if ( BridgeLockPolicy.AllowsTransformChange( existingByReq ) )
							ApplyTransform( existingByReq, root );
						if ( BridgeLockPolicy.AllowsGeometryChange( existingByReq ) && root.TryGetProperty( "meshData", out var upsertMd ) )
							ApplyMeshData( existingByReq, upsertMd );
						_lastWriteSeq[requestedId] = blenderSeq;
						_lastKnown[requestedId] = (existingByReq.WorldPosition, existingByReq.WorldRotation);
						StampAppliedMeshHash( requestedId, existingByReq );
						BridgePersistence.SaveAfterChange( scene, requestedId, existingByReq );
					}
					if ( !string.IsNullOrEmpty( idemKey ) )
						_idempotencyKeys[idemKey] = requestedId;
					BlenderBridgeServer.LogInfo( $"Upserted '{name}' onto existing {requestedId}" );
					return JsonSerializer.Serialize( new { bridgeId = requestedId }, BlenderBridgeServer.JsonOptions );
				}
			}

			var bridgeId = IsValidClientBridgeId( requestedId )
				? requestedId
				: "b_" + Guid.NewGuid().ToString( "N" ).Substring( 0, 8 );

			// Resolve hierarchy: Blender Bridge > [collection path] > object
			var parent = GetOrCreateBridgeGroup( scene );
			if ( root.TryGetProperty( "hierarchy", out var hierEl ) && hierEl.ValueKind == JsonValueKind.Array )
			{
				foreach ( var h in hierEl.EnumerateArray() )
				{
					var hName = h.GetString();
					if ( !string.IsNullOrEmpty( hName ) )
						parent = GetOrCreateChild( parent, hName );
				}
			}

			var go = scene.CreateObject();
			go.Name = name;
			go.Parent = parent;
			ApplyTransform( go, root );

			if ( root.TryGetProperty( "meshData", out var meshData ) )
				ApplyMeshData( go, meshData );

			go.Tags.Add( $"bridge_{bridgeId}" );
			// A re-created id must not linger in the deleted set — the scan
			// would re-announce an object Blender just sent us.
			_recentlyDeleted.Remove( bridgeId );
			_bridgeObjectCache[bridgeId] = go;
			_lastWriteSeq[bridgeId] = blenderSeq;
			_lastKnown[bridgeId] = (go.WorldPosition, go.WorldRotation);
			StampAppliedMeshHash( bridgeId, go );
			if ( !string.IsNullOrEmpty( idemKey ) )
				_idempotencyKeys[idemKey] = bridgeId;

			BlenderBridgeServer.LogInfo( $"Created '{name}' as {bridgeId}" );
			BridgePersistence.SaveAfterChange( scene, bridgeId, go );
			return JsonSerializer.Serialize( new { bridgeId }, BlenderBridgeServer.JsonOptions );
		}

		// ── update_transform ──────────────────────────────────────────────────

		private static string HandleUpdateTransform( JsonElement root )
		{
			var bridgeId = GetString( root, "bridgeId" );
			if ( string.IsNullOrEmpty( bridgeId ) ) return "{\"error\":\"missing bridgeId\"}";
			var blenderSeq = GetInt( root, "seq", 0 );

			var go = FindByBridgeTag( bridgeId );
			if ( go == null ) return "{\"error\":\"not found\"}";

			// Lock-rejected: nothing was applied, so there is no echo to
			// suppress — arming the guard here would swallow the next real
			// s&box-side edit instead.
			if ( !BridgeLockPolicy.AllowsTransformChange( go ) )
				return "{\"ok\":true,\"locked\":true}";

			ApplyTransform( go, root );
			_lastWriteSeq[bridgeId] = blenderSeq;
			_lastKnown[bridgeId] = (go.WorldPosition, go.WorldRotation);

			return "{\"ok\":true}";
		}

		// ── update_mesh ───────────────────────────────────────────────────────

		// All lock decisions go through BridgeLockPolicy. The legacy bridge_locked
		// tag is still recognized there for backward compatibility, and Terrain
		// components are auto-locked (Inbound | Geometry | Materials) so the
		// one-way proxy mesh never clobbers the heightmap-driven terrain.
		internal const string LockTag = BridgeLockPolicy.LegacyLockTag;

		internal static bool IsLockedFromBlender( GameObject go )
			=> !BridgeLockPolicy.AllowsInbound( go );

		private static string HandleUpdateMesh( JsonElement root )
		{
			var bridgeId = GetString( root, "bridgeId" );
			if ( string.IsNullOrEmpty( bridgeId ) ) return "{\"error\":\"missing bridgeId\"}";
			var blenderSeq = GetInt( root, "seq", 0 );

			var go = FindByBridgeTag( bridgeId );
			if ( go == null ) return "{\"error\":\"not found\"}";

			// Lock-rejected: nothing applied, no echo to suppress — do not arm
			// the guard (see HandleUpdateTransform).
			if ( !BridgeLockPolicy.AllowsInbound( go ) )
				return "{\"ok\":true,\"locked\":true}";

			if ( BridgeLockPolicy.AllowsTransformChange( go ) )
				ApplyTransform( go, root );

			if ( BridgeLockPolicy.AllowsGeometryChange( go ) && root.TryGetProperty( "meshData", out var meshData ) )
				ApplyMeshData( go, meshData );

			_lastWriteSeq[bridgeId] = blenderSeq;
			_lastKnown[bridgeId] = (go.WorldPosition, go.WorldRotation);
			StampAppliedMeshHash( bridgeId, go );

			var scene = BridgeSceneHelper.ResolveScene();
			if ( scene != null )
				BridgePersistence.SaveAfterChange( scene, bridgeId, go );

			return "{\"ok\":true}";
		}

		/// <summary>
		/// Record the hash of a mesh we JUST applied from a Blender write.
		/// Mandatory with amortized scanning: the round-robin check may not
		/// reach this object until many ticks after the echo guard was
		/// consumed, and without the stamp our own write would read as a
		/// user edit and get broadcast back to Blender.
		/// </summary>
		private static void StampAppliedMeshHash( string bridgeId, GameObject go )
		{
			var mesh = go?.Components.Get<MeshComponent>()?.Mesh;
			if ( mesh != null )
				_lastMeshHash[bridgeId] = ComputeMeshGeometryHash( mesh );
		}

		// ── delete ────────────────────────────────────────────────────────────

		private static string HandleDelete( JsonElement root )
		{
			var bridgeId = GetString( root, "bridgeId" );
			if ( string.IsNullOrEmpty( bridgeId ) ) return "{\"error\":\"missing bridgeId\"}";

			var go = FindByBridgeTag( bridgeId );
			if ( go != null )
			{
				go.Destroy();
				// If Ctrl+Z later restores this GameObject, the scan must
				// re-announce it — Blender no longer maps this id.
				_recentlyDeleted.Add( bridgeId );
			}

			_bridgeObjectCache.Remove( bridgeId );
			_lastKnown.Remove( bridgeId );
			_lastWriteSeq.Remove( bridgeId );
			_lastMeshHash.Remove( bridgeId );
			_idempotencyKeys.Where( kv => kv.Value == bridgeId ).Select( kv => kv.Key ).ToList()
				.ForEach( k => _idempotencyKeys.Remove( k ) );

			BridgePersistence.RemoveFromCache( bridgeId );
			BlenderBridgeServer.LogInfo( $"Deleted {bridgeId}" );
			return "{\"ok\":true}";
		}

		// ── set_visibility ────────────────────────────────────────────────────

		/// <summary>Blender hid/unhid an object — toggle Enabled instead of
		/// destroying, so the GameObject keeps its components and materials.</summary>
		private static string HandleSetVisibility( JsonElement root )
		{
			var bridgeId = GetString( root, "bridgeId" );
			if ( string.IsNullOrEmpty( bridgeId ) ) return "{\"error\":\"missing bridgeId\"}";

			var go = FindByBridgeTag( bridgeId );
			if ( go == null ) return JsonSerializer.Serialize( new { error = $"'{bridgeId}' not found" }, BlenderBridgeServer.JsonOptions );

			if ( !BridgeLockPolicy.AllowsInbound( go ) )
				return "{\"ok\":true,\"locked\":true}";

			var visible = !root.TryGetProperty( "visible", out var v ) || v.ValueKind != JsonValueKind.False;
			go.Enabled = visible;
			BlenderBridgeServer.LogInfo( $"{(visible ? "Enabled" : "Disabled")} {bridgeId}" );
			return "{\"ok\":true}";
		}

		// ── create_light ──────────────────────────────────────────────────────

		private static string HandleCreateLight( JsonElement root )
		{
			var name = GetString( root, "name", "Blender Light" );
			var lightType = GetString( root, "lightType", "point" );
			var blenderSeq = GetInt( root, "seq", 0 );

			// Idempotency
			var idemKey = GetString( root, "idempotencyKey" );
			if ( !string.IsNullOrEmpty( idemKey ) && _idempotencyKeys.TryGetValue( idemKey, out var existingId ) )
			{
				var existingGo = FindByBridgeTag( existingId );
				if ( existingGo != null )
				{
					// Same lock policy as the mesh handlers — the idempotent
					// re-apply must not be a side door around "Lock In".
					if ( BridgeLockPolicy.AllowsInbound( existingGo ) )
					{
						if ( BridgeLockPolicy.AllowsTransformChange( existingGo ) )
							ApplyTransform( existingGo, root );
						ApplyLightProperties( existingGo, root );
						_lastWriteSeq[existingId] = blenderSeq;
						_lastKnown[existingId] = (existingGo.WorldPosition, existingGo.WorldRotation);
					}
					return JsonSerializer.Serialize( new { bridgeId = existingId }, BlenderBridgeServer.JsonOptions );
				}
				_idempotencyKeys.Remove( idemKey );
			}

			// Honor a Blender-minted ID; upsert if the id already exists here
			// (same rationale as HandleCreate).
			var requestedId = GetString( root, "bridgeId" );
			if ( IsValidClientBridgeId( requestedId ) )
			{
				var existingByReq = FindByBridgeTag( requestedId );
				if ( existingByReq != null )
				{
					if ( BridgeLockPolicy.AllowsInbound( existingByReq ) )
					{
						if ( BridgeLockPolicy.AllowsTransformChange( existingByReq ) )
							ApplyTransform( existingByReq, root );
						ApplyLightProperties( existingByReq, root );
						_lastWriteSeq[requestedId] = blenderSeq;
						_lastKnown[requestedId] = (existingByReq.WorldPosition, existingByReq.WorldRotation);
					}
					if ( !string.IsNullOrEmpty( idemKey ) )
						_idempotencyKeys[idemKey] = requestedId;
					return JsonSerializer.Serialize( new { bridgeId = requestedId }, BlenderBridgeServer.JsonOptions );
				}
			}

			var bridgeId = IsValidClientBridgeId( requestedId )
				? requestedId
				: "b_" + Guid.NewGuid().ToString( "N" ).Substring( 0, 8 );

			var scene = BridgeSceneHelper.ResolveScene();
			if ( scene == null ) return "{\"error\":\"no scene\"}";

			var parent = GetOrCreateBridgeGroup( scene );
			var go = scene.CreateObject();
			go.Name = name;
			go.Parent = parent;
			ApplyTransform( go, root );

			// Create appropriate light component
			switch ( lightType )
			{
				case "spot":
					go.Components.Create<SpotLight>();
					break;
				case "directional":
					go.Components.Create<DirectionalLight>();
					break;
				default:
					go.Components.Create<PointLight>();
					break;
			}

			ApplyLightProperties( go, root );

			go.Tags.Add( $"bridge_{bridgeId}" );
			_recentlyDeleted.Remove( bridgeId );
			_bridgeObjectCache[bridgeId] = go;
			_lastWriteSeq[bridgeId] = blenderSeq;
			_lastKnown[bridgeId] = (go.WorldPosition, go.WorldRotation);
			if ( !string.IsNullOrEmpty( idemKey ) )
				_idempotencyKeys[idemKey] = bridgeId;

			BlenderBridgeServer.LogInfo( $"Created light '{name}' as {bridgeId} ({lightType})" );
			return JsonSerializer.Serialize( new { bridgeId }, BlenderBridgeServer.JsonOptions );
		}

		// ── update_light ──────────────────────────────────────────────────────

		private static string HandleUpdateLight( JsonElement root )
		{
			var bridgeId = GetString( root, "bridgeId" );
			if ( string.IsNullOrEmpty( bridgeId ) ) return "{\"error\":\"missing bridgeId\"}";
			var blenderSeq = GetInt( root, "seq", 0 );

			var go = FindByBridgeTag( bridgeId );
			if ( go == null ) return "{\"error\":\"not found\"}";

			// Same lock policy as every other mutation handler — "Lock In"
			// must cover lights too. Nothing applied, so the echo guard
			// stays unarmed.
			if ( !BridgeLockPolicy.AllowsInbound( go ) )
				return "{\"ok\":true,\"locked\":true}";

			if ( BridgeLockPolicy.AllowsTransformChange( go ) )
				ApplyTransform( go, root );
			ApplyLightProperties( go, root );
			_lastWriteSeq[bridgeId] = blenderSeq;
			_lastKnown[bridgeId] = (go.WorldPosition, go.WorldRotation);

			return "{\"ok\":true}";
		}

		// ── Chunked mesh handlers ─────────────────────────────────────────────

		private static string HandleMeshBegin( JsonElement root )
		{
			var bridgeId = GetString( root, "bridgeId" );
			if ( string.IsNullOrEmpty( bridgeId ) ) return "{\"error\":\"missing bridgeId\"}";

			var totalVerts = GetInt( root, "totalVertices", 0 );
			var chunkCount = GetInt( root, "chunkCount", 0 );

			_pendingChunks[bridgeId] = new MeshAccumulator
			{
				Vertices = new List<float>( totalVerts * 3 ),
				TotalVertices = totalVerts,
				ChunksReceived = 0,
				ChunkCount = chunkCount,
				StartTime = DateTime.UtcNow,
			};

			return "{\"ok\":true}";
		}

		private static string HandleMeshChunk( JsonElement root )
		{
			var bridgeId = GetString( root, "bridgeId" );
			if ( string.IsNullOrEmpty( bridgeId ) ) return "{\"error\":\"missing bridgeId\"}";

			if ( !_pendingChunks.TryGetValue( bridgeId, out var accum ) )
				return "{\"error\":\"no pending mesh_begin\"}";

			if ( root.TryGetProperty( "vertices", out var vertsEl ) )
			{
				foreach ( var v in vertsEl.EnumerateArray() )
					accum.Vertices.Add( v.GetSingle() );
			}

			accum.ChunksReceived++;
			_pendingChunks[bridgeId] = accum;
			return "{\"ok\":true}";
		}

		private static string HandleMeshEnd( JsonElement root )
		{
			var bridgeId = GetString( root, "bridgeId" );
			if ( string.IsNullOrEmpty( bridgeId ) ) return "{\"error\":\"missing bridgeId\"}";
			var blenderSeq = GetInt( root, "seq", 0 );

			if ( !_pendingChunks.TryGetValue( bridgeId, out var accum ) )
				return "{\"error\":\"no pending mesh_begin\"}";

			_pendingChunks.Remove( bridgeId );

			// Validate stream completeness before applying. A dropped chunk
			// shifts every later vertex — faces then resolve to the wrong
			// vertices and the scrambled mesh would be persisted to the mesh
			// cache while Blender, having stored the geometry hash at stream
			// start, considers it synced and never resends.
			var receivedVerts = accum.Vertices.Count / 3;
			if ( (accum.ChunkCount > 0 && accum.ChunksReceived != accum.ChunkCount)
				|| (accum.TotalVertices > 0 && receivedVerts != accum.TotalVertices) )
			{
				var detail = $"incomplete mesh stream for {bridgeId}: "
					+ $"{accum.ChunksReceived}/{accum.ChunkCount} chunks, "
					+ $"{receivedVerts}/{accum.TotalVertices} vertices";
				BlenderBridgeServer.LogError( detail );
				return JsonSerializer.Serialize( new { error = detail }, BlenderBridgeServer.JsonOptions );
			}

			// Build a complete meshData JsonElement from accumulated vertices + face data from this message
			var vertArray = accum.Vertices;

			// Parse faces from this message
			var faces = new List<int>();
			if ( root.TryGetProperty( "faces", out var facesEl ) )
				foreach ( var f in facesEl.EnumerateArray() )
					faces.Add( f.GetInt32() );

			int[] faceMaterials = null;
			if ( root.TryGetProperty( "faceMaterials", out var fmEl ) )
			{
				var fmList = new List<int>();
				foreach ( var fm in fmEl.EnumerateArray() )
					fmList.Add( fm.GetInt32() );
				faceMaterials = fmList.ToArray();
			}

			List<MaterialDef> materials = null;
			if ( root.TryGetProperty( "materials", out var matsEl ) )
				materials = ParseMaterialDefs( matsEl );

			// Build ParsedMesh
			var vertCount = vertArray.Count / 3;
			var vertices = new Vector3[vertCount];
			for ( int i = 0; i < vertCount; i++ )
				vertices[i] = new Vector3( vertArray[i * 3], vertArray[i * 3 + 1], vertArray[i * 3 + 2] );

			var faceGroups = new List<int[]>();
			int idx = 0;
			while ( idx < faces.Count )
			{
				int fvc = faces[idx++];
				if ( idx + fvc > faces.Count ) break;
				var face = new int[fvc];
				for ( int i = 0; i < fvc; i++ )
					face[i] = faces[idx++];
				faceGroups.Add( face );
			}

			// Blender-authored UVs ride on mesh_end in the same flat format as
			// update_mesh. Without parsing them here, every chunked (20k+ vertex)
			// mesh silently loses its UV layout to the grid-align fallback.
			List<Vector2[]> faceUVs = null;
			if ( root.TryGetProperty( "faceUVs", out var uvEl ) )
				faceUVs = ParseFaceUVs( uvEl, faceGroups );

			var parsed = new ParsedMesh
			{
				Vertices = vertices,
				FaceGroups = faceGroups,
				FaceMaterials = faceMaterials,
				Materials = materials,
				FaceUVs = faceUVs
			};

			// Find the object and apply mesh
			var go = FindByBridgeTag( bridgeId );
			if ( go == null )
				return "{\"error\":\"not found\"}";

			// Same lock policy as update_mesh — the chunked path must not be a
			// side door around per-object locks. Nothing applied, so do not arm
			// the echo guard.
			if ( !BridgeLockPolicy.AllowsInbound( go ) )
				return "{\"ok\":true,\"locked\":true}";

			if ( BridgeLockPolicy.AllowsTransformChange( go ) )
				ApplyTransform( go, root );

			if ( BridgeLockPolicy.AllowsGeometryChange( go ) )
				ApplyParsedMeshData( go, parsed );

			_lastWriteSeq[bridgeId] = blenderSeq;
			_lastKnown[bridgeId] = (go.WorldPosition, go.WorldRotation);
			StampAppliedMeshHash( bridgeId, go );

			var scene = BridgeSceneHelper.ResolveScene();
			if ( scene != null )
				BridgePersistence.SaveAfterChange( scene, bridgeId, go );

			BlenderBridgeServer.LogInfo( $"Chunked mesh assembled for {bridgeId} ({vertCount} verts)" );
			return "{\"ok\":true}";
		}

		// ── update_scene_transform ────────────────────────────────────────────

		private static string HandleUpdateSceneTransform( JsonElement root )
		{
			var sceneId = GetString( root, "sceneId" );
			if ( string.IsNullOrEmpty( sceneId ) ) return "{\"error\":\"missing sceneId\"}";
			var blenderSeq = GetInt( root, "seq", 0 );

			if ( !Guid.TryParse( sceneId, out var guid ) ) return "{\"error\":\"invalid sceneId\"}";

			var scene = BridgeSceneHelper.ResolveScene();
			if ( scene == null ) return "{\"error\":\"no scene\"}";

			GameObject go = null;
			foreach ( var root2 in scene.Children )
			{
				go = SearchTree( root2, g => g.Id == guid );
				if ( go != null ) break;
			}
			if ( go == null ) return "{\"error\":\"not found\"}";

			ApplyTransform( go, root );

			var key = $"scene_{sceneId}";
			_lastWriteSeq[key] = blenderSeq;
			_lastKnown[key] = (go.WorldPosition, go.WorldRotation);

			return "{\"ok\":true}";
		}

		// ── grid_changed ──────────────────────────────────────────────────────

		private static string HandleGridChanged( JsonElement root )
		{
			var gridSize = GetInt( root, "gridSize", 0 );
			if ( gridSize <= 0 ) return "{\"ok\":true}";

			// GridSpacing range is [0.125, 128]. Bridge range is [1, 256].
			// Clamp at the engine ceiling; the bridge property keeps the original value
			// so users see what they asked for, but the engine grid can't go above 128.
			float spacing = gridSize > 128 ? 128f : gridSize;
			_lastBroadcastGridSpacing = spacing;
			try
			{
				// EditorScene.GizmoSettings is the editor's live settings object — the
				// scene viewport assigns this exact instance into its GizmoInstance, and
				// the editor's own grid menu/[ ]/snap widgets read and write it.
				// Gizmo.Settings must NOT be used here: it resolves Active?.Settings,
				// and Gizmo.Active only exists inside a gizmo scope — from the
				// dispatcher it is null, so writes through it silently do nothing.
				EditorScene.GizmoSettings.GridSpacing = spacing;
			}
			catch ( Exception ex )
			{
				BlenderBridgeServer.LogInfo( $"grid_changed apply skipped: {ex.Message}" );
			}
			return "{\"ok\":true}";
		}

		/// <summary>
		/// Broadcast the editor's grid spacing to Blender as grid_updated and stamp
		/// the echo guard so the poll loop doesn't re-send the same value.
		/// </summary>
		private static void BroadcastGridSpacing( float spacing )
		{
			_lastBroadcastGridSpacing = spacing;
			int gridSize = (int)MathF.Round( spacing );
			if ( gridSize < 1 ) gridSize = 1;
			if ( gridSize > 256 ) gridSize = 256;
			BlenderBridgeServer.BroadcastWithSeq( new
			{
				type = "grid_updated",
				gridSize
			} );
		}

		// ── sync ──────────────────────────────────────────────────────────────

		private static string HandleSync( JsonElement root )
		{
			var scene = BridgeSceneHelper.ResolveScene();
			if ( scene == null ) return "{\"ok\":true}";

			// Parse what Blender knows about
			var blenderKnown = new HashSet<string>();
			if ( root.TryGetProperty( "knownObjects", out var known ) )
			{
				foreach ( var item in known.EnumerateArray() )
				{
					var bid = item.TryGetProperty( "bridgeId", out var b ) ? b.GetString() : null;
					if ( bid != null ) blenderKnown.Add( bid );
				}
			}

			var bridgeObjects = FindAllBridgeObjects( scene );
			var sboxIds = new HashSet<string>( bridgeObjects.Select( x => x.bridgeId ) );
			var objects = new List<object>();

			// Sync re-baselines _lastKnown to the objects' current state below,
			// so any armed echo guard is stale — clearing here means the first
			// edit after a manual sync always broadcasts instead of being
			// swallowed by a leftover guard.
			_lastWriteSeq.Clear();

			foreach ( var (bridgeId, go) in bridgeObjects )
			{
				_lastKnown[bridgeId] = (go.WorldPosition, go.WorldRotation);
				if ( !BridgeLockPolicy.AllowsOutbound( go ) ) continue;
				objects.Add( BuildObjectPayload( bridgeId, go ) );
			}

			// Include scene lights, models, and native MeshComponents
			foreach ( var go in BridgeSceneHelper.WalkAll( scene, true ) )
			{
				if ( go.Tags.TryGetAll().Any( tag => tag.StartsWith( "bridge_" ) ) )
					continue;

				var light = go.Components.GetAll().FirstOrDefault( c => c is Light ) as Light;
				if ( light != null )
				{
					objects.Add( BuildLightPayload( go, light ) );
					continue;
				}

				// Native MeshComponents placed in s&box — adopt as bridge objects
				var meshComp = go.Components.Get<MeshComponent>();
				if ( meshComp?.Mesh != null )
				{
					var adoptId = "b_" + Guid.NewGuid().ToString( "N" ).Substring( 0, 8 );
					go.Tags.Add( $"bridge_{adoptId}" );
					_bridgeObjectCache[adoptId] = go;
					_lastKnown[adoptId] = (go.WorldPosition, go.WorldRotation);
					objects.Add( BuildObjectPayload( adoptId, go ) );
					sboxIds.Add( adoptId );
					continue;
				}

				var anyModel = go.Components.GetAll()
					.FirstOrDefault( c => c.GetType().Name.Contains( "ModelRenderer" ) );
				if ( anyModel != null )
					objects.Add( BuildModelPayload( go, anyModel ) );
			}

			// Tell Blender to remove objects it has that s&box doesn't.
			// Only when the resolved scene is the one the change-scanner has
			// settled on — during a tab switch Blender's list reflects another
			// scene, and emitting deletes here would wipe the Blender side.
			if ( ReferenceEquals( scene, _lastScannedScene ) )
			{
				var staleInBlender = blenderKnown.Except( sboxIds );
				foreach ( var staleId in staleInBlender )
					objects.Add( new { type = "deleted", bridgeId = staleId } );
			}
			else
			{
				BlenderBridgeServer.LogInfo( "Sync: scene not settled — skipped stale-object reconciliation" );
			}

			BlenderBridgeServer.BroadcastWithSeq( new { type = "sync_response", objects } );

			// Grid handshake: Blender requests sync on connect, reconnect, and session
			// change. Push the editor's current grid spacing alongside the response so
			// both sides start aligned — change-detection alone never fires for a
			// client that reconnected after the last change.
			try
			{
				BroadcastGridSpacing( EditorScene.GizmoSettings.GridSpacing );
			}
			catch { }

			BlenderBridgeServer.LogInfo( $"Sync: {bridgeObjects.Count} bridge, {objects.Count} total" );
			return "{\"ok\":true}";
		}

		// ── Poll for s&box-side changes ───────────────────────────────────────

		internal static void PollForChanges()
		{
			try
			{
				PollForChangesInternal();
			}
			catch ( Exception ex )
			{
				BlenderBridgeServer.LogInfo( $"Poll cycle skipped: {ex.Message}" );
			}
		}

		// Last observed Game.IsPlaying, stamped on the main thread each scan tick.
		// Read by HandlePoll (background thread) so every poll response carries
		// play-mode ground truth — Blender syncs its play flag level-triggered
		// instead of trusting edge-triggered messages that can be missed.
		internal static bool IsPlayingSnapshot;

		// Scene the change-scanner ran against last tick. ResolveScene() follows
		// the ACTIVE editor tab — with multiple scenes open, a tab switch makes
		// every tracked object "vanish" from the scan and the deletion detector
		// would broadcast deletes for all of them (Blender then wipes its side).
		private static Scene _lastScannedScene;

		private static void PollForChangesInternal()
		{
			var scene = BridgeSceneHelper.ResolveScene();
			if ( scene == null ) return;

			if ( !ReferenceEquals( scene, _lastScannedScene ) )
			{
				bool firstScan = _lastScannedScene == null;
				_lastScannedScene = scene;
				if ( !firstScan )
				{
					_lastKnown.Clear();
					_lastMeshHash.Clear();
					_bridgeObjectCache.Clear();
					// Echo guards belong to the old scene's write history — a
					// stale armed key would swallow the first real edit made to
					// a same-ID object after switching back.
					_lastWriteSeq.Clear();
					BlenderBridgeServer.LogInfo( $"Scene changed to '{scene.Name}' — bridge tracking reset, deletes suppressed this tick" );
					return;
				}
			}

			// Play mode detection
			bool isPlaying = Game.IsPlaying;
			IsPlayingSnapshot = isPlaying;
			if ( isPlaying != _wasPlaying )
			{
				_wasPlaying = isPlaying;
				BlenderBridgeServer.BroadcastWithSeq( new
				{
					type = "play_mode",
					state = isPlaying ? "started" : "stopped"
				} );

				if ( isPlaying )
				{
					// Entering play mode wipes editor meshes — everything
					// queued must hit disk NOW or the restore has stale data.
					try { BridgePersistence.FlushPendingSaves( scene, force: true ); }
					catch { }
				}

				if ( !isPlaying )
				{
					// Exiting play mode — restore cached meshes
					try { BridgePersistence.RestoreFromCache( scene ); }
					catch { }
				}
			}

			// Clean up timed-out chunk accumulators
			var timedOut = _pendingChunks
				.Where( kv => (DateTime.UtcNow - kv.Value.StartTime).TotalSeconds > 30 )
				.Select( kv => kv.Key ).ToList();
			foreach ( var key in timedOut )
				_pendingChunks.Remove( key );

			// Grid-spacing change detection: if the editor user changed the grid spacing
			// (menu, [ ] shortcuts, snap widget), broadcast it to Blender.
			// _lastBroadcastGridSpacing is stamped on inbound grid_changed too, so a
			// bridge-driven write doesn't echo back. Read EditorScene.GizmoSettings —
			// NOT Gizmo.Settings, which is null outside a gizmo scope.
			try
			{
				float spacing = EditorScene.GizmoSettings.GridSpacing;
				if ( MathF.Abs( spacing - _lastBroadcastGridSpacing ) > 0.001f )
					BroadcastGridSpacing( spacing );
			}
			catch { }

			var bridgeObjects = FindAllBridgeObjects( scene );
			var currentIds = new HashSet<string>();

			// Mesh-hash amortization: hashing every mesh every tick is
			// per-vertex work × object count × 5Hz on the editor main thread —
			// the scan's scale cliff. Round-robin so at most MeshHashBudget
			// objects hash per tick; each object is still checked every
			// ceil(n/budget) ticks (~1s at 60 objects). Safe because every
			// mesh-apply handler stamps _lastMeshHash itself, so a delayed
			// check can never mistake our own write for a user edit after the
			// echo guard was consumed.
			_scanTick++;
			int hashSlices = Math.Max( 1, (bridgeObjects.Count + MeshHashBudget - 1) / MeshHashBudget );
			int objIdx = -1;

			foreach ( var (bridgeId, go) in bridgeObjects )
			{
				objIdx++;
				if ( go == null || !go.IsValid ) continue;

				// Editor-side duplication (shift-drag, Ctrl+D) copies the GameObject
				// together with its bridge tag, leaving two objects claiming one ID.
				// The scan then flip-flops _lastKnown between their transforms and
				// broadcasts an "updated" storm every tick — which also keeps
				// Blender's echo-suppression window open so Blender->engine moves
				// stop applying. Re-tag the copy with a fresh ID and announce it
				// to Blender as a new object.
				if ( !currentIds.Add( bridgeId ) )
				{
					go.Tags.Remove( $"bridge_{bridgeId}" );
					var dupId = "b_" + Guid.NewGuid().ToString( "N" ).Substring( 0, 8 );
					go.Tags.Add( $"bridge_{dupId}" );
					currentIds.Add( dupId );
					_bridgeObjectCache.Remove( bridgeId ); // may point at the re-tagged copy
					_bridgeObjectCache[dupId] = go;
					_lastKnown[dupId] = (go.WorldPosition, go.WorldRotation);

					var dupMeshComp = go.Components.Get<MeshComponent>();
					if ( dupMeshComp?.Mesh != null )
					{
						_lastMeshHash[dupId] = ComputeMeshGeometryHash( dupMeshComp.Mesh );
						var dupExtracted = ExtractMeshData( dupMeshComp.Mesh );
						object dupMd = null;
						if ( dupExtracted != null )
							dupMd = new { vertices = dupExtracted.Value.Vertices, faces = dupExtracted.Value.Faces };
						var dupPos = go.WorldPosition;
						var dupRot = go.WorldRotation.Angles();
						// sourceBridgeId: the wire meshData is geometry-only, so
						// Blender clones materials/UVs from its own copy of the
						// source object instead of creating the dup bare — a bare
						// dup used to echo a material-less mesh update back and
						// wipe THIS object's textures.
						BlenderBridgeServer.BroadcastWithSeq( new
						{
							type = "object_created",
							bridgeId = dupId,
							sourceBridgeId = bridgeId,
							name = go.Name,
							position = new { x = dupPos.x, y = dupPos.y, z = dupPos.z },
							rotation = new { pitch = dupRot.pitch, yaw = dupRot.yaw, roll = dupRot.roll },
							meshData = dupMd
						} );
					}
					BlenderBridgeServer.LogInfo( $"Duplicated {bridgeId} re-tagged as {dupId}" );
					continue;
				}

				// Undo of a delete restores the GameObject with its bridge tag
				// intact — Blender was already told to delete this id, so
				// re-announce it as a creation instead of silently re-tracking.
				if ( _recentlyDeleted.Remove( bridgeId ) )
				{
					_bridgeObjectCache[bridgeId] = go;
					_lastKnown[bridgeId] = (go.WorldPosition, go.WorldRotation);
					var undoMesh = go.Components.Get<MeshComponent>();
					if ( undoMesh?.Mesh != null )
						_lastMeshHash[bridgeId] = ComputeMeshGeometryHash( undoMesh.Mesh );
					BlenderBridgeServer.BroadcastWithSeq( BuildObjectCreatedMessage( bridgeId, go ) );
					BlenderBridgeServer.LogInfo( $"Restored {bridgeId} ('{go.Name}') — re-announced to Blender" );
					continue;
				}

				// Outbound lock: skip pushing changes to Blender for this object,
				// but keep tracking it (so deletes still propagate, etc).
				if ( !BridgeLockPolicy.AllowsOutbound( go ) )
				{
					_lastKnown[bridgeId] = (go.WorldPosition, go.WorldRotation);
					continue;
				}

				var pos = go.WorldPosition;
				var rot = go.WorldRotation;

				bool posChanged = false;
				bool rotChanged = false;

				if ( _lastKnown.TryGetValue( bridgeId, out var prev ) )
				{
					posChanged = MathF.Abs( pos.x - prev.pos.x ) > 0.01f
						|| MathF.Abs( pos.y - prev.pos.y ) > 0.01f
						|| MathF.Abs( pos.z - prev.pos.z ) > 0.01f;
					rotChanged = !rot.Equals( prev.rot );
				}

				// Check mesh changes — hash vertex positions so moves/pulls are
				// detected. Only this object's round-robin slice hashes this tick.
				bool meshChanged = false;
				var meshComp = go.Components.Get<MeshComponent>();
				if ( meshComp?.Mesh != null && (objIdx % hashSlices) == (_scanTick % hashSlices) )
				{
					var meshHash = ComputeMeshGeometryHash( meshComp.Mesh );
					if ( _lastMeshHash.TryGetValue( bridgeId, out var prevHash ) && prevHash != meshHash )
						meshChanged = true;
					_lastMeshHash[bridgeId] = meshHash;
				}

				// Echo suppression: consume the guard every tick, delta or not.
				// Handlers stamp _lastKnown from the object AFTER applying a
				// Blender write, so the echo usually produces no delta here —
				// consuming only inside the delta branch leaves the key armed
				// indefinitely, where it silently swallows the user's next
				// genuine s&box edit (undo, typed coordinate, snap). Dispatch
				// and poll are serialized on the main thread, so any echo delta
				// that does surface (mesh hash, transform quantization) is
				// visible by the tick right after the write — a one-tick
				// lifetime is enough.
				bool suppressEcho = _lastWriteSeq.Remove( bridgeId );

				if ( (posChanged || rotChanged || meshChanged) && !suppressEcho )
				{
					var angles = rot.Angles();
					if ( meshChanged )
					{
						var extracted = ExtractMeshData( meshComp.Mesh );
						object md = null;
						if ( extracted != null )
							md = new { vertices = extracted.Value.Vertices, faces = extracted.Value.Faces };

						BlenderBridgeServer.BroadcastWithSeq( new
						{
							type = "mesh_updated",
							bridgeId,
							position = new { x = pos.x, y = pos.y, z = pos.z },
							rotation = new { pitch = angles.pitch, yaw = angles.yaw, roll = angles.roll },
							meshData = md
						} );
					}
					else
					{
						BlenderBridgeServer.BroadcastWithSeq( new
						{
							type = "updated",
							bridgeId,
							position = new { x = pos.x, y = pos.y, z = pos.z },
							rotation = new { pitch = angles.pitch, yaw = angles.yaw, roll = angles.roll }
						} );
					}
				}

				_lastKnown[bridgeId] = (pos, rot);
			}

			// Detect deletions
			foreach ( var oldId in _lastKnown.Keys.ToList() )
			{
				if ( oldId.StartsWith( "scene_" ) ) continue;
				if ( !currentIds.Contains( oldId ) )
				{
					_lastKnown.Remove( oldId );
					_lastWriteSeq.Remove( oldId );
					_lastMeshHash.Remove( oldId );
					_bridgeObjectCache.Remove( oldId );
					_recentlyDeleted.Add( oldId );
					BlenderBridgeServer.BroadcastWithSeq( new { type = "deleted", bridgeId = oldId } );
				}
			}

			// Track scene objects (models/lights) and auto-adopt native MeshComponents
			foreach ( var go in BridgeSceneHelper.WalkAll( scene, true ) )
			{
				if ( go == null || !go.IsValid ) continue;
				if ( go.Tags.TryGetAll().Any( tag => tag.StartsWith( "bridge_" ) ) )
					continue;

				// Auto-adopt native MeshComponents that aren't bridge-tagged yet
				var nativeMesh = go.Components.Get<MeshComponent>();
				if ( nativeMesh?.Mesh != null )
				{
					var adoptId = "b_" + Guid.NewGuid().ToString( "N" ).Substring( 0, 8 );
					go.Tags.Add( $"bridge_{adoptId}" );
					_bridgeObjectCache[adoptId] = go;
					_lastKnown[adoptId] = (go.WorldPosition, go.WorldRotation);
					_lastMeshHash[adoptId] = ComputeMeshGeometryHash( nativeMesh.Mesh );

					// Broadcast the new object to Blender as a creation event
					var adoptPos = go.WorldPosition;
					var adoptRot = go.WorldRotation.Angles();
					var extracted = ExtractMeshData( nativeMesh.Mesh );
					object meshData = null;
					if ( extracted != null )
						meshData = new { vertices = extracted.Value.Vertices, faces = extracted.Value.Faces };

					BlenderBridgeServer.BroadcastWithSeq( new
					{
						type = "object_created",
						bridgeId = adoptId,
						name = go.Name,
						position = new { x = adoptPos.x, y = adoptPos.y, z = adoptPos.z },
						rotation = new { pitch = adoptRot.pitch, yaw = adoptRot.yaw, roll = adoptRot.roll },
						meshData
					} );
					BlenderBridgeServer.LogInfo( $"Auto-adopted native mesh '{go.Name}' as {adoptId}" );
					continue;
				}

				var hasModel = go.Components.GetAll().Any( c => c.GetType().Name.Contains( "ModelRenderer" ) );
				var hasLight = go.Components.GetAll().Any( c => c is Light );
				if ( !hasModel && !hasLight ) continue;

				var key = $"scene_{go.Id}";
				var pos = go.WorldPosition;
				var rot = go.WorldRotation;

				if ( _lastKnown.TryGetValue( key, out var prevScene ) )
				{
					bool sceneChanged = MathF.Abs( pos.x - prevScene.pos.x ) > 0.01f
						|| MathF.Abs( pos.y - prevScene.pos.y ) > 0.01f
						|| MathF.Abs( pos.z - prevScene.pos.z ) > 0.01f;

					if ( sceneChanged && !_lastWriteSeq.ContainsKey( key ) )
					{
						var angles = rot.Angles();
						BlenderBridgeServer.BroadcastWithSeq( new
						{
							type = "scene_updated",
							sceneId = go.Id.ToString(),
							position = new { x = pos.x, y = pos.y, z = pos.z },
							rotation = new { pitch = angles.pitch, yaw = angles.yaw, roll = angles.roll }
						} );
					}
					else if ( _lastWriteSeq.ContainsKey( key ) )
					{
						_lastWriteSeq.Remove( key );
					}
				}

				_lastKnown[key] = (pos, rot);
			}

			// Write out queued mesh caches + manifest once things go quiet.
			BridgePersistence.FlushPendingSaves( scene );
		}

		// ── Hierarchy grouping ────────────────────────────────────────────────

		/// <summary>Find or create the "Blender Bridge" parent object identified by tag.</summary>
		private static GameObject GetOrCreateBridgeGroup( Scene scene )
		{
			// Search by tag (survives renames)
			foreach ( var root in scene.Children )
			{
				var found = SearchTree( root, g => g.Tags.Has( "bridge_group" ) );
				if ( found != null ) return found;
			}

			// Create new group
			var go = scene.CreateObject();
			go.Name = "Blender Bridge";
			go.Tags.Add( "bridge_group" );
			return go;
		}

		/// <summary>Find or create a child GameObject by name under a parent.
		/// Used to build hierarchy from Blender collection paths.</summary>
		private static GameObject GetOrCreateChild( GameObject parent, string name )
		{
			// Search existing children
			foreach ( var child in parent.Children )
			{
				if ( child.Name == name )
					return child;
			}

			// Create new empty child
			var scene = BridgeSceneHelper.ResolveScene();
			if ( scene == null ) return parent;

			var go = scene.CreateObject();
			go.Name = name;
			go.Parent = parent;
			return go;
		}

		// ── Object finders ────────────────────────────────────────────────────

		/// <summary>True if this tag carries a bridge identity (bridge_&lt;id&gt;).
		/// The group marker and the lock tags (bridge_locked, bridge_lock_&lt;flags&gt;)
		/// share the prefix but are not identity — treating them as one yields
		/// phantom ids like "lock_4" shared by every object with the same flags.</summary>
		internal static bool IsBridgeIdentityTag( string tag )
			=> tag.StartsWith( "bridge_" ) && tag != "bridge_group"
				&& tag != BridgeLockPolicy.LegacyLockTag
				&& !tag.StartsWith( BridgeLockPolicy.FlagTagPrefix );

		/// <summary>Client-supplied bridge IDs become GameObject tags — accept
		/// only the addon's own "b_" + lowercase-hex format so a malformed
		/// value can't smuggle arbitrary tag content (or collide with the
		/// group/lock tag namespace).</summary>
		private static bool IsValidClientBridgeId( string id )
		{
			if ( string.IsNullOrEmpty( id ) || id.Length < 4 || id.Length > 40 ) return false;
			if ( !id.StartsWith( "b_" ) ) return false;
			for ( int i = 2; i < id.Length; i++ )
			{
				var c = id[i];
				if ( !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) ) return false;
			}
			return true;
		}

		/// <summary>Find a bridge object by ID. Uses cache, falls back to tree walk.</summary>
		private static GameObject FindByBridgeTag( string bridgeId )
		{
			// Cache hit
			if ( _bridgeObjectCache.TryGetValue( bridgeId, out var cached ) && cached != null && cached.IsValid )
				return cached;

			// Cache miss — walk the scene
			var scene = BridgeSceneHelper.ResolveScene();
			if ( scene == null ) return null;

			var tag = $"bridge_{bridgeId}";
			foreach ( var root in scene.Children )
			{
				var found = SearchTree( root, tag );
				if ( found != null )
				{
					_bridgeObjectCache[bridgeId] = found;
					return found;
				}
			}
			return null;
		}

		private static GameObject SearchTree( GameObject node, string tag )
		{
			if ( node.Tags.Has( tag ) ) return node;
			foreach ( var child in node.Children )
			{
				var found = SearchTree( child, tag );
				if ( found != null ) return found;
			}
			return null;
		}

		private static GameObject SearchTree( GameObject node, Func<GameObject, bool> predicate )
		{
			if ( predicate( node ) ) return node;
			foreach ( var child in node.Children )
			{
				var found = SearchTree( child, predicate );
				if ( found != null ) return found;
			}
			return null;
		}

		private static List<(string bridgeId, GameObject go)> FindAllBridgeObjects( Scene scene )
		{
			var result = new List<(string, GameObject)>();
			foreach ( var root in scene.Children )
				CollectBridgeObjects( root, result );
			return result;
		}

		private static void CollectBridgeObjects( GameObject node, List<(string, GameObject)> result )
		{
			foreach ( var tag in node.Tags.TryGetAll() )
			{
				if ( IsBridgeIdentityTag( tag ) )
				{
					var bridgeId = tag.Substring( 7 );
					result.Add( (bridgeId, node) );
					_bridgeObjectCache[bridgeId] = node; // Keep cache warm
					break;
				}
			}
			foreach ( var child in node.Children )
				CollectBridgeObjects( child, result );
		}

		/// <summary>Read-only diagnostic snapshot for GET /debug.</summary>
		internal static string DumpDebugState()
		{
			try
			{
				var scene = BridgeSceneHelper.ResolveScene();
				var objs = new List<object>();
				if ( scene != null )
				{
					foreach ( var go in BridgeSceneHelper.WalkAll( scene, true ) )
					{
						if ( go == null || !go.IsValid ) continue;
						var bridgeTags = go.Tags.TryGetAll()
							.Where( t => t.StartsWith( "bridge_" ) ).ToList();
						if ( bridgeTags.Count == 0 ) continue;
						var p = go.WorldPosition;
						objs.Add( new
						{
							name = go.Name,
							tags = bridgeTags,
							position = new { x = p.x, y = p.y, z = p.z }
						} );
					}
				}
				return JsonSerializer.Serialize( new
				{
					scene = scene?.Name,
					playing = Game.IsPlaying,
					objects = objs,
					lastKnown = _lastKnown.Keys.ToList(),
					lastWriteSeq = _lastWriteSeq.Keys.ToList(),
					lastMeshHash = _lastMeshHash.Keys.ToList(),
					idempotencyKeys = _idempotencyKeys.Count,
				}, BlenderBridgeServer.JsonOptions );
			}
			catch ( Exception ex )
			{
				return JsonSerializer.Serialize( new { error = ex.Message }, BlenderBridgeServer.JsonOptions );
			}
		}

		// ── Light properties ──────────────────────────────────────────────────

		private static void ApplyLightProperties( GameObject go, JsonElement root )
		{
			if ( !root.TryGetProperty( "properties", out var propsEl ) )
				return;

			var light = go.Components.GetAll().FirstOrDefault( c => c is Light ) as Light;
			if ( light == null ) return;

			if ( propsEl.TryGetProperty( "color", out var colorEl ) )
			{
				var r = GetFloat( colorEl, "r", 1f );
				var g = GetFloat( colorEl, "g", 1f );
				var b = GetFloat( colorEl, "b", 1f );
				light.LightColor = new Color( r, g, b );
			}

			if ( light is PointLight point )
			{
				if ( propsEl.TryGetProperty( "radius", out var radiusEl ) )
					point.Radius = radiusEl.GetSingle();
			}
			else if ( light is SpotLight spot )
			{
				if ( propsEl.TryGetProperty( "radius", out var radiusEl ) )
					spot.Radius = radiusEl.GetSingle();
				if ( propsEl.TryGetProperty( "coneOuter", out var outerEl ) )
					spot.ConeOuter = outerEl.GetSingle();
				if ( propsEl.TryGetProperty( "coneInner", out var innerEl ) )
					spot.ConeInner = innerEl.GetSingle();
			}
		}

		// ── Mesh handling ─────────────────────────────────────────────────────

		private struct ParsedMesh
		{
			public Vector3[] Vertices;
			public List<int[]> FaceGroups;
			public int[] FaceMaterials;
			public List<MaterialDef> Materials;
			/// <summary>Per-face Blender-authored UVs, parallel to FaceGroups.
			/// Each entry has one Vector2 per face vertex. Null when Blender
			/// didn't send a UV layer; the apply path falls back to grid-align.</summary>
			public List<Vector2[]> FaceUVs;
		}

		private struct MaterialDef
		{
			public string Name;
			public float[] BaseColor;
			public float Metallic;
			public float Roughness;
			public string BaseColorTexture;
			public string RoughnessTexture;
			public string MetallicTexture;
			public string NormalTexture;
			public float NormalStrength;
			public float[] EmissionColor;
			public float EmissionStrength;
			public string VmatPath;
		}

		private static void ApplyMeshData( GameObject go, JsonElement meshData )
		{
			var parsed = ParseMeshData( meshData );
			if ( parsed == null ) return;
			ApplyParsedMeshData( go, parsed.Value );
		}

		/// <summary>
		/// Pulls the material off the first face of the GameObject's existing
		/// mesh. Used to preserve manually-authored materials (water shaders,
		/// custom vmats) when Blender resyncs without supplying material info.
		/// Returns null if there's no mesh, no faces, or only the dev default
		/// material is present (in which case the caller's fallback is fine).
		/// </summary>
		private static Material TryGetExistingDominantMaterial( GameObject go )
		{
			try
			{
				var mesh = go?.Components.Get<MeshComponent>()?.Mesh;
				if ( mesh == null ) return null;

				var getFaceMatMeth = mesh.GetType().GetMethod( "GetFaceMaterial", new[] { typeof( FaceHandle ) } );
				if ( getFaceMatMeth == null ) return null;

				foreach ( var fh in mesh.FaceHandles )
				{
					var result = getFaceMatMeth.Invoke( mesh, new object[] { fh } );
					if ( result is Material m && m != null )
					{
						// Skip the dev placeholder — using it as the "preserve"
						// fallback means we'd never escape it once it's been set.
						var path = m.ResourcePath ?? "";
						if ( path.EndsWith( "reflectivity_30.vmat", StringComparison.OrdinalIgnoreCase ) ||
							 path.EndsWith( "reflectivity_30.vmat_c", StringComparison.OrdinalIgnoreCase ) )
							return null;
						return m;
					}
					break;
				}
			}
			catch { }
			return null;
		}

		private static void ApplyParsedMeshData( GameObject go, ParsedMesh parsed )
		{
			var faceMaterials = new Material[parsed.FaceGroups.Count];
			var defaultMaterial = LoadMaterialSafe( "materials/dev/reflectivity_30.vmat" );

			// Materials lock: preserve whatever's already on the object regardless
			// of what Blender supplied. Skips the entire material-resolution path
			// and falls through to the existing-material preservation branch.
			bool forceMaterialPreserve = !BridgeLockPolicy.AllowsMaterialChange( go );

			if ( !forceMaterialPreserve && parsed.Materials != null && parsed.FaceMaterials != null )
			{
				var materialCache = new Dictionary<int, Material>();
				for ( int fi = 0; fi < parsed.FaceGroups.Count && fi < parsed.FaceMaterials.Length; fi++ )
				{
					var matIdx = parsed.FaceMaterials[fi];
					if ( !materialCache.ContainsKey( matIdx ) )
					{
						Material mat = null;
						if ( matIdx < parsed.Materials.Count )
						{
							var vmatPath = parsed.Materials[matIdx].VmatPath;
							if ( !string.IsNullOrEmpty( vmatPath ) )
								mat = LoadMaterialSafe( vmatPath );
							if ( mat == null )
								mat = GenerateOrLoadMaterial( parsed.Materials[matIdx] );
						}
						materialCache[matIdx] = mat ?? defaultMaterial;
					}
					faceMaterials[fi] = materialCache[matIdx];
				}
			}
			else
			{
				// Blender sent geometry but no material info. Before stamping
				// the dev placeholder over every face, try to preserve whatever
				// material is already on this GameObject's mesh — water shaders,
				// custom vmats, anything authored on the s&box side.
				var preserved = TryGetExistingDominantMaterial( go );
				var fallback = preserved ?? defaultMaterial;
				for ( int i = 0; i < faceMaterials.Length; i++ )
					faceMaterials[i] = fallback;
				if ( preserved != null )
					BlenderBridgeServer.LogInfo( $"Preserved existing material on '{go.Name}' (Blender sent no materials)" );
			}

			var mesh = new PolygonMesh();
			var hVertices = mesh.AddVertices( parsed.Vertices );

			bool hasBlenderUVs = parsed.FaceUVs != null && parsed.FaceUVs.Count == parsed.FaceGroups.Count;

			int faceIdx = 0;
			foreach ( var faceGroup in parsed.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, faceIdx < faceMaterials.Length ? faceMaterials[faceIdx] : defaultMaterial );
					if ( hasBlenderUVs && faceIdx < parsed.FaceUVs.Count )
					{
						// Use Blender-authored UVs verbatim. SetFaceTextureCoords
						// also recomputes the face's texture parameters from the
						// new coords, so the planar projection follows the UV
						// layout instead of fighting it.
						mesh.SetFaceTextureCoords( hFace, parsed.FaceUVs[faceIdx] );
					}
				}
				faceIdx++;
			}

			// Only fall back to grid-aligned auto-UVs if Blender didn't send a
			// UV layer. Calling TextureAlignToGrid when we have real UVs would
			// stomp them.
			if ( !hasBlenderUVs )
				mesh.TextureAlignToGrid( mesh.Transform );
			mesh.SetSmoothingAngle( 40.0f );

			// Defense in depth: never replace a Terrain component with a
			// MeshComponent. If the lock check earlier in the dispatch flow was
			// bypassed (e.g. an addon bug, a missing tag), this still blocks the
			// destructive path.
			if ( go.Components.Get<Terrain>() != null )
			{
				BlenderBridgeServer.LogInfo( $"Skipped ApplyMeshData on '{go.Name}' — has Terrain component" );
				return;
			}

			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;
		}

		// ── Material generation ───────────────────────────────────────────────

		private static Material GenerateOrLoadMaterial( MaterialDef def )
		{
			var safeName = System.Text.RegularExpressions.Regex.Replace(
				def.Name ?? "default", @"[^a-zA-Z0-9_\-]", "_" ).ToLower();
			var vmatRelPath = $"materials/blender_bridge/{safeName}.vmat";

			var assetsDir = GetProjectAssetsDir();
			if ( assetsDir == null ) return null;

			var bridgeMatDir = System.IO.Path.Combine( assetsDir, "materials", "blender_bridge" );
			System.IO.Directory.CreateDirectory( bridgeMatDir );

			var colorTexRef = CopyTextureToAssets( def.BaseColorTexture, safeName, "color", assetsDir );
			var roughTexRef = CopyTextureToAssets( def.RoughnessTexture, safeName, "rough", assetsDir );
			var metalTexRef = CopyTextureToAssets( def.MetallicTexture, safeName, "metal", assetsDir );
			var normalTexRef = CopyTextureToAssets( def.NormalTexture, safeName, "normal", assetsDir );

			var sb = new System.Text.StringBuilder();
			sb.AppendLine( "// AUTO-GENERATED BY BLENDER BRIDGE" );
			sb.AppendLine();
			sb.AppendLine( "Layer0" );
			sb.AppendLine( "{" );
			sb.AppendLine( "\tshader \"shaders/complex.shader\"" );
			sb.AppendLine();
			if ( metalTexRef != null ) sb.AppendLine( "\tF_METALNESS_TEXTURE 1" );
			sb.AppendLine( "\tF_SPECULAR 1" );
			sb.AppendLine();

			var r = def.BaseColor?.Length >= 3 ? def.BaseColor[0] : 0.8f;
			var g = def.BaseColor?.Length >= 3 ? def.BaseColor[1] : 0.8f;
			var b = def.BaseColor?.Length >= 3 ? def.BaseColor[2] : 0.8f;
			// A color texture drives albedo — Blender's Base Color value is
			// whatever was set before the texture was linked, and tinting by
			// it multiplies the texture by that stale color.
			if ( colorTexRef != null ) { r = 1f; g = 1f; b = 1f; }
			sb.AppendLine( $"\tg_flModelTintAmount \"1.000\"" );
			sb.AppendLine( $"\tg_vColorTint \"[{r:F6} {g:F6} {b:F6} 0.000000]\"" );
			if ( colorTexRef != null ) sb.AppendLine( $"\tTextureColor \"{colorTexRef}\"" );
			sb.AppendLine();
			sb.AppendLine( $"\tg_flMetalness \"{def.Metallic:F3}\"" );
			if ( metalTexRef != null ) sb.AppendLine( $"\tTextureMetalness \"{metalTexRef}\"" );
			sb.AppendLine();
			sb.AppendLine( $"\tg_flRoughnessScaleFactor \"{def.Roughness:F3}\"" );
			if ( roughTexRef != null ) sb.AppendLine( $"\tTextureRoughness \"{roughTexRef}\"" );
			sb.AppendLine();
			if ( normalTexRef != null ) { sb.AppendLine( $"\tTextureNormal \"{normalTexRef}\"" ); sb.AppendLine(); }
			if ( def.EmissionStrength > 0.001f )
			{
				var er = def.EmissionColor?.Length >= 3 ? def.EmissionColor[0] : 0f;
				var eg = def.EmissionColor?.Length >= 3 ? def.EmissionColor[1] : 0f;
				var eb = def.EmissionColor?.Length >= 3 ? def.EmissionColor[2] : 0f;
				sb.AppendLine( $"\tg_vSelfIllumTint \"[{er:F6} {eg:F6} {eb:F6} 0.000000]\"" );
				sb.AppendLine( $"\tg_flSelfIllumScale \"{def.EmissionStrength:F3}\"" );
				sb.AppendLine();
			}
			sb.AppendLine( "\tg_vTexCoordScale \"[1.000 1.000]\"" );
			sb.AppendLine( "\tg_vTexCoordOffset \"[0.000 0.000]\"" );
			sb.AppendLine( "}" );

			var vmatPath = System.IO.Path.Combine( assetsDir, vmatRelPath.Replace( "/", "\\" ) );
			System.IO.File.WriteAllText( vmatPath, sb.ToString() );

			// Register AND compile, otherwise Material.Load returns null on the
			// first call (no .vmat_c on disk yet) and we silently fall through
			// to the dev placeholder. Compile(false) is incremental and
			// synchronous — by the time it returns, the compiled resource
			// exists and Material.Load can resolve it. Re-registering an
			// already-known path is a no-op.
			try
			{
				var asset = AssetSystem.RegisterFile( vmatPath );
				asset?.Compile( full: false );
			}
			catch ( Exception ex )
			{
				BlenderBridgeServer.LogInfo( $"Vmat register/compile failed for {vmatPath}: {ex.Message}" );
			}

			return LoadMaterialSafe( vmatRelPath ) ?? LoadMaterialSafe( "materials/dev/reflectivity_30.vmat" );
		}

		private static string GenerateVmatPath( MaterialDef def )
		{
			var safeName = System.Text.RegularExpressions.Regex.Replace(
				def.Name ?? "default", @"[^a-zA-Z0-9_\-]", "_" ).ToLower();
			return $"materials/blender_bridge/{safeName}.vmat";
		}

		private static string CopyTextureToAssets( string srcPath, string matName, string suffix, string assetsDir )
		{
			if ( string.IsNullOrEmpty( srcPath ) || !System.IO.File.Exists( srcPath ) )
				return null;
			var ext = System.IO.Path.GetExtension( srcPath );
			var destName = $"{matName}_{suffix}{ext}";
			var destRelPath = $"materials/blender_bridge/{destName}";
			var destAbsPath = System.IO.Path.Combine( assetsDir, destRelPath.Replace( "/", "\\" ) );
			try
			{
				System.IO.File.Copy( srcPath, destAbsPath, overwrite: true );
				// Register + compile so the vmat that references this texture
				// can find a resolved .vtex_c on disk. Without compile, the
				// material loads but renders untextured.
				try
				{
					var asset = AssetSystem.RegisterFile( destAbsPath );
					asset?.Compile( full: false );
				}
				catch ( Exception cex )
				{
					BlenderBridgeServer.LogInfo( $"Texture register/compile failed for {destAbsPath}: {cex.Message}" );
				}
				return destRelPath;
			}
			catch ( Exception ex )
			{
				BlenderBridgeServer.LogInfo( $"Texture copy failed ({srcPath}): {ex.Message}" );
				return null;
			}
		}

		// ── Mesh parsing ──────────────────────────────────────────────────────

		private static ParsedMesh? ParseMeshData( JsonElement meshData )
		{
			if ( !meshData.TryGetProperty( "vertices", out var vertsEl ) ) return null;
			if ( !meshData.TryGetProperty( "faces", out var facesEl ) ) return null;

			var vertFloats = new List<float>();
			foreach ( var v in vertsEl.EnumerateArray() ) vertFloats.Add( v.GetSingle() );
			if ( vertFloats.Count < 9 ) return null;

			var vertCount = vertFloats.Count / 3;
			var vertices = new Vector3[vertCount];
			for ( int i = 0; i < vertCount; i++ )
				vertices[i] = new Vector3( vertFloats[i * 3], vertFloats[i * 3 + 1], vertFloats[i * 3 + 2] );

			var rawFaces = new List<int>();
			foreach ( var f in facesEl.EnumerateArray() ) rawFaces.Add( f.GetInt32() );

			var faceGroups = new List<int[]>();
			int idx = 0;
			while ( idx < rawFaces.Count )
			{
				int faceVertCount = rawFaces[idx++];
				if ( idx + faceVertCount > rawFaces.Count ) break;
				var face = new int[faceVertCount];
				for ( int i = 0; i < faceVertCount; i++ ) face[i] = rawFaces[idx++];
				faceGroups.Add( face );
			}

			int[] faceMaterials = null;
			if ( meshData.TryGetProperty( "faceMaterials", out var fmEl ) )
			{
				var fmList = new List<int>();
				foreach ( var fm in fmEl.EnumerateArray() ) fmList.Add( fm.GetInt32() );
				faceMaterials = fmList.ToArray();
			}

			List<MaterialDef> materials = null;
			if ( meshData.TryGetProperty( "materials", out var matsEl ) )
				materials = ParseMaterialDefs( matsEl );

			// Optional per-face-corner UVs. Flat [u,v,u,v,...] aligned with
			// faceGroups: face N's UVs are the next (face.Length) pairs.
			List<Vector2[]> faceUVs = null;
			if ( meshData.TryGetProperty( "faceUVs", out var uvEl ) )
				faceUVs = ParseFaceUVs( uvEl, faceGroups );

			return new ParsedMesh
			{
				Vertices = vertices,
				FaceGroups = faceGroups,
				FaceMaterials = faceMaterials,
				Materials = materials,
				FaceUVs = faceUVs
			};
		}

		/// <summary>
		/// Parse a flat [u,v,u,v,...] array into per-face UV sets aligned with
		/// faceGroups: face N's UVs are the next (face.Length) pairs.
		/// </summary>
		private static List<Vector2[]> ParseFaceUVs( JsonElement uvEl, List<int[]> faceGroups )
		{
			var uvFloats = new List<float>();
			foreach ( var u in uvEl.EnumerateArray() ) uvFloats.Add( u.GetSingle() );
			var faceUVs = new List<Vector2[]>( faceGroups.Count );
			int uvIdx = 0;
			foreach ( var face in faceGroups )
			{
				var uvs = new Vector2[face.Length];
				for ( int i = 0; i < face.Length && uvIdx + 1 < uvFloats.Count; i++ )
				{
					uvs[i] = new Vector2( uvFloats[uvIdx], uvFloats[uvIdx + 1] );
					uvIdx += 2;
				}
				faceUVs.Add( uvs );
			}
			return faceUVs;
		}

		private static List<MaterialDef> ParseMaterialDefs( JsonElement matsEl )
		{
			var materials = new List<MaterialDef>();
			foreach ( var matEl in matsEl.EnumerateArray() )
			{
				var def = new MaterialDef
				{
					Name = matEl.TryGetProperty( "name", out var n ) ? n.GetString() : "default",
					Metallic = matEl.TryGetProperty( "metallic", out var met ) ? met.GetSingle() : 0f,
					Roughness = matEl.TryGetProperty( "roughness", out var rough ) ? rough.GetSingle() : 0.5f,
					NormalStrength = matEl.TryGetProperty( "normalStrength", out var ns ) ? ns.GetSingle() : 1f,
					EmissionStrength = matEl.TryGetProperty( "emissionStrength", out var es ) ? es.GetSingle() : 0f,
					BaseColorTexture = matEl.TryGetProperty( "baseColorTexture", out var bct ) && bct.ValueKind == JsonValueKind.String ? bct.GetString() : null,
					RoughnessTexture = matEl.TryGetProperty( "roughnessTexture", out var rt ) && rt.ValueKind == JsonValueKind.String ? rt.GetString() : null,
					MetallicTexture = matEl.TryGetProperty( "metallicTexture", out var mt ) && mt.ValueKind == JsonValueKind.String ? mt.GetString() : null,
					NormalTexture = matEl.TryGetProperty( "normalTexture", out var nt ) && nt.ValueKind == JsonValueKind.String ? nt.GetString() : null,
					VmatPath = matEl.TryGetProperty( "vmatPath", out var vp ) && vp.ValueKind == JsonValueKind.String ? vp.GetString() : null,
				};

				if ( matEl.TryGetProperty( "baseColor", out var bcEl ) && bcEl.ValueKind == JsonValueKind.Array )
				{
					var arr = new List<float>();
					foreach ( var c in bcEl.EnumerateArray() ) arr.Add( c.GetSingle() );
					def.BaseColor = arr.ToArray();
				}

				if ( matEl.TryGetProperty( "emissionColor", out var ecEl ) && ecEl.ValueKind == JsonValueKind.Array )
				{
					var arr = new List<float>();
					foreach ( var c in ecEl.EnumerateArray() ) arr.Add( c.GetSingle() );
					def.EmissionColor = arr.ToArray();
				}

				materials.Add( def );
			}
			return materials;
		}

		/// <summary>
		/// Compute a geometry hash that includes vertex positions,
		/// so moving/pulling vertices is detected as a change.
		/// </summary>
		// Reflection hot-path cache. GetMethod + Invoke PER VERTEX (boxing every
		// argument and return) was fine at small scenes but became the "editor
		// lags on everything" stall once the scan ran it thousands of times per
		// tick. Resolve once into an open instance delegate — a direct call
		// thereafter.
		private static Func<PolygonMesh, VertexHandle, Vector3> _getVertexPositionFn;
		private static bool _getVertexPositionTried;

		private static Func<PolygonMesh, VertexHandle, Vector3> ResolveGetVertexPosition( PolygonMesh mesh )
		{
			if ( !_getVertexPositionTried )
			{
				_getVertexPositionTried = true;
				try
				{
					var m = mesh.GetType().GetMethod( "GetVertexPosition", new[] { typeof( VertexHandle ) } );
					if ( m != null && m.ReturnType == typeof( Vector3 ) && !m.IsStatic )
						_getVertexPositionFn = (Func<PolygonMesh, VertexHandle, Vector3>)Delegate.CreateDelegate(
							typeof( Func<PolygonMesh, VertexHandle, Vector3> ), m );
				}
				catch { }
			}
			return _getVertexPositionFn;
		}

		private static int ComputeMeshGeometryHash( PolygonMesh mesh )
		{
			unchecked
			{
				int hash = 17;
				var vertHandles = mesh.VertexHandles;
				if ( vertHandles == null ) return 0;

				hash = hash * 31 + vertHandles.Count();

				var getPos = ResolveGetVertexPosition( mesh );
				if ( getPos != null )
				{
					foreach ( var vh in vertHandles )
					{
						var pos = getPos( mesh, vh );
						// Quantize to 0.001 precision to avoid float noise
						hash = hash * 31 + (int)( pos.x * 1000 );
						hash = hash * 31 + (int)( pos.y * 1000 );
						hash = hash * 31 + (int)( pos.z * 1000 );
					}
				}
				else
				{
					var getPosMeth = mesh.GetType().GetMethod( "GetVertexPosition", new[] { typeof( VertexHandle ) } );
					if ( getPosMeth != null )
					{
						foreach ( var vh in vertHandles )
						{
							try
							{
								var result = getPosMeth.Invoke( mesh, new object[] { vh } );
								if ( result is Vector3 pos )
								{
									hash = hash * 31 + (int)( pos.x * 1000 );
									hash = hash * 31 + (int)( pos.y * 1000 );
									hash = hash * 31 + (int)( pos.z * 1000 );
								}
							}
							catch { break; }
						}
					}
				}

				var faceHandles = mesh.FaceHandles;
				if ( faceHandles != null )
					hash = hash * 31 + faceHandles.Count();

				return hash;
			}
		}

		// ── Mesh extraction (s&box -> Blender) ────────────────────────────────

		internal struct ExtractedMesh
		{
			public float[] Vertices;
			public int[] Faces;
		}

		internal static ExtractedMesh? ExtractMeshData( PolygonMesh mesh )
		{
			try
			{
				var vertHandles = mesh.VertexHandles.ToList();
				if ( vertHandles.Count < 3 ) return null;

				var vertMap = new Dictionary<VertexHandle, int>();
				int vertIdx = 0;
				foreach ( var vh in vertHandles ) vertMap[vh] = vertIdx++;

				var verts = new List<float>();
				var getPos = ResolveGetVertexPosition( mesh );
				if ( getPos != null )
				{
					foreach ( var vh in vertHandles )
					{
						var pos = getPos( mesh, vh );
						verts.Add( pos.x );
						verts.Add( pos.y );
						verts.Add( pos.z );
					}
				}
				else
				{
					var getPosMeth = mesh.GetType().GetMethod( "GetVertexPosition", new[] { typeof( VertexHandle ) } );
					if ( getPosMeth != null )
					{
						foreach ( var vh in vertHandles )
						{
							try
							{
								var result = getPosMeth.Invoke( mesh, new object[] { vh } );
								if ( result is Vector3 pos )
								{
									verts.Add( pos.x );
									verts.Add( pos.y );
									verts.Add( pos.z );
								}
							}
							catch { break; }
						}
					}
				}

				if ( verts.Count < 9 ) return null;

				var faces = new List<int>();
				var getFaceVertsMeth = mesh.GetType().GetMethod( "GetFaceVertices", new[] { typeof( FaceHandle ) } );
				if ( getFaceVertsMeth != null )
				{
					foreach ( var fh in mesh.FaceHandles )
					{
						var result = getFaceVertsMeth.Invoke( mesh, new object[] { fh } );
						if ( result is VertexHandle[] faceVerts )
						{
							faces.Add( faceVerts.Length );
							foreach ( var fv in faceVerts )
								faces.Add( vertMap.TryGetValue( fv, out var i ) ? i : 0 );
						}
						else if ( result is IEnumerable<VertexHandle> faceVertsEnum )
						{
							var fvList = faceVertsEnum.ToList();
							faces.Add( fvList.Count );
							foreach ( var fv in fvList )
								faces.Add( vertMap.TryGetValue( fv, out var i ) ? i : 0 );
						}
					}
				}

				if ( faces.Count == 0 ) return null;
				return new ExtractedMesh { Vertices = verts.ToArray(), Faces = faces.ToArray() };
			}
			catch ( Exception ex )
			{
				BlenderBridgeServer.LogError( $"ExtractMeshData failed: {ex.Message}" );
				return null;
			}
		}

		// ── Terrain proxy extraction (s&box -> Blender) ──────────────────────
		//
		// The Sandbox.Terrain component isn't a PolygonMesh — its data lives in
		// a TerrainStorage with a Resolution² ushort[] heightmap. Sending the
		// full heightmap to Blender is impractical (a 1024² terrain = 1M verts),
		// so we downsample to a fixed PROXY_RESOLUTION grid for a low-poly
		// reference mesh in mesh-local space. Triangulation: each cell is a
		// quad face (the wire format already supports n-gons).

		private const int TerrainProxyResolution = 128;

		internal static ExtractedMesh? ExtractTerrainProxyMesh( Terrain terrain )
		{
			try
			{
				var storage = terrain?.Storage;
				if ( storage == null ) return null;
				if ( storage.HeightMap == null || storage.HeightMap.Length == 0 ) return null;

				int srcRes = storage.Resolution;
				if ( srcRes <= 0 || storage.HeightMap.Length < srcRes * srcRes ) return null;

				int proxyRes = Math.Min( TerrainProxyResolution, srcRes );
				int vertsPerSide = proxyRes + 1;

				float terrainSize = storage.TerrainSize;
				float terrainHeight = storage.TerrainHeight;
				float cellSize = terrainSize / proxyRes;
				float invMaxHeight = 1.0f / 65535f;

				var verts = new float[vertsPerSide * vertsPerSide * 3];
				for ( int gy = 0; gy < vertsPerSide; gy++ )
				{
					int srcY = (int)((long)gy * (srcRes - 1) / proxyRes);
					for ( int gx = 0; gx < vertsPerSide; gx++ )
					{
						int srcX = (int)((long)gx * (srcRes - 1) / proxyRes);
						ushort h = storage.HeightMap[srcY * srcRes + srcX];

						int vi = (gy * vertsPerSide + gx) * 3;
						verts[vi + 0] = gx * cellSize;
						verts[vi + 1] = gy * cellSize;
						verts[vi + 2] = h * invMaxHeight * terrainHeight;
					}
				}

				// Faces: one quad per cell, [4, v00, v10, v11, v01].
				var faces = new int[proxyRes * proxyRes * 5];
				int fi = 0;
				for ( int gy = 0; gy < proxyRes; gy++ )
				{
					int row0 = gy * vertsPerSide;
					int row1 = (gy + 1) * vertsPerSide;
					for ( int gx = 0; gx < proxyRes; gx++ )
					{
						faces[fi++] = 4;
						faces[fi++] = row0 + gx;
						faces[fi++] = row0 + gx + 1;
						faces[fi++] = row1 + gx + 1;
						faces[fi++] = row1 + gx;
					}
				}

				return new ExtractedMesh { Vertices = verts, Faces = faces };
			}
			catch ( Exception ex )
			{
				BlenderBridgeServer.LogError( $"ExtractTerrainProxyMesh failed: {ex.Message}" );
				return null;
			}
		}

		/// <summary>
		/// Pulls mesh data out of either a MeshComponent or (as a downsampled
		/// proxy) a Terrain. Used by the payload builders so terrain shows up
		/// in Blender alongside regular meshes.
		/// </summary>
		private static object TryBuildMeshData( GameObject go )
		{
			var meshComp = go.Components.Get<MeshComponent>();
			if ( meshComp?.Mesh != null )
			{
				var extracted = ExtractMeshData( meshComp.Mesh );
				if ( extracted != null )
					return new { vertices = extracted.Value.Vertices, faces = extracted.Value.Faces };
			}

			var terrain = go.Components.Get<Terrain>();
			if ( terrain?.Storage != null )
			{
				var extracted = ExtractTerrainProxyMesh( terrain );
				if ( extracted != null )
					return new { vertices = extracted.Value.Vertices, faces = extracted.Value.Faces };
			}

			return null;
		}

		// ── Payload builders ──────────────────────────────────────────────────

		/// <summary>Public access to BuildObjectPayload for the editor window's Send to Blender button.</summary>
		internal static object BuildObjectPayloadPublic( string bridgeId, GameObject go )
		{
			return BuildObjectPayload( bridgeId, go );
		}

		/// <summary>Build a complete object_created message with type field for broadcasting.</summary>
		internal static object BuildObjectCreatedMessage( string bridgeId, GameObject go )
		{
			var pos = go.WorldPosition;
			var rot = go.WorldRotation.Angles();
			var meshData = TryBuildMeshData( go );

			return new
			{
				type = "object_created",
				bridgeId,
				name = go.Name,
				position = new { x = pos.x, y = pos.y, z = pos.z },
				rotation = new { pitch = rot.pitch, yaw = rot.yaw, roll = rot.roll },
				meshData
			};
		}

		private static object BuildObjectPayload( string bridgeId, GameObject go )
		{
			var pos = go.WorldPosition;
			var rot = go.WorldRotation.Angles();
			var meshData = TryBuildMeshData( go );

			// Build hierarchy path from parent chain (excluding bridge group root)
			var hierarchy = new List<string>();
			var parent = go.Parent;
			while ( parent != null && !parent.Tags.Has( "bridge_group" ) )
			{
				hierarchy.Insert( 0, parent.Name );
				parent = parent.Parent;
			}

			return new
			{
				bridgeId,
				name = go.Name,
				position = new { x = pos.x, y = pos.y, z = pos.z },
				rotation = new { pitch = rot.pitch, yaw = rot.yaw, roll = rot.roll },
				meshData,
				hierarchy
			};
		}

		private static object BuildLightPayload( GameObject go, Light light )
		{
			var pos = go.WorldPosition;
			var rot = go.WorldRotation.Angles();
			string lightType = "point";
			if ( light is SpotLight ) lightType = "spot";
			else if ( light is DirectionalLight ) lightType = "directional";

			float radius = 0f;
			float coneOuter = 0f;
			float coneInner = 0f;
			if ( light is PointLight pl ) radius = pl.Radius;
			if ( light is SpotLight spl ) { radius = spl.Radius; coneOuter = spl.ConeOuter; coneInner = spl.ConeInner; }

			object properties = new
			{
				color = new { r = light.LightColor.r, g = light.LightColor.g, b = light.LightColor.b },
				radius,
				coneOuter,
				coneInner,
			};

			return new
			{
				objectType = "light",
				lightType,
				name = go.Name,
				sceneId = go.Id.ToString(),
				position = new { x = pos.x, y = pos.y, z = pos.z },
				rotation = new { pitch = rot.pitch, yaw = rot.yaw, roll = rot.roll },
				properties
			};
		}

		private static object BuildModelPayload( GameObject go, Component modelComp )
		{
			var pos = go.WorldPosition;
			var rot = go.WorldRotation.Angles();
			var modelProp = modelComp.GetType().GetProperty( "Model" );
			var model = modelProp?.GetValue( modelComp ) as Model;
			var modelPath = model?.ResourcePath ?? "unknown";

			object bounds = null;
			if ( model != null )
			{
				try
				{
					bounds = new
					{
						mins = new { x = model.Bounds.Mins.x, y = model.Bounds.Mins.y, z = model.Bounds.Mins.z },
						maxs = new { x = model.Bounds.Maxs.x, y = model.Bounds.Maxs.y, z = model.Bounds.Maxs.z }
					};
				}
				catch { }
			}

			string fbxSourcePath = null;
			try
			{
				if ( modelPath != "unknown" )
					fbxSourcePath = ResolveFbxPath( modelPath );
			}
			catch { }

			return new
			{
				objectType = "model",
				name = go.Name,
				sceneId = go.Id.ToString(),
				modelPath,
				fbxSourcePath,
				bounds,
				position = new { x = pos.x, y = pos.y, z = pos.z },
				rotation = new { pitch = rot.pitch, yaw = rot.yaw, roll = rot.roll }
			};
		}

		private static string ResolveFbxPath( string modelPath )
		{
			try
			{
				var assetsDir = GetProjectAssetsDir();
				if ( assetsDir == null ) return null;
				var projectRoot = System.IO.Path.GetDirectoryName( assetsDir );
				var basePaths = new List<string> { assetsDir, projectRoot };
				var relativePath = modelPath.Replace( "\\", "/" );
				foreach ( var prefix in new[] { "assets/", "models/" } )
					if ( relativePath.StartsWith( prefix, StringComparison.OrdinalIgnoreCase ) )
					{ relativePath = relativePath.Substring( prefix.Length ); break; }

				string vmdlPath = null;
				string vmdlDir = null;
				foreach ( var basePath in basePaths )
				{
					if ( string.IsNullOrEmpty( basePath ) ) continue;
					var candidate = System.IO.Path.Combine( basePath, relativePath );
					if ( System.IO.File.Exists( candidate ) ) { vmdlPath = candidate; vmdlDir = System.IO.Path.GetDirectoryName( candidate ); break; }
					candidate = System.IO.Path.Combine( basePath, modelPath.Replace( "/", "\\" ) );
					if ( System.IO.File.Exists( candidate ) ) { vmdlPath = candidate; vmdlDir = System.IO.Path.GetDirectoryName( candidate ); break; }
				}
				if ( vmdlPath == null ) return null;

				var content = System.IO.File.ReadAllText( vmdlPath );
				var match = System.Text.RegularExpressions.Regex.Match( content,
					@"_class\s*=\s*""RenderMeshFile""[\s\S]*?filename\s*=\s*""([^""]+)""" );
				if ( !match.Success ) return null;

				var fbxRelative = match.Groups[1].Value.Replace( "\\", "/" );
				var fbxPath = System.IO.Path.GetFullPath( System.IO.Path.Combine( vmdlDir, fbxRelative ) );
				if ( System.IO.File.Exists( fbxPath ) ) return fbxPath;
				foreach ( var basePath in basePaths )
				{
					if ( string.IsNullOrEmpty( basePath ) ) continue;
					fbxPath = System.IO.Path.GetFullPath( System.IO.Path.Combine( basePath, fbxRelative ) );
					if ( System.IO.File.Exists( fbxPath ) ) return fbxPath;
				}
				return null;
			}
			catch { return null; }
		}

		// ── Helpers ───────────────────────────────────────────────────────────

		private static void ApplyTransform( GameObject go, JsonElement root )
		{
			if ( root.TryGetProperty( "position", out var pos ) )
			{
				go.WorldPosition = new Vector3(
					GetFloat( pos, "x", 0f ),
					GetFloat( pos, "y", 0f ),
					GetFloat( pos, "z", 0f )
				);
			}
			if ( root.TryGetProperty( "rotation", out var rot ) )
			{
				go.WorldRotation = Rotation.From(
					GetFloat( rot, "pitch", 0f ),
					GetFloat( rot, "yaw", 0f ),
					GetFloat( rot, "roll", 0f )
				);
			}
		}

		private static string GetString( JsonElement el, string prop, string fallback = null )
		{
			return el.TryGetProperty( prop, out var v ) && v.ValueKind == JsonValueKind.String ? v.GetString() : fallback;
		}

		private static float GetFloat( JsonElement el, string prop, float fallback )
		{
			if ( el.TryGetProperty( prop, out var v ) && v.ValueKind == JsonValueKind.Number ) return v.GetSingle();
			return fallback;
		}

		private static int GetInt( JsonElement el, string prop, int fallback )
		{
			if ( el.TryGetProperty( prop, out var v ) && v.ValueKind == JsonValueKind.Number ) return v.GetInt32();
			return fallback;
		}

		/// <summary>
		/// Reply with this s&box project's Assets folder so Blender can
		/// auto-populate its addon path on connect. Carries diagnostic
		/// fields too so the Blender side can log what failed when
		/// assetsDir comes back null.
		/// </summary>
		private static string HandleGetProjectInfo( JsonElement root )
		{
			string discoveryMethod = "none";
			string projectCurrentName = null;
			string projectCurrentRoot = null;
			string sessionState = "missing";
			string sceneState = "missing";
			string sceneResourcePath = null;
			string fullScenePath = null;
			string discoveryError = null;
			string assetsDir = null;

			// Primary: Project.Current — scene-independent.
			try
			{
				if ( Sandbox.Project.Current != null )
				{
					projectCurrentName = Sandbox.Project.Current.Config?.Title;
					projectCurrentRoot = Sandbox.Project.Current.GetRootPath();
					if ( Sandbox.Project.Current.HasAssetsPath() )
					{
						assetsDir = Sandbox.Project.Current.GetAssetsPath();
						discoveryMethod = "Project.Current";
					}
				}
			}
			catch ( Exception ex )
			{
				discoveryError = "Project.Current: " + ex.GetType().Name + ": " + ex.Message;
			}

			// Fallback: scene-walk (only if Project.Current didn't resolve).
			if ( assetsDir == null )
			{
				try
				{
					var session = SceneEditorSession.Active;
					if ( session != null )
					{
						sessionState = "active";
						if ( session.Scene != null )
						{
							sceneState = "active";
							sceneResourcePath = session.Scene.Source?.ResourcePath;
							if ( !string.IsNullOrEmpty( sceneResourcePath ) )
							{
								fullScenePath = Sandbox.FileSystem.Mounted.GetFullPath( sceneResourcePath );
								if ( !string.IsNullOrEmpty( fullScenePath ) )
								{
									var dir = System.IO.Path.GetDirectoryName( fullScenePath );
									while ( dir != null )
									{
										var candidate = System.IO.Path.Combine( dir, "Assets" );
										if ( System.IO.Directory.Exists( candidate ) )
										{
											assetsDir = candidate;
											discoveryMethod = "scene-walk";
											break;
										}
										dir = System.IO.Path.GetDirectoryName( dir );
									}
								}
							}
						}
					}
				}
				catch ( Exception ex )
				{
					discoveryError = (discoveryError == null ? "" : discoveryError + "; ")
						+ "scene-walk: " + ex.GetType().Name + ": " + ex.Message;
				}
			}

			string projectName = projectCurrentName;
			if ( projectName == null && !string.IsNullOrEmpty( assetsDir ) )
			{
				var projectDir = System.IO.Path.GetDirectoryName( assetsDir );
				if ( !string.IsNullOrEmpty( projectDir ) )
					projectName = System.IO.Path.GetFileName( projectDir );
			}

			static string J( string s ) => s == null ? "null" : System.Text.Json.JsonSerializer.Serialize( s );

			var sb = new System.Text.StringBuilder();
			sb.Append( "{\"ok\":true" );
			sb.Append( ",\"assetsDir\":" ).Append( J( assetsDir ) );
			sb.Append( ",\"projectName\":" ).Append( J( projectName ) );
			sb.Append( ",\"diag\":{" );
			sb.Append( "\"discoveryMethod\":" ).Append( J( discoveryMethod ) );
			sb.Append( ",\"projectCurrentRoot\":" ).Append( J( projectCurrentRoot ) );
			sb.Append( ",\"sessionState\":" ).Append( J( sessionState ) );
			sb.Append( ",\"sceneState\":" ).Append( J( sceneState ) );
			sb.Append( ",\"sceneResourcePath\":" ).Append( J( sceneResourcePath ) );
			sb.Append( ",\"fullScenePath\":" ).Append( J( fullScenePath ) );
			sb.Append( ",\"discoveryError\":" ).Append( J( discoveryError ) );
			sb.Append( "}}" );
			return sb.ToString();
		}

		internal static string GetProjectAssetsDir()
		{
			// Primary: Project.Current — works regardless of whether a scene is
			// loaded or saved. The editor sets this when the project opens.
			try
			{
				if ( Sandbox.Project.Current != null && Sandbox.Project.Current.HasAssetsPath() )
					return Sandbox.Project.Current.GetAssetsPath();
			}
			catch { }

			// Fallback: walk up from the active scene's resource path. Useful in
			// edge cases where Project.Current isn't set but a scene is.
			try
			{
				var session = SceneEditorSession.Active;
				if ( session?.Scene != null )
				{
					var scenePath = session.Scene.Source?.ResourcePath;
					if ( !string.IsNullOrEmpty( scenePath ) )
					{
						var fullScenePath = Sandbox.FileSystem.Mounted.GetFullPath( scenePath );
						if ( !string.IsNullOrEmpty( fullScenePath ) )
						{
							var dir = System.IO.Path.GetDirectoryName( fullScenePath );
							while ( dir != null )
							{
								var candidate = System.IO.Path.Combine( dir, "Assets" );
								if ( System.IO.Directory.Exists( candidate ) ) return candidate;
								dir = System.IO.Path.GetDirectoryName( dir );
							}
						}
					}
				}
			}
			catch { }

			return null;
		}

		private static Material LoadMaterialSafe( string path )
		{
			if ( string.IsNullOrEmpty( path ) ) return null;
			try
			{
				// The addon rewrites generated vmats IN PLACE (same path) when a
				// material is edited. Material.Load can win the race against the
				// asset watcher and return the stale compiled version — live
				// material edits then only show up on the NEXT load (a dup, a
				// restart). Nudge the compile first; no-op when up to date.
				try { AssetSystem.FindByPath( path )?.Compile( false ); }
				catch { }
				return Material.Load( path );
			}
			catch { return null; }
		}
	}
}