From 62f07a827c7c8c888771bec88756a74a86251b8d Mon Sep 17 00:00:00 2001 From: silicoky Date: Sat, 16 May 2026 13:22:19 -0400 Subject: [PATCH 1/2] Rename JSON importer to .oae extension; spawner + collider polish The Wave 2 task A brief and ADR-0002 chose to register a ScriptedImporter for the .json extension. Unity rejects that at runtime - the built-in TextAsset importer owns .json and scripted importers cannot claim it. This PR switches to a dedicated .oae (OpenApparatus Environment) extension, documented in new ADR-0008 (supersedes ADR-0002 on the .json point only). Alongside the rename, several spawner and inspector improvements landed: Importer / inspector - JsonEnvironmentImporter now claims ".oae" and exposes ColliderMode + Substitution as serialized fields persisted in the .meta file. - JsonEnvironmentImporterEditor (new) hosts the spawn options + button in the editable top "Import Settings" section. Unity 6 locks the bottom "Imported Object" section, where they previously lived. - Inspector now lists each environment's Object Types so researchers see exactly what strings to put in PrefabSubstitutionTable entries. - MultiRoomEnvironmentAssetEditor reduced to a passive summary. Spawner - Each Room now gets explicit Floor and Ceiling child GameObjects in addition to per-Wall children. Each carries its own MeshFilter + MeshRenderer + per-part material via MaterialResolver. - JsonGeometryBuilder splits BuildRoomMesh into BuildFloorMesh, BuildCeilingMesh, and BuildWallMesh; the X-mirror that walls already applied is now applied to floor / ceiling tile coordinates too. - Spawner removes the primitive collider on placeholder objects when ColliderMode.Objects is not set. Collider builder - ColliderMode is now a [Flags] enum: None / Walls / Floors / Ceilings / Objects / All. Multi-select in the inspector. - FloorCollider lives under each Room's Floor child; CeilingCollider under Ceiling - symmetric with WallCollider under each Wall. - BoxCollider sizing accounts for the X-mirror correction. Prefab substitution - The new instance carries a RoomObjectInstance marker component (slot + room id + rotation) so downstream code can identify research objects after substitution. Reverses the original design from task J - the marker carry-over is the right call now that we have collider passes that need to enumerate object instances. Tests + docs - Fixture Tests/Fixtures/single_room.json renamed to .oae; .meta moved with it so the GUID stays stable. - Test path constants updated in three test files. - Sample renamed; Samples~/ImportedEnvironment/README.md updated. - ADR-0008 added; ADR-0002 marked superseded with cross-reference. - format-contracts.md and conventions.md updated for the new extension and the [Flags] ColliderMode. - Task briefs A, D, I, L updated to reference .oae fixtures. Plus several smaller cleanups: AssemblyInfo.cs grants the test assembly InternalsVisibleTo on the editor assembly so JsonEnvironmentDiscriminator can stay internal; the importer no longer emits unused per-room Mesh sub-assets (geometry is built at spawn time from RoomData). Follow-ups not in this PR: - openapparatus-core JsonExporter should write .oae files; PR pending. - openapparatus-studio file-save dialogs should default to .oae; PR pending. - Wall-mesh winding may still extrude inward on some wall orientations; to be revisited if production geometry continues to come from this builder rather than Studio's GltfExporter via task B. --- .gitignore | 3 + Editor/AssemblyInfo.cs.meta | 2 + Editor/Importers/JsonEnvironmentImporter.cs | 20 +-- .../JsonEnvironmentImporterEditor.cs | 98 ++++++++++++ .../JsonEnvironmentImporterEditor.cs.meta | 2 + .../MultiRoomEnvironmentAssetEditor.cs | 40 ++--- Editor/Internal/ColliderBuilder.cs | 58 +++++-- Editor/Internal/EnvironmentSpawner.cs | 61 +++++--- Editor/Internal/JsonGeometryBuilder.cs | 142 ++++++++++-------- .../Internal/PrefabSubstitutionApplicator.cs | 15 ++ Runtime/Enums/ColliderMode.cs | 13 +- Samples~/ImportedEnvironment/README.md | 29 ++-- .../{single_room.json => single_room.oae} | 0 Tests/Editor/ColliderBuilderTests.cs | 106 ++++++++++++- Tests/Editor/JsonEnvironmentImporterTests.cs | 4 +- .../PrefabSubstitutionApplicatorTests.cs | 2 +- .../{single_room.json => single_room.oae} | 0 ...le_room.json.meta => single_room.oae.meta} | 0 docs/conventions.md | 28 +++- docs/decisions.md | 60 +++++++- docs/format-contracts.md | 21 ++- docs/roadmap.md | 6 +- docs/tasks/A-json-importer.md | 8 +- docs/tasks/D-samples.md | 6 +- docs/tasks/I-collider-generation.md | 4 +- docs/tasks/L-studio-api-client.md | 2 +- 26 files changed, 541 insertions(+), 189 deletions(-) create mode 100644 Editor/AssemblyInfo.cs.meta create mode 100644 Editor/Importers/JsonEnvironmentImporterEditor.cs create mode 100644 Editor/Importers/JsonEnvironmentImporterEditor.cs.meta rename Samples~/ImportedEnvironment/{single_room.json => single_room.oae} (100%) rename Tests/Fixtures/{single_room.json => single_room.oae} (100%) rename Tests/Fixtures/{single_room.json.meta => single_room.oae.meta} (100%) diff --git a/.gitignore b/.gitignore index e3c58d1..ecdadbc 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ # OS .DS_Store Thumbs.db + +# Session worktrees from Claude Code agent runs. +.claude/ diff --git a/Editor/AssemblyInfo.cs.meta b/Editor/AssemblyInfo.cs.meta new file mode 100644 index 0000000..93dbd87 --- /dev/null +++ b/Editor/AssemblyInfo.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 950b686a795e86f4dba7a9a224d9bf21 \ No newline at end of file diff --git a/Editor/Importers/JsonEnvironmentImporter.cs b/Editor/Importers/JsonEnvironmentImporter.cs index a10afe0..dcec130 100644 --- a/Editor/Importers/JsonEnvironmentImporter.cs +++ b/Editor/Importers/JsonEnvironmentImporter.cs @@ -11,9 +11,14 @@ namespace OpenApparatus.Unity.Editor.Importers { - [ScriptedImporter(version: 1, ext: "json")] + [ScriptedImporter(version: 1, ext: "oae")] public sealed class JsonEnvironmentImporter : ScriptedImporter { + // Spawn-time settings persisted on the importer so they survive reimport. + // Editable via JsonEnvironmentImporterEditor (top "Import Settings" section). + public ColliderMode ColliderMode = ColliderMode.None; + public PrefabSubstitutionTable Substitution; + public override void OnImportAsset(AssetImportContext ctx) { string text; @@ -47,20 +52,11 @@ public override void OnImportAsset(AssetImportContext ctx) asset.ObjectSlots = MapSlots(doc.objectSlots); asset.Rooms = MapRooms(doc.rooms); asset.OutsideObjects = MapOutside(doc.outside); + asset.ColliderMode = ColliderMode; + asset.Substitution = Substitution; ctx.AddObjectToAsset("environment", asset); ctx.SetMainObject(asset); - - if (asset.Rooms != null) - { - foreach (var room in asset.Rooms) - { - var mesh = JsonGeometryBuilder.BuildRoomMesh(room, asset.Parameters, - $"OpenApparatus_Room_{room.Id}"); - ctx.AddObjectToAsset($"mesh_room_{room.Id}", mesh); - asset.SetRoomMesh(room.Id, mesh); - } - } } static EnvironmentParameters MapParameters(JsonParameters p) diff --git a/Editor/Importers/JsonEnvironmentImporterEditor.cs b/Editor/Importers/JsonEnvironmentImporterEditor.cs new file mode 100644 index 0000000..4084b85 --- /dev/null +++ b/Editor/Importers/JsonEnvironmentImporterEditor.cs @@ -0,0 +1,98 @@ +using UnityEditor; +using UnityEditor.AssetImporters; +using UnityEngine; +using OpenApparatus.Unity.Editor.Internal; + +namespace OpenApparatus.Unity.Editor.Importers +{ + [CustomEditor(typeof(JsonEnvironmentImporter))] + public sealed class JsonEnvironmentImporterEditor : ScriptedImporterEditor + { + SerializedProperty _colliderMode; + SerializedProperty _substitution; + + public override void OnEnable() + { + base.OnEnable(); + _colliderMode = serializedObject.FindProperty(nameof(JsonEnvironmentImporter.ColliderMode)); + _substitution = serializedObject.FindProperty(nameof(JsonEnvironmentImporter.Substitution)); + } + + public override void OnInspectorGUI() + { + var importer = (JsonEnvironmentImporter)target; + var asset = AssetDatabase.LoadAssetAtPath(importer.assetPath); + + DrawSummary(asset); + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Spawn options", EditorStyles.boldLabel); + + serializedObject.Update(); + EditorGUILayout.PropertyField(_colliderMode); + EditorGUILayout.PropertyField(_substitution); + serializedObject.ApplyModifiedProperties(); + + EditorGUILayout.HelpBox( + "Changing collider mode or substitution applies on reimport (Apply below) " + + "and on the next Spawn into scene.", + MessageType.Info); + + EditorGUILayout.Space(); + ApplyRevertGUI(); + + EditorGUILayout.Space(); + using (new EditorGUI.DisabledScope(asset == null)) + { + if (GUILayout.Button("Spawn into scene", GUILayout.Height(28))) + { + var root = EnvironmentSpawner.Spawn(asset); + if (root != null) Selection.activeGameObject = root; + } + } + } + + static void DrawSummary(MultiRoomEnvironmentAsset asset) + { + EditorGUILayout.LabelField("Summary", EditorStyles.boldLabel); + if (asset == null) + { + EditorGUILayout.HelpBox("No imported asset (file may have failed to parse).", + MessageType.Warning); + return; + } + EditorGUILayout.LabelField("Schema version", asset.SchemaVersion.ToString()); + EditorGUILayout.LabelField("Rooms", (asset.Rooms?.Length ?? 0).ToString()); + int objectCount = 0; + if (asset.Rooms != null) + foreach (var r in asset.Rooms) objectCount += r.Objects?.Length ?? 0; + EditorGUILayout.LabelField("Objects", objectCount.ToString()); + EditorGUILayout.LabelField("Object slots", (asset.ObjectSlots?.Length ?? 0).ToString()); + if (asset.Parameters != null) + { + EditorGUILayout.LabelField($"Tile size: {asset.Parameters.TileSize:0.##}"); + EditorGUILayout.LabelField( + $"Wall: {asset.Parameters.WallThickness:0.##} x {asset.Parameters.WallHeight:0.##}"); + } + + if (asset.ObjectSlots != null && asset.ObjectSlots.Length > 0) + { + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Object types in this environment", + EditorStyles.boldLabel); + EditorGUILayout.HelpBox( + "Use the bold type name on the left as the 'Object Type' " + + "value in a PrefabSubstitutionTable entry.", + MessageType.None); + foreach (var slot in asset.ObjectSlots) + { + var label = !string.IsNullOrEmpty(slot.ObjectType) + ? slot.ObjectType + : (slot.DisplayName ?? $"slot {slot.Id}"); + var meta = $"slot {slot.Id} · {slot.Shape ?? "?"} · size {slot.Size:0.##}"; + EditorGUILayout.LabelField(label, meta); + } + } + } + } +} diff --git a/Editor/Importers/JsonEnvironmentImporterEditor.cs.meta b/Editor/Importers/JsonEnvironmentImporterEditor.cs.meta new file mode 100644 index 0000000..582ab2f --- /dev/null +++ b/Editor/Importers/JsonEnvironmentImporterEditor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2aa7bb96d4618594f8ac6cf3291ffbd4 \ No newline at end of file diff --git a/Editor/Inspectors/MultiRoomEnvironmentAssetEditor.cs b/Editor/Inspectors/MultiRoomEnvironmentAssetEditor.cs index ae6dbc6..006c653 100644 --- a/Editor/Inspectors/MultiRoomEnvironmentAssetEditor.cs +++ b/Editor/Inspectors/MultiRoomEnvironmentAssetEditor.cs @@ -1,26 +1,20 @@ using UnityEditor; using UnityEngine; -using OpenApparatus.Unity.Editor.Internal; namespace OpenApparatus.Unity.Editor.Inspectors { + // Bottom "Imported Object" section of the .oae inspector. + // Unity locks this entire section read-only to prevent edits that would + // be lost on reimport. Spawn options + Spawn button therefore live on + // JsonEnvironmentImporterEditor (the top "Import Settings" section), not + // here. This editor stays as a passive summary. [CustomEditor(typeof(MultiRoomEnvironmentAsset))] public sealed class MultiRoomEnvironmentAssetEditor : UnityEditor.Editor { - SerializedProperty _colliderMode; - SerializedProperty _substitution; - - void OnEnable() - { - _colliderMode = serializedObject.FindProperty(nameof(MultiRoomEnvironmentAsset.ColliderMode)); - _substitution = serializedObject.FindProperty(nameof(MultiRoomEnvironmentAsset.Substitution)); - } - public override void OnInspectorGUI() { var asset = (MultiRoomEnvironmentAsset)target; - EditorGUILayout.LabelField("Summary", EditorStyles.boldLabel); EditorGUILayout.LabelField("Schema version", asset.SchemaVersion.ToString()); EditorGUILayout.LabelField("Rooms", (asset.Rooms?.Length ?? 0).ToString()); int objectCount = 0; @@ -28,29 +22,15 @@ public override void OnInspectorGUI() foreach (var r in asset.Rooms) objectCount += r.Objects?.Length ?? 0; EditorGUILayout.LabelField("Objects", objectCount.ToString()); EditorGUILayout.LabelField("Object slots", (asset.ObjectSlots?.Length ?? 0).ToString()); - - if (asset.Parameters != null) - { - EditorGUILayout.LabelField($"Tile size: {asset.Parameters.TileSize:0.##}"); - EditorGUILayout.LabelField($"Wall: {asset.Parameters.WallThickness:0.##} x {asset.Parameters.WallHeight:0.##}"); - } + EditorGUILayout.LabelField("Collider mode", asset.ColliderMode.ToString()); + EditorGUILayout.LabelField("Substitution table", + asset.Substitution != null ? asset.Substitution.name : "(none)"); EditorGUILayout.Space(); - EditorGUILayout.LabelField("Spawn options", EditorStyles.boldLabel); - serializedObject.Update(); - EditorGUILayout.PropertyField(_colliderMode); - EditorGUILayout.PropertyField(_substitution); EditorGUILayout.HelpBox( - "Collider mode and substitution table take effect on next spawn.", + "Edit spawn options and trigger spawn via the Import Settings " + + "section above (top of inspector).", MessageType.Info); - serializedObject.ApplyModifiedProperties(); - - EditorGUILayout.Space(); - if (GUILayout.Button("Spawn into scene", GUILayout.Height(28))) - { - var root = EnvironmentSpawner.Spawn(asset); - if (root != null) Selection.activeGameObject = root; - } } } } diff --git a/Editor/Internal/ColliderBuilder.cs b/Editor/Internal/ColliderBuilder.cs index 9b5673e..76d55e5 100644 --- a/Editor/Internal/ColliderBuilder.cs +++ b/Editor/Internal/ColliderBuilder.cs @@ -4,9 +4,10 @@ namespace OpenApparatus.Unity.Editor.Internal { public static class ColliderBuilder { - const string WallColliderChildName = "WallCollider"; - const string FloorCollidersChildName = "FloorColliders"; - const float FloorColliderThickness = 0.1f; + const string WallColliderChildName = "WallCollider"; + const string FloorColliderChildName = "FloorCollider"; + const string CeilingColliderChildName = "CeilingCollider"; + const float SurfaceColliderThickness = 0.1f; public static void Apply(GameObject environmentRoot, ColliderMode mode, @@ -14,16 +15,25 @@ public static void Apply(GameObject environmentRoot, { if (mode == ColliderMode.None || environmentRoot == null || parameters == null) return; - bool doWalls = mode == ColliderMode.WallsOnly || mode == ColliderMode.All; - bool doFloors = mode == ColliderMode.FloorsOnly || mode == ColliderMode.All; - - if (doWalls) + if ((mode & ColliderMode.Walls) != 0) foreach (var wall in environmentRoot.GetComponentsInChildren(includeInactive: true)) AddWallCollider(wall, parameters); - if (doFloors) + if ((mode & (ColliderMode.Floors | ColliderMode.Ceilings)) != 0) foreach (var room in environmentRoot.GetComponentsInChildren(includeInactive: true)) - AddFloorColliders(room, parameters); + { + if ((mode & ColliderMode.Floors) != 0) + AddSurfaceCollider(room, "Floor", FloorColliderChildName, + parameters, atCeiling: false); + if ((mode & ColliderMode.Ceilings) != 0) + AddSurfaceCollider(room, "Ceiling", CeilingColliderChildName, + parameters, atCeiling: true); + } + + // Placeholder colliders (ColliderMode.Objects) are owned by the + // spawner, not by this pass. Spawner removes the primitive's + // default collider when the flag is off; substituted prefabs are + // never touched (prefab author decides). } static void AddWallCollider(Wall wall, EnvironmentParameters parameters) @@ -48,23 +58,39 @@ static void AddWallCollider(Wall wall, EnvironmentParameters parameters) col.size = new Vector3(length, parameters.WallHeight, parameters.WallThickness); } - static void AddFloorColliders(Room room, EnvironmentParameters parameters) + // One BoxCollider per tile, all attached to a single child node under + // the room's Floor or Ceiling surface object. Floor sits a hair below + // y=0; ceiling sits a hair above wallHeight — both flush with the + // visible mesh but invisible to the agent. + static void AddSurfaceCollider(Room room, + string surfaceChildName, + string colliderChildName, + EnvironmentParameters parameters, + bool atCeiling) { if (room.TileIndices == null || room.TileIndices.Length == 0) return; - ClearExistingChild(room.transform, FloorCollidersChildName); + var surface = room.transform.Find(surfaceChildName); + if (surface == null) return; - var holder = new GameObject(FloorCollidersChildName); - holder.transform.SetParent(room.transform, worldPositionStays: false); + ClearExistingChild(surface, colliderChildName); + var holder = new GameObject(colliderChildName); + holder.transform.SetParent(surface, worldPositionStays: false); float t = parameters.TileSize; + float colliderCenterY = atCeiling + ? parameters.WallHeight + SurfaceColliderThickness * 0.5f + : -SurfaceColliderThickness * 0.5f; + + // Tile indices are Studio coords; mirror X to share the room's + // Unity-space frame (same as the floor/ceiling mesh builder). foreach (var idx in room.TileIndices) { var col = holder.AddComponent(); - col.size = new Vector3(t, FloorColliderThickness, t); + col.size = new Vector3(t, SurfaceColliderThickness, t); col.center = new Vector3( - (idx.x + 0.5f) * t, - -FloorColliderThickness * 0.5f, + -(idx.x + 0.5f) * t, + colliderCenterY, (idx.y + 0.5f) * t); } } diff --git a/Editor/Internal/EnvironmentSpawner.cs b/Editor/Internal/EnvironmentSpawner.cs index 71ac206..57d2c62 100644 --- a/Editor/Internal/EnvironmentSpawner.cs +++ b/Editor/Internal/EnvironmentSpawner.cs @@ -41,34 +41,37 @@ static void SpawnRoom(Transform parent, RoomData roomData, MultiRoomEnvironmentA roomComponent.GridPositionStudio = roomData.GridPositionStudio; roomComponent.TileIndices = roomData.TileIndices; - var mesh = asset.GetRoomMesh(roomData.Id); - if (mesh != null) - { - var mf = roomGo.AddComponent(); - mf.sharedMesh = mesh; - var mr = roomGo.AddComponent(); - mr.sharedMaterials = new[] - { - MaterialResolver.Resolve($"OpenApparatus_Floor_{roomData.Id}"), - MaterialResolver.Resolve($"OpenApparatus_Walls_{roomData.Id}"), - MaterialResolver.Resolve($"OpenApparatus_Ceiling_{roomData.Id}"), - }; - mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.TwoSided; - } + SpawnFloor(roomGo.transform, roomData, p); + SpawnCeiling(roomGo.transform, roomData, p); if (roomData.Walls != null) foreach (var wd in roomData.Walls) - SpawnWall(roomGo.transform, wd); + SpawnWall(roomGo.transform, wd, roomData.Id, p); if (roomData.Objects != null) foreach (var od in roomData.Objects) SpawnObject(roomGo.transform, od, roomData.Id, asset); } - static void SpawnWall(Transform parent, WallData wd) + static void SpawnFloor(Transform parent, RoomData rd, EnvironmentParameters p) { - var go = new GameObject($"Wall_{wd.Number}"); - go.transform.SetParent(parent, worldPositionStays: false); + var go = CreateMeshChild(parent, "Floor", + JsonGeometryBuilder.BuildFloorMesh(rd, p, $"OpenApparatus_Floor_{rd.Id}"), + MaterialResolver.Resolve($"OpenApparatus_Floor_{rd.Id}")); + } + + static void SpawnCeiling(Transform parent, RoomData rd, EnvironmentParameters p) + { + var go = CreateMeshChild(parent, "Ceiling", + JsonGeometryBuilder.BuildCeilingMesh(rd, p, $"OpenApparatus_Ceiling_{rd.Id}"), + MaterialResolver.Resolve($"OpenApparatus_Ceiling_{rd.Id}")); + } + + static void SpawnWall(Transform parent, WallData wd, int roomId, EnvironmentParameters p) + { + var go = CreateMeshChild(parent, $"Wall_{wd.Number}", + JsonGeometryBuilder.BuildWallMesh(wd, p, $"OpenApparatus_Walls_{roomId}_{wd.Number}"), + MaterialResolver.Resolve($"OpenApparatus_Walls_{roomId}_{wd.Number}")); var w = go.AddComponent(); w.WallNumber = wd.Number; @@ -79,6 +82,18 @@ static void SpawnWall(Transform parent, WallData wd) w.Openings = wd.Openings; } + static GameObject CreateMeshChild(Transform parent, string name, Mesh mesh, Material material) + { + var go = new GameObject(name); + go.transform.SetParent(parent, worldPositionStays: false); + var mf = go.AddComponent(); + mf.sharedMesh = mesh; + var mr = go.AddComponent(); + mr.sharedMaterial = material; + mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.TwoSided; + return go; + } + static void SpawnObject(Transform parent, ObjectInstanceData od, int owningRoomId, MultiRoomEnvironmentAsset asset) { @@ -92,6 +107,16 @@ static void SpawnObject(Transform parent, ObjectInstanceData od, int owningRoomI if (slot != null && slot.Size > 0f) go.transform.localScale = Vector3.one * slot.Size; + // CreatePrimitive auto-attaches a Collider matching the shape. + // The Objects flag in ColliderMode controls whether placeholders + // keep that collider. Substituted prefabs are unaffected — their + // collider is whatever the prefab author shipped. + if ((asset.ColliderMode & ColliderMode.Objects) == 0) + { + foreach (var c in go.GetComponents()) + Object.DestroyImmediate(c); + } + var instance = go.AddComponent(); instance.Slot = od.Slot; instance.OwningRoomId = owningRoomId; diff --git a/Editor/Internal/JsonGeometryBuilder.cs b/Editor/Internal/JsonGeometryBuilder.cs index b5eeb03..f9af906 100644 --- a/Editor/Internal/JsonGeometryBuilder.cs +++ b/Editor/Internal/JsonGeometryBuilder.cs @@ -5,98 +5,112 @@ namespace OpenApparatus.Unity.Editor.Internal { internal static class JsonGeometryBuilder { - public static Mesh BuildRoomMesh(RoomData room, EnvironmentParameters p, string meshName) + // Tile indices are in Studio coords (right-handed, +X east). Wall + // start/end positions stored on WallData are already mirrored to + // Unity by the importer. Floor and ceiling vertices must apply the + // same X-mirror so they share the room's coordinate frame; otherwise + // floors sit at +X while walls sit at -X (a 7-unit gap). + public static Mesh BuildFloorMesh(RoomData room, EnvironmentParameters p, string meshName) { var verts = new List(); var normals = new List(); var uvs = new List(); - var floorTris = new List(); - var wallTris = new List(); - var ceilingTris = new List(); + var tris = new List(); - BuildTileSurfaces(room, p, verts, normals, uvs, floorTris, ceilingTris); - - if (room.Walls != null) - foreach (var w in room.Walls) - BuildWall(w, p, verts, normals, uvs, wallTris); - - var mesh = new Mesh { name = meshName }; - mesh.indexFormat = verts.Count > 65000 - ? UnityEngine.Rendering.IndexFormat.UInt32 - : UnityEngine.Rendering.IndexFormat.UInt16; - mesh.SetVertices(verts); - mesh.SetNormals(normals); - mesh.SetUVs(0, uvs); - mesh.subMeshCount = 3; - mesh.SetTriangles(floorTris, 0, calculateBounds: false); - mesh.SetTriangles(wallTris, 1, calculateBounds: false); - mesh.SetTriangles(ceilingTris, 2, calculateBounds: false); - mesh.RecalculateBounds(); - return mesh; + if (room.TileIndices != null) + { + float t = p.TileSize; + foreach (var idx in room.TileIndices) + { + // After X-mirror the lower bound becomes -(idx.x+1)*t + // and the upper bound -idx.x*t so we still have x0 < x1. + float x0 = -(idx.x + 1) * t, z0 = idx.y * t; + float x1 = -idx.x * t, z1 = z0 + t; + AddQuad(verts, normals, uvs, tris, + new Vector3(x0, 0, z0), new Vector3(x1, 0, z0), + new Vector3(x1, 0, z1), new Vector3(x0, 0, z1), + Vector3.up); + } + } + return BuildMesh(meshName, verts, normals, uvs, tris); } - static void BuildTileSurfaces(RoomData room, EnvironmentParameters p, - List verts, List normals, - List uvs, - List floorTris, List ceilingTris) + public static Mesh BuildCeilingMesh(RoomData room, EnvironmentParameters p, string meshName) { - if (room.TileIndices == null) return; - float t = p.TileSize; - float h = p.WallHeight; + var verts = new List(); + var normals = new List(); + var uvs = new List(); + var tris = new List(); - foreach (var idx in room.TileIndices) + if (room.TileIndices != null) { - float x0 = idx.x * t; - float z0 = idx.y * t; - float x1 = x0 + t; - float z1 = z0 + t; - - AddQuad(verts, normals, uvs, floorTris, - new Vector3(x0, 0, z0), new Vector3(x1, 0, z0), - new Vector3(x1, 0, z1), new Vector3(x0, 0, z1), - Vector3.up); - - AddQuad(verts, normals, uvs, ceilingTris, - new Vector3(x0, h, z1), new Vector3(x1, h, z1), - new Vector3(x1, h, z0), new Vector3(x0, h, z0), - Vector3.down); + float t = p.TileSize; + float h = p.WallHeight; + foreach (var idx in room.TileIndices) + { + float x0 = -(idx.x + 1) * t, z0 = idx.y * t; + float x1 = -idx.x * t, z1 = z0 + t; + AddQuad(verts, normals, uvs, tris, + new Vector3(x0, h, z1), new Vector3(x1, h, z1), + new Vector3(x1, h, z0), new Vector3(x0, h, z0), + Vector3.down); + } } + return BuildMesh(meshName, verts, normals, uvs, tris); } - static void BuildWall(WallData wall, EnvironmentParameters p, - List verts, List normals, - List uvs, List tris) + public static Mesh BuildWallMesh(WallData wall, EnvironmentParameters p, string meshName) { + var verts = new List(); + var normals = new List(); + var uvs = new List(); + var tris = new List(); + var start = wall.StartLocal; var end = wall.EndLocal; var delta = end - start; float length = delta.magnitude; - if (length < 1e-4f) return; + if (length < 1e-4f) return BuildMesh(meshName, verts, normals, uvs, tris); float h = p.WallHeight; float th = p.WallThickness; var tangent = delta / length; var inward = new Vector3(-tangent.z, 0, tangent.x); + var inwardOffset = inward * th; - var a = start; - var b = end; - var c = end + Vector3.up * h; - var d = start + Vector3.up * h; - - var inward2 = inward * th; - var ai = a + inward2; - var bi = b + inward2; - var ci = c + inward2; - var di = d + inward2; + var a = start; + var b = end; + var c = end + Vector3.up * h; + var d = start + Vector3.up * h; + var ai = a + inwardOffset; + var bi = b + inwardOffset; + var ci = c + inwardOffset; + var di = d + inwardOffset; - var outwardNormal = -inward; - var inwardNormal = inward; - - AddQuad(verts, normals, uvs, tris, a, b, c, d, outwardNormal); - AddQuad(verts, normals, uvs, tris, bi, ai, di, ci, inwardNormal); + AddQuad(verts, normals, uvs, tris, a, b, c, d, -inward); + AddQuad(verts, normals, uvs, tris, bi, ai, di, ci, inward); AddQuad(verts, normals, uvs, tris, b, bi, ci, c, tangent); AddQuad(verts, normals, uvs, tris, ai, a, d, di, -tangent); AddQuad(verts, normals, uvs, tris, d, c, ci, di, Vector3.up); + + return BuildMesh(meshName, verts, normals, uvs, tris); + } + + static Mesh BuildMesh(string name, + List verts, List normals, + List uvs, List tris) + { + var mesh = new Mesh { name = name }; + mesh.indexFormat = verts.Count > 65000 + ? UnityEngine.Rendering.IndexFormat.UInt32 + : UnityEngine.Rendering.IndexFormat.UInt16; + mesh.SetVertices(verts); + mesh.SetNormals(normals); + mesh.SetUVs(0, uvs); + mesh.subMeshCount = 1; + mesh.SetTriangles(tris, 0, calculateBounds: false); + mesh.RecalculateBounds(); + return mesh; } static void AddQuad(List verts, List normals, diff --git a/Editor/Internal/PrefabSubstitutionApplicator.cs b/Editor/Internal/PrefabSubstitutionApplicator.cs index 0344fa8..cc87d17 100644 --- a/Editor/Internal/PrefabSubstitutionApplicator.cs +++ b/Editor/Internal/PrefabSubstitutionApplicator.cs @@ -50,6 +50,13 @@ static void SwapPlaceholder(RoomObjectInstance placeholder, SubstitutionEntry en var basePos = placeholder.transform.localPosition; var baseRot = placeholder.transform.localRotation; + // Capture the placeholder's slot metadata so we can carry it onto + // the substituted instance. Downstream tools (ColliderBuilder, + // researcher code) identify research objects via RoomObjectInstance. + int slot = placeholder.Slot; + int owningRoomId = placeholder.OwningRoomId; + float rotationY = placeholder.LocalRotationY; + GameObject instance = null; if (PrefabUtility.IsPartOfPrefabAsset(entry.Prefab)) instance = (GameObject)PrefabUtility.InstantiatePrefab(entry.Prefab); @@ -71,6 +78,14 @@ static void SwapPlaceholder(RoomObjectInstance placeholder, SubstitutionEntry en prefabScale.y * scale.y, prefabScale.z * scale.z); + // Marker survives the swap. The scene instance is mutable; the + // source prefab asset is untouched. + var marker = instance.GetComponent() + ?? instance.AddComponent(); + marker.Slot = slot; + marker.OwningRoomId = owningRoomId; + marker.LocalRotationY = rotationY; + Object.DestroyImmediate(placeholder.gameObject); } } diff --git a/Runtime/Enums/ColliderMode.cs b/Runtime/Enums/ColliderMode.cs index 65b239e..c3c420c 100644 --- a/Runtime/Enums/ColliderMode.cs +++ b/Runtime/Enums/ColliderMode.cs @@ -1,10 +1,15 @@ +using System; + namespace OpenApparatus.Unity { + [Flags] public enum ColliderMode { - None, - WallsOnly, - FloorsOnly, - All, + None = 0, + Walls = 1 << 0, + Floors = 1 << 1, + Ceilings = 1 << 2, + Objects = 1 << 3, + All = Walls | Floors | Ceilings | Objects, } } diff --git a/Samples~/ImportedEnvironment/README.md b/Samples~/ImportedEnvironment/README.md index 6b94980..c7efdb8 100644 --- a/Samples~/ImportedEnvironment/README.md +++ b/Samples~/ImportedEnvironment/README.md @@ -1,8 +1,8 @@ # ImportedEnvironment sample -A minimal demonstration of the JSON import flow. +A minimal demonstration of the environment import flow. -`single_room.json` is a one-room Studio export. When you import this +`single_room.oae` is a one-room Studio export. When you import this sample into your project, Unity runs `JsonEnvironmentImporter` and produces a `MultiRoomEnvironmentAsset`. @@ -11,19 +11,26 @@ produces a `MultiRoomEnvironmentAsset`. 1. Open the Unity Package Manager and find OpenApparatus. 2. Expand **Samples**, click **Import** next to *ImportedEnvironment*. 3. In `Assets/Samples/OpenApparatus//ImportedEnvironment/`, - select `single_room.json`. -4. The inspector shows the import summary and a **Spawn into scene** - button. Click it to materialise the room. + select `single_room.oae`. +4. The inspector shows the import summary, **Spawn options**, and a + **Spawn into scene** button. Click it to materialise the room. ## What you can change -- **Collider mode** (in the asset inspector) — toggle box colliders - on walls and floor tiles before re-spawning. -- **Prefab substitution table** — assign a `PrefabSubstitutionTable` - asset to swap the placeholder objects with your own prefabs. +- **Collider Mode** — multi-select flag (`Walls`, `Floors`, `Ceilings`, + `Objects`) controlling which colliders are generated at spawn. +- **Substitution** — assign a `PrefabSubstitutionTable` asset to swap + placeholder objects with your own prefabs. + +## Why `.oae` and not `.json`? + +Unity owns the `.json` file extension via its built-in TextAsset +importer; scripted importers cannot claim it. We use the +`.oae` (OpenApparatus Environment) extension instead. The file +contents are still JSON-formatted — open in any text editor. ## Where to go next For a full Studio export with multiple rooms and authored materials, -export from OpenApparatus Studio and drop the resulting `.json` (and -companion `.glb` for high-fidelity geometry) into your `Assets/`. +export from OpenApparatus Studio and drop the resulting `.oae` (and +optional companion `.glb` for high-fidelity geometry) into your `Assets/`. diff --git a/Samples~/ImportedEnvironment/single_room.json b/Samples~/ImportedEnvironment/single_room.oae similarity index 100% rename from Samples~/ImportedEnvironment/single_room.json rename to Samples~/ImportedEnvironment/single_room.oae diff --git a/Tests/Editor/ColliderBuilderTests.cs b/Tests/Editor/ColliderBuilderTests.cs index bc99cc5..7b6e8f7 100644 --- a/Tests/Editor/ColliderBuilderTests.cs +++ b/Tests/Editor/ColliderBuilderTests.cs @@ -7,14 +7,25 @@ namespace OpenApparatus.Unity.Tests.Editor { public sealed class ColliderBuilderTests { - const string FixturePath = "Packages/com.openapparatus.unity/Tests/Fixtures/single_room.json"; + const string FixturePath = "Packages/com.openapparatus.unity/Tests/Fixtures/single_room.oae"; - [TestCase(ColliderMode.None, 0, 0)] - [TestCase(ColliderMode.WallsOnly, 4, 0)] - [TestCase(ColliderMode.FloorsOnly, 0, 4)] - [TestCase(ColliderMode.All, 4, 4)] + // Object collider count is tracked separately because primitive + // placeholders already carry their own collider from CreatePrimitive + // (one per RoomObjectInstance, regardless of mode); only the Objects + // flag *additionally* ensures substituted prefabs without colliders + // get one. The fixture has no substitution, so toggling Objects does + // not change the unprimitive-collider count. + [TestCase(ColliderMode.None, 0, 0, 0)] + [TestCase(ColliderMode.Walls, 4, 0, 0)] + [TestCase(ColliderMode.Floors, 0, 4, 0)] + [TestCase(ColliderMode.Ceilings, 0, 0, 4)] + [TestCase(ColliderMode.Walls | ColliderMode.Floors, 4, 4, 0)] + [TestCase(ColliderMode.All, 4, 4, 4)] public void Apply_ProducesExpectedColliderCounts( - ColliderMode mode, int expectedWallColliders, int expectedFloorColliders) + ColliderMode mode, + int expectedWallColliders, + int expectedFloorColliders, + int expectedCeilingColliders) { var asset = AssetDatabase.LoadAssetAtPath(FixturePath); asset.ColliderMode = mode; @@ -24,15 +35,18 @@ public void Apply_ProducesExpectedColliderCounts( root = EnvironmentSpawner.Spawn(asset); int wallColliders = 0; int floorColliders = 0; + int ceilingColliders = 0; foreach (var col in root.GetComponentsInChildren(includeInactive: true)) { var name = col.gameObject.name; - if (name == "WallCollider") wallColliders++; - else if (name == "FloorColliders") floorColliders++; + if (name == "WallCollider") wallColliders++; + else if (name == "FloorCollider") floorColliders++; + else if (name == "CeilingCollider") ceilingColliders++; else Assert.Fail($"Unexpected BoxCollider parent: {name}"); } Assert.AreEqual(expectedWallColliders, wallColliders, "wall collider count"); Assert.AreEqual(expectedFloorColliders, floorColliders, "floor tile collider count"); + Assert.AreEqual(expectedCeilingColliders, ceilingColliders, "ceiling tile collider count"); } finally { @@ -40,5 +54,81 @@ public void Apply_ProducesExpectedColliderCounts( asset.ColliderMode = ColliderMode.None; } } + + [Test] + public void Placeholder_KeepsColliderWhenObjectsFlagSet() + { + var asset = AssetDatabase.LoadAssetAtPath(FixturePath); + asset.ColliderMode = ColliderMode.Objects; + GameObject root = null; + try + { + root = EnvironmentSpawner.Spawn(asset); + var placeholder = root.GetComponentInChildren(); + Assert.IsNotNull(placeholder); + Assert.IsNotNull(placeholder.GetComponent(), + "Placeholder should keep its default primitive collider when Objects flag is on."); + } + finally + { + if (root != null) Object.DestroyImmediate(root); + asset.ColliderMode = ColliderMode.None; + } + } + + [Test] + public void Placeholder_DropsColliderWhenObjectsFlagOff() + { + var asset = AssetDatabase.LoadAssetAtPath(FixturePath); + asset.ColliderMode = ColliderMode.None; + GameObject root = null; + try + { + root = EnvironmentSpawner.Spawn(asset); + var placeholder = root.GetComponentInChildren(); + Assert.IsNotNull(placeholder); + Assert.IsNull(placeholder.GetComponent(), + "Placeholder should have its primitive collider removed when Objects flag is off."); + } + finally + { + if (root != null) Object.DestroyImmediate(root); + } + } + + [Test] + public void SubstitutedPrefab_ColliderIsPrefabAuthorsChoice() + { + var asset = AssetDatabase.LoadAssetAtPath(FixturePath); + asset.ColliderMode = ColliderMode.Objects; // Should not influence prefabs. + + // Bare prefab with no collider — substitution should NOT add one. + var bare = new GameObject("BarePrefab", typeof(MeshFilter), typeof(MeshRenderer)); + var table = ScriptableObject.CreateInstance(); + table.Entries = new[] + { + new SubstitutionEntry { ObjectType = "Cup", Prefab = bare, + ScaleMultiplier = Vector3.one } + }; + asset.Substitution = table; + + GameObject root = null; + try + { + root = EnvironmentSpawner.Spawn(asset); + var instance = root.GetComponentInChildren(); + Assert.IsNotNull(instance); + Assert.IsNull(instance.GetComponentInChildren(), + "Objects flag must not add a collider to a substituted prefab."); + } + finally + { + if (root != null) Object.DestroyImmediate(root); + Object.DestroyImmediate(bare); + asset.Substitution = null; + Object.DestroyImmediate(table); + asset.ColliderMode = ColliderMode.None; + } + } } } diff --git a/Tests/Editor/JsonEnvironmentImporterTests.cs b/Tests/Editor/JsonEnvironmentImporterTests.cs index 40335bc..43cffff 100644 --- a/Tests/Editor/JsonEnvironmentImporterTests.cs +++ b/Tests/Editor/JsonEnvironmentImporterTests.cs @@ -9,7 +9,7 @@ namespace OpenApparatus.Unity.Tests.Editor { public sealed class JsonEnvironmentImporterTests { - const string FixturePath = "Packages/com.openapparatus.unity/Tests/Fixtures/single_room.json"; + const string FixturePath = "Packages/com.openapparatus.unity/Tests/Fixtures/single_room.oae"; const string ForeignPath = "Packages/com.openapparatus.unity/Tests/Fixtures/foreign.json"; [Test] @@ -36,7 +36,7 @@ public void Discriminator_RejectsMalformedJson() public void Import_ProducesMultiRoomEnvironmentAsset() { var asset = AssetDatabase.LoadAssetAtPath(FixturePath); - Assert.IsNotNull(asset, "single_room.json should import as MultiRoomEnvironmentAsset."); + Assert.IsNotNull(asset, "single_room.oae should import as MultiRoomEnvironmentAsset."); Assert.AreEqual(1, asset.Rooms.Length); Assert.AreEqual(4, asset.Rooms[0].Walls.Length); Assert.AreEqual(1, asset.Rooms[0].Objects.Length); diff --git a/Tests/Editor/PrefabSubstitutionApplicatorTests.cs b/Tests/Editor/PrefabSubstitutionApplicatorTests.cs index d446d20..d6f6638 100644 --- a/Tests/Editor/PrefabSubstitutionApplicatorTests.cs +++ b/Tests/Editor/PrefabSubstitutionApplicatorTests.cs @@ -8,7 +8,7 @@ namespace OpenApparatus.Unity.Tests.Editor { public sealed class PrefabSubstitutionApplicatorTests { - const string FixturePath = "Packages/com.openapparatus.unity/Tests/Fixtures/single_room.json"; + const string FixturePath = "Packages/com.openapparatus.unity/Tests/Fixtures/single_room.oae"; [Test] public void Apply_NullTable_LeavesPlaceholdersUntouched() diff --git a/Tests/Fixtures/single_room.json b/Tests/Fixtures/single_room.oae similarity index 100% rename from Tests/Fixtures/single_room.json rename to Tests/Fixtures/single_room.oae diff --git a/Tests/Fixtures/single_room.json.meta b/Tests/Fixtures/single_room.oae.meta similarity index 100% rename from Tests/Fixtures/single_room.json.meta rename to Tests/Fixtures/single_room.oae.meta diff --git a/docs/conventions.md b/docs/conventions.md index f3e29c7..1147575 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -59,8 +59,10 @@ stays as-is — Wave 2 doesn't refactor existing code without reason. - One public type per `.cs` file. Filename matches the type. - Test files: `Tests.cs`. -- Fixture files: `.json` / `.oapp`, descriptive - not numeric (`single_room.json`, not `test1.json`). +- Fixture files: `.oae` / `.oapp`, descriptive + not numeric (`single_room.oae`, not `test1.oae`). The `.oae` + (OpenApparatus Environment) extension replaces an earlier `.json` + plan — see [ADR-0008](decisions.md#adr-0008-oae-extension-for-environment-files). ## Component schemas @@ -109,12 +111,32 @@ public sealed class RoomObjectInstance : MonoBehaviour public float LocalRotationY; // radians } -public enum ColliderMode { None, WallsOnly, FloorsOnly, All } +[Flags] +public enum ColliderMode +{ + None = 0, + Walls = 1 << 0, // BoxCollider per Wall, sized + oriented to the wall segment + Floors = 1 << 1, // BoxCollider per tile, under each Room's Floor child + Ceilings = 1 << 2, // BoxCollider per tile, under each Room's Ceiling child + Objects = 1 << 3, // Keep the primitive collider on placeholder objects + All = Walls | Floors | Ceilings | Objects, +} // MultiRoomEnvironmentAsset carries these two fields (added for tasks I and J): // public ColliderMode ColliderMode; // public PrefabSubstitutionTable Substitution; // null = no substitution +// Spawn structure under each Room: +// Room_X/ +// Floor ← MeshRenderer +// FloorCollider ← BoxCollider(s), one per tile (Floors flag) +// Ceiling ← MeshRenderer +// CeilingCollider ← BoxCollider(s), one per tile (Ceilings flag) +// Wall_N/ ← MeshRenderer + Wall component +// WallCollider ← BoxCollider (Walls flag) +// Object_SlotN ← primitive + RoomObjectInstance (collider iff Objects flag) +// or substituted prefab + RoomObjectInstance marker + public sealed class PrefabSubstitutionTable : ScriptableObject { public SubstitutionEntry[] Entries; diff --git a/docs/decisions.md b/docs/decisions.md index 1e4469f..a103a23 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -43,7 +43,9 @@ must remember to republish. ## ADR-0002: Importer patterns — ScriptedImporter for `.json`/`.oapp`, AssetPostprocessor for glTF -**Status:** Accepted, 2026-05-14. +**Status:** Superseded by [ADR-0008](#adr-0008-oae-extension-for-environment-files) on 2026-05-16. The `ScriptedImporter`-for-`.json` part was incorrect — Unity rejects scripted-importer registrations on extensions it handles natively. Replaced with `.oae`. The glTF postprocessor decision stands. + +**Status (historical):** Accepted, 2026-05-14. **Context.** Three import flows, three Unity APIs available: @@ -285,3 +287,59 @@ endpoint contract, only the transport address differs. address differs. Adding it would slot in as a `RemoteGltfConverter` alongside today's `StudioGltfConverter` without breaking either consumer. + + +## ADR-0008: `.oae` extension for environment files + +**Status:** Accepted, 2026-05-16. Supersedes [ADR-0002](#adr-0002-importer-patterns---scriptedimporter-for-jsonoapp-assetpostprocessor-for-gltf) on the `.json` choice. + +**Context.** ADR-0002 chose `ScriptedImporter` with the `.json` +extension to import Studio's environment exports. Discovered at +implementation time: Unity rejects `ScriptedImporter` registrations +on extensions handled by built-in importers (`.json` is owned by the +default TextAsset importer). The log message is unambiguous: + +> The scripted importer 'JsonEnvironmentImporter' attempted to register +> file type '.json', which is handled by a native Unity importer. +> Registration rejected. + +Workarounds considered: + +1. Use `AssetPostprocessor.OnPostprocessAllAssets` to watch + `.json` imports, detect ours via discriminator, and side-create a + `MultiRoomEnvironmentAsset` next to the source file. +2. Use a custom extension that Unity doesn't already own. + +**Decision.** Option 2. Studio exports go to `.oae` +(OpenApparatus Environment). The file's contents remain valid JSON — +researchers can open in any text editor — but the extension is ours, +and the `ScriptedImporter`-based design from ADR-0002 works as +intended. + +**Why not option 1.** Two assets per import (`foo.json` TextAsset + +`foo.asset` MultiRoomEnvironmentAsset) is clunky to manage, doubles +the AssetDatabase entries, and breaks the clean main-asset +relationship the spawn flow depends on. Half a step from doing it +right. + +**Alternatives rejected.** + +- *`.openapparatus.json` / `.oa.json`* — Unity matches on the last + extension only, so these still register as `.json`. No-op. +- *Keep `.json` and skip the importer; rely on a manual menu item.* — + Loses drag-and-drop ergonomics. Substandard researcher workflow. + +**Consequences.** + +- Cross-repo coordination required: + - `openapparatus-core`'s `JsonExporter` should write `.oae` files + (or accept an extension config). Tracked as a follow-up PR. + - `openapparatus-studio`'s file-save dialogs should default to + `.oae`. Tracked as a follow-up PR. +- Until those PRs land, researchers manually rename Studio's `.json` + exports to `.oae` after saving. Documented in the sample README. +- The schema discriminator (ADR-0006) still applies for the contents, + in case someone hand-renames an unrelated `.json` to `.oae`. +- The companion glTF flow (ADR-0002, AssetPostprocessor) is + unaffected — Unity's glTF importer is fine with the postprocessor + pattern; only the JSON path needed the rename. diff --git a/docs/format-contracts.md b/docs/format-contracts.md index 7aa1f3b..4048594 100644 --- a/docs/format-contracts.md +++ b/docs/format-contracts.md @@ -6,12 +6,21 @@ here first. Authoritative source files in `openapparatus-core`: -| Format | Schema source | Writer | -|---|---|---| -| JSON (semantic) | `src/OpenApparatus.IO/Exporters/JsonExporter.cs` | `JsonExporter.SchemaVersion = 3` | -| `.oapp` (project) | `src/OpenApparatus.IO/ProjectFile.cs` | `ProjectIO.CurrentVersion = "1.0"` | -| glTF (.glb / .gltf) | `src/OpenApparatus.IO/Exporters/GltfExporter.cs` | glTF 2.0 + naming convention | -| OBJ + MTL | `src/OpenApparatus.IO/Exporters/ObjExporter.cs` | Wavefront; already handled by existing postprocessor | +| Format | Extension | Schema source | Writer | +|---|---|---|---| +| Environment (semantic, JSON-formatted) | `.oae` | `src/OpenApparatus.IO/Exporters/JsonExporter.cs` | `JsonExporter.SchemaVersion = 3` | +| Project | `.oapp` | `src/OpenApparatus.IO/ProjectFile.cs` | `ProjectIO.CurrentVersion = "1.0"` | +| glTF | `.glb` / `.gltf` | `src/OpenApparatus.IO/Exporters/GltfExporter.cs` | glTF 2.0 + naming convention | +| OBJ + MTL | `.obj` / `.mtl` | `src/OpenApparatus.IO/Exporters/ObjExporter.cs` | Wavefront; already handled by existing postprocessor | + +The environment format's file extension is **`.oae`** (OpenApparatus +Environment), not `.json`. Unity claims `.json` natively, so a +`ScriptedImporter` cannot register for it. The file's *contents* are +still valid JSON — researchers can open in any text editor. See +[ADR-0008](decisions.md#adr-0008-oae-extension-for-environment-files). +Until `openapparatus-core` and `openapparatus-studio` are updated to +write `.oae` by default, exporters write `.json` and users rename +after export. ## Coordinate handedness diff --git a/docs/roadmap.md b/docs/roadmap.md index d0584e0..5ecdad8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -90,7 +90,7 @@ the resolver work end-to-end. Independent files: -- A → `Editor/Importers/Json*.cs`, `Tests/Fixtures/single_room.json` +- A → `Editor/Importers/Json*.cs`, `Tests/Fixtures/single_room.oae` - B → `Editor/Importers/Gltf*.cs`, `Tests/Fixtures/single_room.glb` - D → `Samples~/ImportedEnvironment/`, `package.json` (`samples` block) - E → `Tests/Editor/`, `.github/workflows/test.yml` @@ -102,7 +102,7 @@ Independent files: Shared touch-point: `Tests/Fixtures/`. If A and B both add fixtures, they may compete on naming. Task briefs prescribe distinct fixture -names (`single_room.json` for A, `single_room.glb` for B) so this +names (`single_room.oae` for A, `single_room.glb` for B) so this doesn't blow up. Tasks I and J both call into the spawn sequence that task A introduces. @@ -144,7 +144,7 @@ are in [decisions.md § ADR-0007](decisions.md#adr-0007-studio-local-http-api-ra **Wave 2 done when:** -- Dragging a Studio-exported `single_room.json` into Assets/ produces +- Dragging a Studio-exported `single_room.oae` into Assets/ produces a `MultiRoomEnvironmentAsset` with one Room, four Walls, correct passage data. - Clicking "Spawn into scene" produces the GameObject tree described diff --git a/docs/tasks/A-json-importer.md b/docs/tasks/A-json-importer.md index 302aa41..d419891 100644 --- a/docs/tasks/A-json-importer.md +++ b/docs/tasks/A-json-importer.md @@ -7,7 +7,7 @@ | Blocks | C (`.oapp` importer mirrors A's patterns) | | Effort | 2 days | | Repo | openapparatus-unity | -| Files touched | `Editor/Importers/Json*.cs`, `Tests/Fixtures/single_room.json` | +| Files touched | `Editor/Importers/Json*.cs`, `Tests/Fixtures/single_room.oae` | ## Goal @@ -150,7 +150,7 @@ Tests/ ├── Editor/ │ └── JsonEnvironmentImporterTests.cs └── Fixtures/ - └── single_room.json ← fixture, ~50 lines, hand-written or Studio-exported + └── single_room.oae ← fixture, ~50 lines, hand-written or Studio-exported ``` At minimum: @@ -166,7 +166,7 @@ At minimum: ## Acceptance criteria -- `single_room.json` imports as `MultiRoomEnvironmentAsset` with one +- `single_room.oae` imports as `MultiRoomEnvironmentAsset` with one Room sub-asset, one Mesh sub-asset, no errors. - A foreign `.json` (e.g. `package.json`) imports as `TextAsset` as before — the importer does not claim it. @@ -189,7 +189,7 @@ At minimum: ``` Verified by me: - All four unit tests pass. [how: ran Test Runner edit-mode] -- Manually imported single_room.json and clicked Spawn; got expected +- Manually imported single_room.oae and clicked Spawn; got expected hierarchy. [how: visual + hierarchy panel inspection] - Foreign .json files still import as TextAsset. [how: imported package.json] diff --git a/docs/tasks/D-samples.md b/docs/tasks/D-samples.md index 48d5cb3..ee3a47b 100644 --- a/docs/tasks/D-samples.md +++ b/docs/tasks/D-samples.md @@ -48,14 +48,14 @@ The sample serves three purposes: ``` Samples~/ └── ImportedEnvironment/ - ├── single_room.json ← Studio export, ~50 lines + ├── single_room.oae ← Studio export, ~50 lines ├── ImportedEnvironment.unity ← scene with EnvironmentRoot already spawned └── README.md ← three-line orientation ``` The scene should: -- Reference `single_room.json` as an imported asset (so opening the +- Reference `single_room.oae` as an imported asset (so opening the scene triggers the import if not already done). - Have one `EnvironmentRoot` GameObject with the spawned tree as children. @@ -113,7 +113,7 @@ Verified by me: - Import copies the files to the expected location. [how: file system check] - The .unity scene opens with no console errors. [how: visual] -- The single_room.json file matches the format-contracts.md spec +- The single_room.oae file matches the format-contracts.md spec exactly. [how: round-trip through Studio] Needs your eyes: diff --git a/docs/tasks/I-collider-generation.md b/docs/tasks/I-collider-generation.md index ea27075..9fd37a5 100644 --- a/docs/tasks/I-collider-generation.md +++ b/docs/tasks/I-collider-generation.md @@ -125,7 +125,7 @@ Tests/ └── ColliderBuilderTests.cs ``` -Using the `single_room.json` fixture from task A (or a minimal +Using the `single_room.oae` fixture from task A (or a minimal programmatically-constructed asset): - **None:** spawn with `ColliderMode.None`; assert zero `BoxCollider` @@ -143,7 +143,7 @@ programmatically-constructed asset): ## Acceptance criteria - Spawning with `ColliderMode.None` produces no extra components. -- Spawning with `ColliderMode.All` on `single_room.json` produces a +- Spawning with `ColliderMode.All` on `single_room.oae` produces a `BoxCollider` on every `Wall` child and floor-tile colliders under each `Room` child. - Changing `ColliderMode` in the inspector and re-spawning produces diff --git a/docs/tasks/L-studio-api-client.md b/docs/tasks/L-studio-api-client.md index 2b50ba1..47b6643 100644 --- a/docs/tasks/L-studio-api-client.md +++ b/docs/tasks/L-studio-api-client.md @@ -255,7 +255,7 @@ document the manual procedure in the verification checklist. ``` Verified by me: - All five StudioDiscoveryTests pass. [how: ran Test Runner edit-mode] -- Manual end-to-end: launched Studio, imported single_room.json into a +- Manual end-to-end: launched Studio, imported single_room.oae into a Unity project, clicked Export GLB via Studio, observed single_room.glb appear next to it with Room and Wall components attached after Unity's reimport. [how: hierarchy + asset inspection] From 78d69f9976aff3553d73776befec20e6f69e5a1a Mon Sep 17 00:00:00 2001 From: silicoky Date: Sun, 17 May 2026 23:31:49 -0400 Subject: [PATCH 2/2] Fix CI workflow: use UNITY_SERIAL for Pro license The workflow was wired up for Unity Personal (UNITY_LICENSE = full .ulf contents). Switch to the Pro flow: UNITY_SERIAL holds the serial code, combined with UNITY_EMAIL and UNITY_PASSWORD for authentication. --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eb234fc..7d56f66 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,7 +32,7 @@ jobs: - name: Run edit-mode tests uses: game-ci/unity-test-runner@v4 env: - UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} with: