diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..eb234fc --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,51 @@ +name: Unity tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + test: + name: Edit-mode tests + runs-on: ubuntu-latest + permissions: + checks: write + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + lfs: true + + - name: Cache Unity Library + uses: actions/cache@v4 + with: + path: Tests/UnityProject/Library + key: Library-${{ runner.os }}-2022.3 + restore-keys: | + Library-${{ runner.os }}- + + - name: Run edit-mode tests + uses: game-ci/unity-test-runner@v4 + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + with: + projectPath: Tests/UnityProject + unityVersion: 2022.3.40f1 + testMode: editmode + artifactsPath: test-results + githubToken: ${{ secrets.GITHUB_TOKEN }} + checkName: Edit-mode test results + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: test-results diff --git a/.gitignore b/.gitignore index 07c71c5..e3c58d1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,7 @@ # UPM package — installed inside other Unity projects' Packages/ directory. # This .gitignore covers only what's relevant when developing the package itself. -# Build outputs -/[Bb]uild/ +# Build outputs (Unity artefacts) /[Bb]uilds/ /[Ll]ibrary/ /[Tt]emp/ @@ -11,6 +10,14 @@ /[Uu]ser[Ss]ettings/ *.log +# tests/UnityProject is a host project for CI; only its source-tracked +# settings + manifest belong in version control. +/Tests/UnityProject/Library/ +/Tests/UnityProject/Temp/ +/Tests/UnityProject/Logs/ +/Tests/UnityProject/obj/ +/Tests/UnityProject/Build/ + # Plugins/ contents are produced by the Core build (DLL drop). Track the directory # but not the binaries on day one — uncomment the next line if you want to commit # specific DLL versions. For now, build provides them. diff --git a/Editor/Importers/GltfEnvironmentPostprocessor.cs b/Editor/Importers/GltfEnvironmentPostprocessor.cs new file mode 100644 index 0000000..0950ec4 --- /dev/null +++ b/Editor/Importers/GltfEnvironmentPostprocessor.cs @@ -0,0 +1,82 @@ +using System.Text.RegularExpressions; +using UnityEditor; +using UnityEngine; + +namespace OpenApparatus.Unity.Editor.Importers +{ + internal sealed class GltfEnvironmentPostprocessor : AssetPostprocessor + { + static readonly Regex RoomNameRx = new Regex(@"^Room_(\d+)$", RegexOptions.Compiled); + static readonly Regex WallNameRx = new Regex(@"^Room_(\d+)_wall_(\d+)$", RegexOptions.Compiled); + static readonly Regex ObjectNameRx = new Regex(@"^Room_(\d+)_object_(\d+)_(\d+)$", RegexOptions.Compiled); + + void OnPostprocessModel(GameObject root) + { + if (root == null) return; + if (!LooksLikeOpenApparatusGltf(root)) return; + + AttachComponents(root.transform); + } + + static bool LooksLikeOpenApparatusGltf(GameObject root) + { + foreach (Transform child in root.transform) + { + if (RoomNameRx.IsMatch(child.name)) return true; + } + foreach (var t in root.GetComponentsInChildren(includeInactive: true)) + { + if (RoomNameRx.IsMatch(t.name)) return true; + } + return false; + } + + static void AttachComponents(Transform root) + { + foreach (var t in root.GetComponentsInChildren(includeInactive: true)) + { + var m = RoomNameRx.Match(t.name); + if (m.Success) + { + var room = t.gameObject.GetComponent() ?? t.gameObject.AddComponent(); + room.RoomId = int.Parse(m.Groups[1].Value); + continue; + } + + m = WallNameRx.Match(t.name); + if (m.Success) + { + var wall = t.gameObject.GetComponent() ?? t.gameObject.AddComponent(); + wall.WallNumber = int.Parse(m.Groups[2].Value); + wall.PassageKind = PassageKind.Closed; + var bounds = ComputeMeshBoundsLocal(t.gameObject); + if (bounds.HasValue) + { + var b = bounds.Value; + wall.StartLocal = new Vector3(b.min.x, 0f, b.min.z); + wall.EndLocal = new Vector3(b.max.x, 0f, b.max.z); + } + continue; + } + + m = ObjectNameRx.Match(t.name); + if (m.Success) + { + var instance = t.gameObject.GetComponent() + ?? t.gameObject.AddComponent(); + instance.OwningRoomId = int.Parse(m.Groups[1].Value); + instance.Slot = int.Parse(m.Groups[2].Value); + instance.LocalRotationY = t.localRotation.eulerAngles.y * Mathf.Deg2Rad; + continue; + } + } + } + + static Bounds? ComputeMeshBoundsLocal(GameObject go) + { + var mf = go.GetComponent(); + if (mf == null || mf.sharedMesh == null) return null; + return mf.sharedMesh.bounds; + } + } +} diff --git a/Editor/Importers/JsonEnvironmentDiscriminator.cs b/Editor/Importers/JsonEnvironmentDiscriminator.cs new file mode 100644 index 0000000..b8f3c35 --- /dev/null +++ b/Editor/Importers/JsonEnvironmentDiscriminator.cs @@ -0,0 +1,42 @@ +using System; +using Newtonsoft.Json.Linq; + +namespace OpenApparatus.Unity.Editor.Importers +{ + internal static class JsonEnvironmentDiscriminator + { + public const string SchemaMarker = "openapparatus.environment"; + + public static bool IsOpenApparatus(string json) + { + if (string.IsNullOrWhiteSpace(json)) return false; + + try + { + var root = JToken.Parse(json) as JObject; + if (root == null) return false; + + var schema = root["schema"]?.Type == JTokenType.String + ? (string)root["schema"] + : null; + if (schema == SchemaMarker) return true; + + var version = root["version"]; + if (version == null || version.Type != JTokenType.Integer || + (int)version != 3) + { + return false; + } + + if (root["parameters"] == null) return false; + if (root["grid"] == null) return false; + var rooms = root["rooms"]; + return rooms != null && rooms.Type == JTokenType.Array; + } + catch (Exception) + { + return false; + } + } + } +} diff --git a/Editor/Importers/JsonEnvironmentDocument.cs b/Editor/Importers/JsonEnvironmentDocument.cs new file mode 100644 index 0000000..84ab7a2 --- /dev/null +++ b/Editor/Importers/JsonEnvironmentDocument.cs @@ -0,0 +1,99 @@ +using System.Collections.Generic; + +namespace OpenApparatus.Unity.Editor.Importers +{ + internal sealed class JsonEnvironmentDocument + { + public string schema { get; set; } + public int version { get; set; } + public JsonParameters parameters { get; set; } + public JsonGrid grid { get; set; } + public List objectSlots { get; set; } + public List rooms { get; set; } + public JsonOutside outside { get; set; } + } + + internal sealed class JsonParameters + { + public float tileSize { get; set; } + public float wallThickness { get; set; } + public float wallHeight { get; set; } + public float doorWidth { get; set; } + public float doorHeight { get; set; } + public float windowWidth { get; set; } + public float windowHeight { get; set; } + public float windowSillHeight { get; set; } + public int gridSubdivision { get; set; } + public float defaultObjectY { get; set; } + } + + internal sealed class JsonGrid + { + public int width { get; set; } + public int length { get; set; } + public List> tiles { get; set; } + } + + internal sealed class JsonObjectSlot + { + public int id { get; set; } + public string shape { get; set; } + public List color { get; set; } + public float size { get; set; } + public string displayName { get; set; } + public string objectType { get; set; } + } + + internal sealed class JsonRoom + { + public int id { get; set; } + public JsonRoomShape shape { get; set; } + public List position { get; set; } + public List> tiles { get; set; } + public List walls { get; set; } + public List objects { get; set; } + } + + internal sealed class JsonRoomShape + { + public string type { get; set; } + public float width { get; set; } + public float depth { get; set; } + } + + internal sealed class JsonWall + { + public int number { get; set; } + public string side { get; set; } + public List start { get; set; } + public List end { get; set; } + public int? neighborRoomId { get; set; } + public JsonPassage passage { get; set; } + } + + internal sealed class JsonPassage + { + public string type { get; set; } + public List openings { get; set; } + } + + internal sealed class JsonOpening + { + public float offsetAlongEdge { get; set; } + public float width { get; set; } + public float height { get; set; } + public float sillHeight { get; set; } + } + + internal sealed class JsonObjectInstance + { + public int slot { get; set; } + public List position { get; set; } + public float rotation { get; set; } + } + + internal sealed class JsonOutside + { + public List objects { get; set; } + } +} diff --git a/Editor/Importers/JsonEnvironmentImporter.cs b/Editor/Importers/JsonEnvironmentImporter.cs new file mode 100644 index 0000000..a10afe0 --- /dev/null +++ b/Editor/Importers/JsonEnvironmentImporter.cs @@ -0,0 +1,232 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Newtonsoft.Json; +using UnityEditor; +using UnityEditor.AssetImporters; +using UnityEngine; +using OpenApparatus.Topology; +using OpenApparatus.Unity.Editor.Internal; +using OpenApparatus.Unity.Internal; + +namespace OpenApparatus.Unity.Editor.Importers +{ + [ScriptedImporter(version: 1, ext: "json")] + public sealed class JsonEnvironmentImporter : ScriptedImporter + { + public override void OnImportAsset(AssetImportContext ctx) + { + string text; + try + { + text = File.ReadAllText(ctx.assetPath); + } + catch (Exception) + { + return; + } + + if (!JsonEnvironmentDiscriminator.IsOpenApparatus(text)) return; + + JsonEnvironmentDocument doc; + try + { + doc = JsonConvert.DeserializeObject(text); + } + catch (JsonException e) + { + ctx.LogImportError($"OpenApparatus JSON parse failed: {e.Message}"); + return; + } + if (doc == null) return; + + var asset = ScriptableObject.CreateInstance(); + asset.name = Path.GetFileNameWithoutExtension(ctx.assetPath); + asset.SchemaVersion = doc.version; + asset.Parameters = MapParameters(doc.parameters); + asset.ObjectSlots = MapSlots(doc.objectSlots); + asset.Rooms = MapRooms(doc.rooms); + asset.OutsideObjects = MapOutside(doc.outside); + + 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) + { + if (p == null) return new EnvironmentParameters(); + return new EnvironmentParameters + { + TileSize = p.tileSize, + WallThickness = p.wallThickness, + WallHeight = p.wallHeight, + DoorWidth = p.doorWidth, + DoorHeight = p.doorHeight, + WindowWidth = p.windowWidth, + WindowHeight = p.windowHeight, + WindowSillHeight = p.windowSillHeight, + GridSubdivision = p.gridSubdivision, + DefaultObjectY = p.defaultObjectY, + }; + } + + static ObjectSlotDefinition[] MapSlots(List slots) + { + if (slots == null) return Array.Empty(); + var result = new ObjectSlotDefinition[slots.Count]; + for (int i = 0; i < slots.Count; i++) + { + var s = slots[i]; + result[i] = new ObjectSlotDefinition + { + Id = s.id, + Shape = s.shape, + Color = s.color != null && s.color.Count >= 3 + ? new Color(s.color[0], s.color[1], s.color[2]) + : Color.white, + Size = s.size, + DisplayName = s.displayName, + ObjectType = !string.IsNullOrEmpty(s.objectType) ? s.objectType : s.displayName, + }; + } + return result; + } + + static RoomData[] MapRooms(List rooms) + { + if (rooms == null) return Array.Empty(); + var result = new RoomData[rooms.Count]; + for (int i = 0; i < rooms.Count; i++) + { + var r = rooms[i]; + result[i] = new RoomData + { + Id = r.id, + RoomType = ParseRoomType(r.shape), + GridPositionStudio = r.position != null && r.position.Count >= 2 + ? new Vector2(r.position[0], r.position[1]) + : Vector2.zero, + TileIndices = MapTiles(r.tiles), + Walls = MapWalls(r.walls), + Objects = MapObjects(r.objects), + }; + } + return result; + } + + static RoomType ParseRoomType(JsonRoomShape shape) + { + if (shape == null) return RoomType.Square; + if (string.Equals(shape.type, "rectangle", StringComparison.OrdinalIgnoreCase) && + !Mathf.Approximately(shape.width, shape.depth)) + { + return RoomType.Rectangle; + } + return RoomType.Square; + } + + static Vector2Int[] MapTiles(List> tiles) + { + if (tiles == null) return Array.Empty(); + var result = new Vector2Int[tiles.Count]; + for (int i = 0; i < tiles.Count; i++) + { + var pair = tiles[i]; + result[i] = pair != null && pair.Count >= 2 + ? new Vector2Int(pair[0], pair[1]) + : Vector2Int.zero; + } + return result; + } + + static WallData[] MapWalls(List walls) + { + if (walls == null) return Array.Empty(); + var result = new WallData[walls.Count]; + for (int i = 0; i < walls.Count; i++) + { + var w = walls[i]; + var startStudio = w.start != null && w.start.Count >= 2 + ? new Vector3(w.start[0], 0, w.start[1]) + : Vector3.zero; + var endStudio = w.end != null && w.end.Count >= 2 + ? new Vector3(w.end[0], 0, w.end[1]) + : Vector3.zero; + + result[i] = new WallData + { + Number = w.number, + StartLocal = OpenApparatusSpace.ToUnity(startStudio), + EndLocal = OpenApparatusSpace.ToUnity(endStudio), + NeighbourRoomId = w.neighborRoomId ?? -1, + PassageKind = ParsePassageKind(w.passage?.type), + Openings = MapOpenings(w.passage?.openings), + }; + } + return result; + } + + static PassageKind ParsePassageKind(string type) + { + if (string.Equals(type, "doorway", StringComparison.OrdinalIgnoreCase)) + return PassageKind.Doorway; + if (string.Equals(type, "open", StringComparison.OrdinalIgnoreCase)) + return PassageKind.Open; + return PassageKind.Closed; + } + + static OpeningSpec[] MapOpenings(List openings) + { + if (openings == null) return Array.Empty(); + var result = new OpeningSpec[openings.Count]; + for (int i = 0; i < openings.Count; i++) + { + var o = openings[i]; + result[i] = new OpeningSpec + { + OffsetAlongEdge = o.offsetAlongEdge, + Width = o.width, + Height = o.height, + SillHeight = o.sillHeight, + }; + } + return result; + } + + static ObjectInstanceData[] MapObjects(List objs) + { + if (objs == null) return Array.Empty(); + var result = new ObjectInstanceData[objs.Count]; + for (int i = 0; i < objs.Count; i++) + { + var o = objs[i]; + var studioPos = o.position != null && o.position.Count >= 3 + ? new Vector3(o.position[0], o.position[1], o.position[2]) + : Vector3.zero; + result[i] = new ObjectInstanceData + { + Slot = o.slot, + LocalPosition = OpenApparatusSpace.ToUnity(studioPos), + LocalRotationY = OpenApparatusSpace.YawToUnity(o.rotation), + }; + } + return result; + } + + static ObjectInstanceData[] MapOutside(JsonOutside outside) + { + return outside == null ? Array.Empty() : MapObjects(outside.objects); + } + } +} diff --git a/Editor/Inspectors/MultiRoomEnvironmentAssetEditor.cs b/Editor/Inspectors/MultiRoomEnvironmentAssetEditor.cs new file mode 100644 index 0000000..ae6dbc6 --- /dev/null +++ b/Editor/Inspectors/MultiRoomEnvironmentAssetEditor.cs @@ -0,0 +1,56 @@ +using UnityEditor; +using UnityEngine; +using OpenApparatus.Unity.Editor.Internal; + +namespace OpenApparatus.Unity.Editor.Inspectors +{ + [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; + 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.##}"); + } + + 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.", + 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/Inspectors/PrefabSubstitutionTableEditor.cs b/Editor/Inspectors/PrefabSubstitutionTableEditor.cs new file mode 100644 index 0000000..8fafa99 --- /dev/null +++ b/Editor/Inspectors/PrefabSubstitutionTableEditor.cs @@ -0,0 +1,65 @@ +using UnityEditor; +using UnityEditorInternal; +using UnityEngine; + +namespace OpenApparatus.Unity.Editor.Inspectors +{ + [CustomEditor(typeof(PrefabSubstitutionTable))] + public sealed class PrefabSubstitutionTableEditor : UnityEditor.Editor + { + ReorderableList _list; + SerializedProperty _entriesProp; + + void OnEnable() + { + _entriesProp = serializedObject.FindProperty(nameof(PrefabSubstitutionTable.Entries)); + _list = new ReorderableList(serializedObject, _entriesProp, + draggable: true, displayHeader: true, + displayAddButton: true, displayRemoveButton: true); + + _list.drawHeaderCallback = rect => + EditorGUI.LabelField(rect, "Substitutions"); + + _list.elementHeightCallback = index => + EditorGUIUtility.singleLineHeight * 5 + 12; + + _list.drawElementCallback = (rect, index, _isActive, _isFocused) => + { + var element = _entriesProp.GetArrayElementAtIndex(index); + float h = EditorGUIUtility.singleLineHeight; + float pad = 2f; + rect.y += pad; + + DrawField(ref rect, element, "ObjectType", h, pad); + DrawField(ref rect, element, "Prefab", h, pad); + DrawField(ref rect, element, "PositionOffset", h, pad); + DrawField(ref rect, element, "RotationOffsetYDegrees", h, pad); + DrawField(ref rect, element, "ScaleMultiplier", h, pad); + }; + + _list.onAddCallback = list => + { + int newIndex = list.serializedProperty.arraySize; + list.serializedProperty.arraySize++; + list.index = newIndex; + var element = list.serializedProperty.GetArrayElementAtIndex(newIndex); + element.FindPropertyRelative("ScaleMultiplier").vector3Value = Vector3.one; + }; + } + + static void DrawField(ref Rect rect, SerializedProperty element, + string fieldName, float h, float pad) + { + var r = new Rect(rect.x, rect.y, rect.width, h); + EditorGUI.PropertyField(r, element.FindPropertyRelative(fieldName)); + rect.y += h + pad; + } + + public override void OnInspectorGUI() + { + serializedObject.Update(); + _list.DoLayoutList(); + serializedObject.ApplyModifiedProperties(); + } + } +} diff --git a/Editor/Internal/ColliderBuilder.cs b/Editor/Internal/ColliderBuilder.cs new file mode 100644 index 0000000..9b5673e --- /dev/null +++ b/Editor/Internal/ColliderBuilder.cs @@ -0,0 +1,78 @@ +using UnityEngine; + +namespace OpenApparatus.Unity.Editor.Internal +{ + public static class ColliderBuilder + { + const string WallColliderChildName = "WallCollider"; + const string FloorCollidersChildName = "FloorColliders"; + const float FloorColliderThickness = 0.1f; + + public static void Apply(GameObject environmentRoot, + ColliderMode mode, + EnvironmentParameters parameters) + { + 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) + foreach (var wall in environmentRoot.GetComponentsInChildren(includeInactive: true)) + AddWallCollider(wall, parameters); + + if (doFloors) + foreach (var room in environmentRoot.GetComponentsInChildren(includeInactive: true)) + AddFloorColliders(room, parameters); + } + + static void AddWallCollider(Wall wall, EnvironmentParameters parameters) + { + var delta = wall.EndLocal - wall.StartLocal; + float length = delta.magnitude; + if (length < 1e-4f) return; + + ClearExistingChild(wall.transform, WallColliderChildName); + + var anchor = new GameObject(WallColliderChildName); + anchor.transform.SetParent(wall.transform, worldPositionStays: false); + + var midpoint = (wall.StartLocal + wall.EndLocal) * 0.5f; + midpoint.y += parameters.WallHeight * 0.5f; + anchor.transform.localPosition = midpoint; + + float yawDeg = Mathf.Atan2(delta.z, delta.x) * Mathf.Rad2Deg; + anchor.transform.localRotation = Quaternion.Euler(0f, -yawDeg, 0f); + + var col = anchor.AddComponent(); + col.size = new Vector3(length, parameters.WallHeight, parameters.WallThickness); + } + + static void AddFloorColliders(Room room, EnvironmentParameters parameters) + { + if (room.TileIndices == null || room.TileIndices.Length == 0) return; + + ClearExistingChild(room.transform, FloorCollidersChildName); + + var holder = new GameObject(FloorCollidersChildName); + holder.transform.SetParent(room.transform, worldPositionStays: false); + + float t = parameters.TileSize; + foreach (var idx in room.TileIndices) + { + var col = holder.AddComponent(); + col.size = new Vector3(t, FloorColliderThickness, t); + col.center = new Vector3( + (idx.x + 0.5f) * t, + -FloorColliderThickness * 0.5f, + (idx.y + 0.5f) * t); + } + } + + static void ClearExistingChild(Transform parent, string name) + { + var existing = parent.Find(name); + if (existing != null) Object.DestroyImmediate(existing.gameObject); + } + } +} diff --git a/Editor/Internal/EnvironmentSpawner.cs b/Editor/Internal/EnvironmentSpawner.cs new file mode 100644 index 0000000..71ac206 --- /dev/null +++ b/Editor/Internal/EnvironmentSpawner.cs @@ -0,0 +1,120 @@ +using UnityEditor; +using UnityEngine; + +namespace OpenApparatus.Unity.Editor.Internal +{ + public static class EnvironmentSpawner + { + public static GameObject Spawn(MultiRoomEnvironmentAsset asset) + { + if (asset == null) return null; + + var root = new GameObject(asset.name); + var rootComponent = root.AddComponent(); + rootComponent.Asset = asset; + + if (asset.Rooms != null) + foreach (var roomData in asset.Rooms) + SpawnRoom(root.transform, roomData, asset); + + PrefabSubstitutionApplicator.Apply(root, asset.Substitution, asset.ObjectSlots); + ColliderBuilder.Apply(root, asset.ColliderMode, asset.Parameters); + + Undo.RegisterCreatedObjectUndo(root, "Spawn OpenApparatus environment"); + return root; + } + + static void SpawnRoom(Transform parent, RoomData roomData, MultiRoomEnvironmentAsset asset) + { + var roomGo = new GameObject($"Room_{roomData.Id}"); + roomGo.transform.SetParent(parent, worldPositionStays: false); + + var p = asset.Parameters; + roomGo.transform.localPosition = new Vector3( + -roomData.GridPositionStudio.x * p.TileSize, + 0f, + roomData.GridPositionStudio.y * p.TileSize); + + var roomComponent = roomGo.AddComponent(); + roomComponent.RoomId = roomData.Id; + roomComponent.RoomType = roomData.RoomType; + 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; + } + + if (roomData.Walls != null) + foreach (var wd in roomData.Walls) + SpawnWall(roomGo.transform, wd); + + if (roomData.Objects != null) + foreach (var od in roomData.Objects) + SpawnObject(roomGo.transform, od, roomData.Id, asset); + } + + static void SpawnWall(Transform parent, WallData wd) + { + var go = new GameObject($"Wall_{wd.Number}"); + go.transform.SetParent(parent, worldPositionStays: false); + + var w = go.AddComponent(); + w.WallNumber = wd.Number; + w.StartLocal = wd.StartLocal; + w.EndLocal = wd.EndLocal; + w.NeighbourRoomId = wd.NeighbourRoomId; + w.PassageKind = wd.PassageKind; + w.Openings = wd.Openings; + } + + static void SpawnObject(Transform parent, ObjectInstanceData od, int owningRoomId, + MultiRoomEnvironmentAsset asset) + { + var slot = ResolveSlot(asset.ObjectSlots, od.Slot); + var primitive = ChoosePrimitive(slot?.Shape); + var go = GameObject.CreatePrimitive(primitive); + go.name = $"Object_Slot{od.Slot}"; + go.transform.SetParent(parent, worldPositionStays: false); + go.transform.localPosition = od.LocalPosition; + go.transform.localRotation = Quaternion.Euler(0f, od.LocalRotationY * Mathf.Rad2Deg, 0f); + if (slot != null && slot.Size > 0f) + go.transform.localScale = Vector3.one * slot.Size; + + var instance = go.AddComponent(); + instance.Slot = od.Slot; + instance.OwningRoomId = owningRoomId; + instance.LocalRotationY = od.LocalRotationY; + } + + static ObjectSlotDefinition ResolveSlot(ObjectSlotDefinition[] slots, int slotNumber) + { + int idx = slotNumber - 1; + if (slots == null || idx < 0 || idx >= slots.Length) return null; + return slots[idx]; + } + + static PrimitiveType ChoosePrimitive(string shape) + { + if (string.IsNullOrEmpty(shape)) return PrimitiveType.Cube; + return shape.ToLowerInvariant() switch + { + "sphere" => PrimitiveType.Sphere, + "cylinder" => PrimitiveType.Cylinder, + "capsule" => PrimitiveType.Capsule, + _ => PrimitiveType.Cube, + }; + } + } +} diff --git a/Editor/Internal/JsonGeometryBuilder.cs b/Editor/Internal/JsonGeometryBuilder.cs new file mode 100644 index 0000000..b5eeb03 --- /dev/null +++ b/Editor/Internal/JsonGeometryBuilder.cs @@ -0,0 +1,116 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace OpenApparatus.Unity.Editor.Internal +{ + internal static class JsonGeometryBuilder + { + public static Mesh BuildRoomMesh(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(); + + 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; + } + + static void BuildTileSurfaces(RoomData room, EnvironmentParameters p, + List verts, List normals, + List uvs, + List floorTris, List ceilingTris) + { + if (room.TileIndices == null) return; + float t = p.TileSize; + float h = p.WallHeight; + + foreach (var idx in room.TileIndices) + { + 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); + } + } + + static void BuildWall(WallData wall, EnvironmentParameters p, + List verts, List normals, + List uvs, List tris) + { + var start = wall.StartLocal; + var end = wall.EndLocal; + var delta = end - start; + float length = delta.magnitude; + if (length < 1e-4f) return; + + float h = p.WallHeight; + float th = p.WallThickness; + var tangent = delta / length; + var inward = new Vector3(-tangent.z, 0, tangent.x); + + 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 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, 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); + } + + static void AddQuad(List verts, List normals, + List uvs, List tris, + Vector3 v0, Vector3 v1, Vector3 v2, Vector3 v3, + Vector3 normal) + { + int baseIdx = verts.Count; + verts.Add(v0); verts.Add(v1); verts.Add(v2); verts.Add(v3); + normals.Add(normal); normals.Add(normal); normals.Add(normal); normals.Add(normal); + uvs.Add(new Vector2(0, 0)); uvs.Add(new Vector2(1, 0)); + uvs.Add(new Vector2(1, 1)); uvs.Add(new Vector2(0, 1)); + tris.Add(baseIdx); tris.Add(baseIdx + 1); tris.Add(baseIdx + 2); + tris.Add(baseIdx); tris.Add(baseIdx + 2); tris.Add(baseIdx + 3); + } + } +} diff --git a/Editor/Internal/MaterialOverrides.cs b/Editor/Internal/MaterialOverrides.cs new file mode 100644 index 0000000..07222a9 --- /dev/null +++ b/Editor/Internal/MaterialOverrides.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace OpenApparatus.Unity.Editor.Internal +{ + public sealed class MaterialOverrides + { + public Dictionary ByStudioName; + public Material FloorDefault; + public Material WallDefault; + public Material CeilingDefault; + } +} diff --git a/Editor/Internal/MaterialResolver.cs b/Editor/Internal/MaterialResolver.cs new file mode 100644 index 0000000..da3ccff --- /dev/null +++ b/Editor/Internal/MaterialResolver.cs @@ -0,0 +1,108 @@ +using UnityEditor; +using UnityEngine; + +namespace OpenApparatus.Unity.Editor.Internal +{ + public static class MaterialResolver + { + const string PackagePath = "Packages/com.openapparatus.unity/Materials"; + static bool _warnedFallback; + + public static Material Resolve(string studioName, MaterialOverrides overrides = null) + { + if (overrides != null) + { + if (overrides.ByStudioName != null && + overrides.ByStudioName.TryGetValue(studioName, out var mapped) && + mapped != null) + { + return mapped; + } + + var partDefault = + studioName.StartsWith("OpenApparatus_Floor_") ? overrides.FloorDefault : + studioName.StartsWith("OpenApparatus_Ceiling_") ? overrides.CeilingDefault : + studioName.StartsWith("OpenApparatus_Walls_") ? overrides.WallDefault : + null; + if (partDefault != null) return partDefault; + } + + var asset = LoadAuthoredDefault(studioName); + if (asset != null) return asset; + + return SynthesizeDefault(studioName); + } + + static Material LoadAuthoredDefault(string studioName) + { + string part = PartFromStudioName(studioName); + if (part == null) return null; + var path = $"{PackagePath}/{PipelineFolder()}/{part}.mat"; + return AssetDatabase.LoadAssetAtPath(path); + } + + static Material SynthesizeDefault(string studioName) + { + string part = PartFromStudioName(studioName); + var shader = FindPipelineShader(); + if (shader == null) + { + if (!_warnedFallback) + { + Debug.LogWarning("[OpenApparatus] No pipeline shader found; using magenta fallback."); + _warnedFallback = true; + } + return CreateMagentaFallback(); + } + + var mat = new Material(shader) { name = $"OpenApparatus_{part ?? "Default"}_Synth" }; + mat.color = part switch + { + "Floor" => new Color(0.55f, 0.55f, 0.55f), + "Ceiling" => new Color(0.85f, 0.85f, 0.85f), + "Wall" => new Color(0.75f, 0.75f, 0.70f), + _ => Color.white, + }; + return mat; + } + + static string PartFromStudioName(string studioName) + { + if (string.IsNullOrEmpty(studioName)) return null; + if (studioName.StartsWith("OpenApparatus_Floor_")) return "Floor"; + if (studioName.StartsWith("OpenApparatus_Ceiling_")) return "Ceiling"; + if (studioName.StartsWith("OpenApparatus_Walls_")) return "Wall"; + return null; + } + + static string PipelineFolder() + { +#if OPENAPPARATUS_HDRP + return "HDRP"; +#elif OPENAPPARATUS_URP + return "URP"; +#else + return "Builtin"; +#endif + } + + static Shader FindPipelineShader() + { +#if OPENAPPARATUS_HDRP + return Shader.Find("HDRP/Lit"); +#elif OPENAPPARATUS_URP + return Shader.Find("Universal Render Pipeline/Lit"); +#else + return Shader.Find("Standard"); +#endif + } + + static Material CreateMagentaFallback() + { + var shader = Shader.Find("Hidden/InternalErrorShader") ?? Shader.Find("Unlit/Color"); + var mat = new Material(shader) { name = "OpenApparatus_Fallback" }; + mat.color = Color.magenta; + return mat; + } + } +} diff --git a/Editor/Internal/OpenApparatusGeometry.cs b/Editor/Internal/OpenApparatusGeometry.cs new file mode 100644 index 0000000..af2b998 --- /dev/null +++ b/Editor/Internal/OpenApparatusGeometry.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using UnityEngine; +using OpenApparatus.Geometry; +using OpenApparatus.Topology; + +namespace OpenApparatus.Unity.Editor.Internal +{ + public readonly struct RoomMesh + { + public readonly int RoomId; + public readonly Mesh Mesh; + public RoomMesh(int roomId, Mesh mesh) { RoomId = roomId; Mesh = mesh; } + } + + public static class OpenApparatusGeometry + { + public static IReadOnlyList AssembleMeshes( + MultiRoomEnvironment plan, + float wallThickness, + float wallHeight) + { + var assembled = new MultiRoomEnvironmentMeshAssembler() + .Assemble(plan, wallThickness, wallHeight); + + var result = new List(assembled.Count); + foreach (var cell in assembled) + { + var mesh = UnityMeshAdapter.ToUnityMesh(cell.Mesh, + $"OpenApparatus_Room_{cell.Room.Id}"); + result.Add(new RoomMesh(cell.Room.Id, mesh)); + } + return result; + } + } +} diff --git a/Editor/Internal/PrefabSubstitutionApplicator.cs b/Editor/Internal/PrefabSubstitutionApplicator.cs new file mode 100644 index 0000000..0344fa8 --- /dev/null +++ b/Editor/Internal/PrefabSubstitutionApplicator.cs @@ -0,0 +1,77 @@ +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; + +namespace OpenApparatus.Unity.Editor.Internal +{ + public static class PrefabSubstitutionApplicator + { + public static void Apply(GameObject environmentRoot, + PrefabSubstitutionTable table, + ObjectSlotDefinition[] objectSlots) + { + if (environmentRoot == null) return; + if (table == null || table.Entries == null || table.Entries.Length == 0) return; + if (objectSlots == null || objectSlots.Length == 0) return; + + var warnedTypes = new HashSet(); + var instances = environmentRoot + .GetComponentsInChildren(includeInactive: true); + + foreach (var instance in instances) + { + int slotIndex = instance.Slot - 1; + if (slotIndex < 0 || slotIndex >= objectSlots.Length) continue; + + var slot = objectSlots[slotIndex]; + var objectType = !string.IsNullOrEmpty(slot.ObjectType) + ? slot.ObjectType + : slot.DisplayName; + if (string.IsNullOrEmpty(objectType)) continue; + + if (!table.TryFind(objectType, out var entry)) continue; + + if (entry.Prefab == null) + { + if (warnedTypes.Add(objectType)) + Debug.LogWarning( + $"[OpenApparatus] PrefabSubstitutionTable has an entry for " + + $"'{objectType}' but Prefab is null; leaving placeholder."); + continue; + } + + SwapPlaceholder(instance, entry); + } + } + + static void SwapPlaceholder(RoomObjectInstance placeholder, SubstitutionEntry entry) + { + var parent = placeholder.transform.parent; + var basePos = placeholder.transform.localPosition; + var baseRot = placeholder.transform.localRotation; + + GameObject instance = null; + if (PrefabUtility.IsPartOfPrefabAsset(entry.Prefab)) + instance = (GameObject)PrefabUtility.InstantiatePrefab(entry.Prefab); + if (instance == null) + instance = Object.Instantiate(entry.Prefab); + if (instance == null) return; + instance.name = entry.Prefab.name; + + instance.transform.SetParent(parent, worldPositionStays: false); + instance.transform.localPosition = basePos + entry.PositionOffset; + instance.transform.localRotation = + baseRot * Quaternion.Euler(0f, entry.RotationOffsetYDegrees, 0f); + + var scale = entry.ScaleMultiplier; + if (scale == Vector3.zero) scale = Vector3.one; + var prefabScale = instance.transform.localScale; + instance.transform.localScale = new Vector3( + prefabScale.x * scale.x, + prefabScale.y * scale.y, + prefabScale.z * scale.z); + + Object.DestroyImmediate(placeholder.gameObject); + } + } +} diff --git a/Editor/OpenApparatus.Editor.asmdef b/Editor/OpenApparatus.Editor.asmdef index a12e060..151f957 100644 --- a/Editor/OpenApparatus.Editor.asmdef +++ b/Editor/OpenApparatus.Editor.asmdef @@ -13,6 +13,17 @@ "precompiledReferences": [], "autoReferenced": true, "defineConstraints": [], - "versionDefines": [], + "versionDefines": [ + { + "name": "com.unity.render-pipelines.universal", + "expression": "", + "define": "OPENAPPARATUS_URP" + }, + { + "name": "com.unity.render-pipelines.high-definition", + "expression": "", + "define": "OPENAPPARATUS_HDRP" + } + ], "noEngineReferences": false } diff --git a/Materials/README.md b/Materials/README.md new file mode 100644 index 0000000..643ea3e --- /dev/null +++ b/Materials/README.md @@ -0,0 +1,38 @@ +# Materials + +`MaterialResolver` looks up default materials in this folder by render +pipeline: + +``` +Materials/ +├── Builtin/ Floor.mat, Wall.mat, Ceiling.mat (Standard shader) +├── URP/ Floor.mat, Wall.mat, Ceiling.mat (Universal RP / Lit) +└── HDRP/ Floor.mat, Wall.mat, Ceiling.mat (HDRP / Lit) +``` + +When a `.mat` exists at the expected path, `MaterialResolver.Resolve` +returns it. When no authored asset is present, the resolver synthesises +a flat-colour material at runtime using the active pipeline's default +lit shader (`Standard`, `Universal Render Pipeline/Lit`, or `HDRP/Lit`) +so the package renders correctly out of the box in any pipeline. + +The active pipeline is detected via the asmdef `versionDefines` +declared in `OpenApparatus.Editor.asmdef` and +`OpenApparatus.Runtime.asmdef`: + +| Pipeline | Define | Folder | +|---|---|---| +| Built-in | (none) | `Builtin/` | +| URP | `OPENAPPARATUS_URP` | `URP/` | +| HDRP | `OPENAPPARATUS_HDRP` | `HDRP/` | + +## Replacing the defaults + +Drop your own `Floor.mat`, `Wall.mat`, and `Ceiling.mat` into the +matching pipeline folder. The resolver will pick them up automatically +on the next spawn. + +To override a single material per-room without authoring whole sets, +pass a `MaterialOverrides` instance to `MaterialResolver.Resolve`. The +JSON / glTF importers do this for any per-room colour overrides loaded +from the source file. diff --git a/Plugins/.gitkeep b/Plugins/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/Plugins/README.md b/Plugins/README.md new file mode 100644 index 0000000..30696c1 --- /dev/null +++ b/Plugins/README.md @@ -0,0 +1,33 @@ +# Plugins + +`OpenApparatus.Core.dll` (netstandard2.1) is shipped here so the package +compiles in a fresh Unity 2022.3 LTS project with no extra setup. + +## Rebuilding the DLL + +Contributors with a sibling `openapparatus-core/` clone rebuild via: + +```powershell +.\build\publish-core-dll.ps1 +``` + +```bash +./build/publish-core-dll.sh +``` + +The script invokes `dotnet build -c Release -f netstandard2.1` against +`openapparatus-core/src/OpenApparatus.Core/` and copies the resulting +`OpenApparatus.Core.dll` (and `.xml` doc file, if present) into this +folder. Commit the updated binary to track the Core version your +package targets. + +If your `openapparatus-core/` clone lives somewhere other than +`/../openapparatus-core`, pass the path explicitly: + +```powershell +.\build\publish-core-dll.ps1 -CoreRepo "C:\path\to\openapparatus-core" +``` + +```bash +./build/publish-core-dll.sh /path/to/openapparatus-core +``` diff --git a/Runtime/Assets/EnvironmentParameters.cs b/Runtime/Assets/EnvironmentParameters.cs new file mode 100644 index 0000000..e0c8508 --- /dev/null +++ b/Runtime/Assets/EnvironmentParameters.cs @@ -0,0 +1,19 @@ +using System; + +namespace OpenApparatus.Unity +{ + [Serializable] + public sealed class EnvironmentParameters + { + public float TileSize = 3.5f; + public float WallThickness = 0.2f; + public float WallHeight = 3.0f; + public float DoorWidth = 1.2f; + public float DoorHeight = 2.2f; + public float WindowWidth = 1.0f; + public float WindowHeight = 1.2f; + public float WindowSillHeight = 0.9f; + public int GridSubdivision = 1; + public float DefaultObjectY; + } +} diff --git a/Runtime/Assets/MultiRoomEnvironmentAsset.cs b/Runtime/Assets/MultiRoomEnvironmentAsset.cs new file mode 100644 index 0000000..3c5c4ac --- /dev/null +++ b/Runtime/Assets/MultiRoomEnvironmentAsset.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace OpenApparatus.Unity +{ + [Serializable] + internal struct RoomMeshEntry + { + public int RoomId; + public Mesh Mesh; + } + + public sealed class MultiRoomEnvironmentAsset : ScriptableObject + { + public int SchemaVersion; + public EnvironmentParameters Parameters = new EnvironmentParameters(); + public RoomData[] Rooms; + public ObjectSlotDefinition[] ObjectSlots; + public ObjectInstanceData[] OutsideObjects; + + [SerializeField] private List _roomMeshes = new List(); + + public ColliderMode ColliderMode = ColliderMode.None; + public PrefabSubstitutionTable Substitution; + + public Mesh GetRoomMesh(int roomId) + { + for (int i = 0; i < _roomMeshes.Count; i++) + if (_roomMeshes[i].RoomId == roomId) return _roomMeshes[i].Mesh; + return null; + } + + public void SetRoomMesh(int roomId, Mesh mesh) + { + for (int i = 0; i < _roomMeshes.Count; i++) + { + if (_roomMeshes[i].RoomId == roomId) + { + _roomMeshes[i] = new RoomMeshEntry { RoomId = roomId, Mesh = mesh }; + return; + } + } + _roomMeshes.Add(new RoomMeshEntry { RoomId = roomId, Mesh = mesh }); + } + + public void ClearRoomMeshes() => _roomMeshes.Clear(); + } +} diff --git a/Runtime/Assets/ObjectSlotDefinition.cs b/Runtime/Assets/ObjectSlotDefinition.cs new file mode 100644 index 0000000..99236f2 --- /dev/null +++ b/Runtime/Assets/ObjectSlotDefinition.cs @@ -0,0 +1,16 @@ +using System; +using UnityEngine; + +namespace OpenApparatus.Unity +{ + [Serializable] + public sealed class ObjectSlotDefinition + { + public int Id; + public string Shape; + public Color Color = Color.white; + public float Size = 0.3f; + public string DisplayName; + public string ObjectType; + } +} diff --git a/Runtime/Assets/PrefabSubstitutionTable.cs b/Runtime/Assets/PrefabSubstitutionTable.cs new file mode 100644 index 0000000..d4fbbb6 --- /dev/null +++ b/Runtime/Assets/PrefabSubstitutionTable.cs @@ -0,0 +1,28 @@ +using UnityEngine; + +namespace OpenApparatus.Unity +{ + [CreateAssetMenu(menuName = "OpenApparatus/Prefab Substitution Table", + fileName = "PrefabSubstitutionTable")] + public sealed class PrefabSubstitutionTable : ScriptableObject + { + public SubstitutionEntry[] Entries; + + public bool TryFind(string objectType, out SubstitutionEntry entry) + { + if (Entries != null && !string.IsNullOrEmpty(objectType)) + { + for (int i = 0; i < Entries.Length; i++) + { + if (Entries[i].ObjectType == objectType) + { + entry = Entries[i]; + return true; + } + } + } + entry = default; + return false; + } + } +} diff --git a/Runtime/Assets/RoomData.cs b/Runtime/Assets/RoomData.cs new file mode 100644 index 0000000..4b995ce --- /dev/null +++ b/Runtime/Assets/RoomData.cs @@ -0,0 +1,36 @@ +using System; +using UnityEngine; +using OpenApparatus.Topology; + +namespace OpenApparatus.Unity +{ + [Serializable] + public sealed class WallData + { + public int Number; + public Vector3 StartLocal; + public Vector3 EndLocal; + public int NeighbourRoomId = -1; + public PassageKind PassageKind; + public OpeningSpec[] Openings; + } + + [Serializable] + public sealed class ObjectInstanceData + { + public int Slot; + public Vector3 LocalPosition; + public float LocalRotationY; + } + + [Serializable] + public sealed class RoomData + { + public int Id; + public RoomType RoomType; + public Vector2 GridPositionStudio; + public Vector2Int[] TileIndices; + public WallData[] Walls; + public ObjectInstanceData[] Objects; + } +} diff --git a/Runtime/Assets/SubstitutionEntry.cs b/Runtime/Assets/SubstitutionEntry.cs new file mode 100644 index 0000000..8368b2d --- /dev/null +++ b/Runtime/Assets/SubstitutionEntry.cs @@ -0,0 +1,15 @@ +using System; +using UnityEngine; + +namespace OpenApparatus.Unity +{ + [Serializable] + public struct SubstitutionEntry + { + public string ObjectType; + public GameObject Prefab; + public Vector3 PositionOffset; + public float RotationOffsetYDegrees; + public Vector3 ScaleMultiplier; + } +} diff --git a/Runtime/Components/EnvironmentRoot.cs b/Runtime/Components/EnvironmentRoot.cs new file mode 100644 index 0000000..0939285 --- /dev/null +++ b/Runtime/Components/EnvironmentRoot.cs @@ -0,0 +1,13 @@ +using UnityEngine; + +namespace OpenApparatus.Unity +{ + [DisallowMultipleComponent] + public sealed class EnvironmentRoot : MonoBehaviour + { + public MultiRoomEnvironmentAsset Asset; + public Material[] FloorMaterialOverrides; + public Material[] WallMaterialOverrides; + public Material[] CeilingMaterialOverrides; + } +} diff --git a/Runtime/Components/OpeningSpec.cs b/Runtime/Components/OpeningSpec.cs new file mode 100644 index 0000000..2682158 --- /dev/null +++ b/Runtime/Components/OpeningSpec.cs @@ -0,0 +1,13 @@ +using System; + +namespace OpenApparatus.Unity +{ + [Serializable] + public sealed class OpeningSpec + { + public float OffsetAlongEdge; + public float Width; + public float Height; + public float SillHeight; + } +} diff --git a/Runtime/Components/Room.cs b/Runtime/Components/Room.cs new file mode 100644 index 0000000..a7fb68c --- /dev/null +++ b/Runtime/Components/Room.cs @@ -0,0 +1,14 @@ +using UnityEngine; +using OpenApparatus.Topology; + +namespace OpenApparatus.Unity +{ + [DisallowMultipleComponent] + public sealed class Room : MonoBehaviour + { + public int RoomId; + public RoomType RoomType; + public Vector2 GridPositionStudio; + public Vector2Int[] TileIndices; + } +} diff --git a/Runtime/Components/RoomObjectInstance.cs b/Runtime/Components/RoomObjectInstance.cs new file mode 100644 index 0000000..b04af81 --- /dev/null +++ b/Runtime/Components/RoomObjectInstance.cs @@ -0,0 +1,12 @@ +using UnityEngine; + +namespace OpenApparatus.Unity +{ + [DisallowMultipleComponent] + public sealed class RoomObjectInstance : MonoBehaviour + { + public int Slot; + public int OwningRoomId; + public float LocalRotationY; + } +} diff --git a/Runtime/Components/Wall.cs b/Runtime/Components/Wall.cs new file mode 100644 index 0000000..f69c53b --- /dev/null +++ b/Runtime/Components/Wall.cs @@ -0,0 +1,20 @@ +using UnityEngine; + +namespace OpenApparatus.Unity +{ + [DisallowMultipleComponent] + public sealed class Wall : MonoBehaviour + { + public int WallNumber; + public Vector3 StartLocal; + public Vector3 EndLocal; + + // -1 = outer wall (no neighbour). Sentinel rather than int? for Unity serialisation. + public int NeighbourRoomId = -1; + + public PassageKind PassageKind; + public OpeningSpec[] Openings; + + public bool IsOuterWall => NeighbourRoomId < 0; + } +} diff --git a/Runtime/Enums/ColliderMode.cs b/Runtime/Enums/ColliderMode.cs new file mode 100644 index 0000000..65b239e --- /dev/null +++ b/Runtime/Enums/ColliderMode.cs @@ -0,0 +1,10 @@ +namespace OpenApparatus.Unity +{ + public enum ColliderMode + { + None, + WallsOnly, + FloorsOnly, + All, + } +} diff --git a/Runtime/Enums/PassageKind.cs b/Runtime/Enums/PassageKind.cs new file mode 100644 index 0000000..dfb813b --- /dev/null +++ b/Runtime/Enums/PassageKind.cs @@ -0,0 +1,9 @@ +namespace OpenApparatus.Unity +{ + public enum PassageKind + { + Closed, + Open, + Doorway, + } +} diff --git a/Runtime/Internal/OpenApparatusSpace.cs b/Runtime/Internal/OpenApparatusSpace.cs new file mode 100644 index 0000000..178256a --- /dev/null +++ b/Runtime/Internal/OpenApparatusSpace.cs @@ -0,0 +1,16 @@ +using UnityEngine; + +namespace OpenApparatus.Unity.Internal +{ + public static class OpenApparatusSpace + { + public static Vector3 ToUnity(Vector3 studio) => + new Vector3(-studio.x, studio.y, studio.z); + + public static Vector2 ToUnityXZ(Vector2 studioXz) => + new Vector2(-studioXz.x, studioXz.y); + + public static float YawToUnity(float studioRadiansCcw) => + -studioRadiansCcw; + } +} diff --git a/Runtime/OpenApparatus.Runtime.asmdef b/Runtime/OpenApparatus.Runtime.asmdef index eff6a8d..c1aaf95 100644 --- a/Runtime/OpenApparatus.Runtime.asmdef +++ b/Runtime/OpenApparatus.Runtime.asmdef @@ -9,6 +9,17 @@ "precompiledReferences": [], "autoReferenced": true, "defineConstraints": [], - "versionDefines": [], + "versionDefines": [ + { + "name": "com.unity.render-pipelines.universal", + "expression": "", + "define": "OPENAPPARATUS_URP" + }, + { + "name": "com.unity.render-pipelines.high-definition", + "expression": "", + "define": "OPENAPPARATUS_HDRP" + } + ], "noEngineReferences": false } diff --git a/Samples~/ImportedEnvironment/README.md b/Samples~/ImportedEnvironment/README.md new file mode 100644 index 0000000..6b94980 --- /dev/null +++ b/Samples~/ImportedEnvironment/README.md @@ -0,0 +1,29 @@ +# ImportedEnvironment sample + +A minimal demonstration of the JSON import flow. + +`single_room.json` is a one-room Studio export. When you import this +sample into your project, Unity runs `JsonEnvironmentImporter` and +produces a `MultiRoomEnvironmentAsset`. + +## To run + +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. + +## 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. + +## 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/`. diff --git a/Samples~/ImportedEnvironment/single_room.json b/Samples~/ImportedEnvironment/single_room.json new file mode 100644 index 0000000..07ba3e1 --- /dev/null +++ b/Samples~/ImportedEnvironment/single_room.json @@ -0,0 +1,74 @@ +{ + "schema": "openapparatus.environment", + "version": 4, + "parameters": { + "tileSize": 3.5, + "wallThickness": 0.2, + "wallHeight": 3.0, + "doorWidth": 1.2, + "doorHeight": 2.2, + "windowWidth": 1.0, + "windowHeight": 1.2, + "windowSillHeight": 0.9, + "gridSubdivision": 1, + "defaultObjectY": 0.0 + }, + "grid": { + "width": 2, + "length": 2, + "tiles": [[0, 0], [0, 0]] + }, + "objectSlots": [ + { "id": 1, "shape": "cube", "color": [0.9, 0.4, 0.4], "size": 0.3, "displayName": "Cup", "objectType": "Cup" } + ], + "rooms": [ + { + "id": 0, + "shape": { "type": "rectangle", "width": 7.0, "depth": 7.0 }, + "position": [0.0, 0.0], + "tiles": [[0, 0], [1, 0], [0, 1], [1, 1]], + "walls": [ + { + "number": 1, + "side": "south", + "start": [0.0, 0.0], + "end": [7.0, 0.0], + "neighborRoomId": null, + "passage": { "type": "closed" } + }, + { + "number": 2, + "side": "east", + "start": [7.0, 0.0], + "end": [7.0, 7.0], + "neighborRoomId": null, + "passage": { "type": "closed" } + }, + { + "number": 3, + "side": "north", + "start": [7.0, 7.0], + "end": [0.0, 7.0], + "neighborRoomId": null, + "passage": { + "type": "doorway", + "openings": [ + { "offsetAlongEdge": 2.9, "width": 1.2, "height": 2.2, "sillHeight": 0.0 } + ] + } + }, + { + "number": 4, + "side": "west", + "start": [0.0, 7.0], + "end": [0.0, 0.0], + "neighborRoomId": null, + "passage": { "type": "closed" } + } + ], + "objects": [ + { "slot": 1, "position": [3.5, 0.0, 3.5], "rotation": 0.0 } + ] + } + ] +} diff --git a/Tests/Editor/ColliderBuilderTests.cs b/Tests/Editor/ColliderBuilderTests.cs new file mode 100644 index 0000000..bc99cc5 --- /dev/null +++ b/Tests/Editor/ColliderBuilderTests.cs @@ -0,0 +1,44 @@ +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using OpenApparatus.Unity.Editor.Internal; + +namespace OpenApparatus.Unity.Tests.Editor +{ + public sealed class ColliderBuilderTests + { + const string FixturePath = "Packages/com.openapparatus.unity/Tests/Fixtures/single_room.json"; + + [TestCase(ColliderMode.None, 0, 0)] + [TestCase(ColliderMode.WallsOnly, 4, 0)] + [TestCase(ColliderMode.FloorsOnly, 0, 4)] + [TestCase(ColliderMode.All, 4, 4)] + public void Apply_ProducesExpectedColliderCounts( + ColliderMode mode, int expectedWallColliders, int expectedFloorColliders) + { + var asset = AssetDatabase.LoadAssetAtPath(FixturePath); + asset.ColliderMode = mode; + GameObject root = null; + try + { + root = EnvironmentSpawner.Spawn(asset); + int wallColliders = 0; + int floorColliders = 0; + foreach (var col in root.GetComponentsInChildren(includeInactive: true)) + { + var name = col.gameObject.name; + if (name == "WallCollider") wallColliders++; + else if (name == "FloorColliders") floorColliders++; + else Assert.Fail($"Unexpected BoxCollider parent: {name}"); + } + Assert.AreEqual(expectedWallColliders, wallColliders, "wall collider count"); + Assert.AreEqual(expectedFloorColliders, floorColliders, "floor tile collider count"); + } + finally + { + if (root != null) Object.DestroyImmediate(root); + asset.ColliderMode = ColliderMode.None; + } + } + } +} diff --git a/Tests/Editor/GltfEnvironmentPostprocessorTests.cs b/Tests/Editor/GltfEnvironmentPostprocessorTests.cs new file mode 100644 index 0000000..115099a --- /dev/null +++ b/Tests/Editor/GltfEnvironmentPostprocessorTests.cs @@ -0,0 +1,92 @@ +using System.Reflection; +using NUnit.Framework; +using UnityEngine; + +namespace OpenApparatus.Unity.Tests.Editor +{ + public sealed class GltfEnvironmentPostprocessorTests + { + [Test] + public void AttachComponents_RecognisesRoomWallObjectNodes() + { + var root = new GameObject("Model"); + try + { + var room0 = MakeChild(root.transform, "Room_0"); + var floor = MakeChild(room0.transform, "Room_0_floor"); + var wall1 = MakeChild(room0.transform, "Room_0_wall_1"); + var wall2 = MakeChild(room0.transform, "Room_0_wall_2"); + var obj = MakeChild(room0.transform, "Room_0_object_3_0"); + + InvokeAttachComponents(root.transform); + + var roomComp = room0.GetComponent(); + Assert.IsNotNull(roomComp, "Room_0 should receive a Room component"); + Assert.AreEqual(0, roomComp.RoomId); + + var wall1Comp = wall1.GetComponent(); + Assert.IsNotNull(wall1Comp); + Assert.AreEqual(1, wall1Comp.WallNumber); + + var wall2Comp = wall2.GetComponent(); + Assert.IsNotNull(wall2Comp); + Assert.AreEqual(2, wall2Comp.WallNumber); + + var instance = obj.GetComponent(); + Assert.IsNotNull(instance); + Assert.AreEqual(0, instance.OwningRoomId); + Assert.AreEqual(3, instance.Slot); + + Assert.IsNull(floor.GetComponent(), + "Floor nodes should not get Room components."); + Assert.IsNull(floor.GetComponent(), + "Floor nodes should not get Wall components."); + } + finally + { + Object.DestroyImmediate(root); + } + } + + [Test] + public void AttachComponents_IgnoresForeignHierarchy() + { + var root = new GameObject("Model"); + try + { + MakeChild(root.transform, "RandomMesh"); + MakeChild(root.transform, "SomeArmature"); + InvokeAttachComponents(root.transform); + + foreach (Transform t in root.GetComponentsInChildren()) + { + Assert.IsNull(t.gameObject.GetComponent()); + Assert.IsNull(t.gameObject.GetComponent()); + Assert.IsNull(t.gameObject.GetComponent()); + } + } + finally + { + Object.DestroyImmediate(root); + } + } + + static GameObject MakeChild(Transform parent, string name) + { + var go = new GameObject(name); + go.transform.SetParent(parent, worldPositionStays: false); + return go; + } + + static void InvokeAttachComponents(Transform root) + { + var type = typeof(OpenApparatus.Unity.Editor.Importers.JsonEnvironmentImporter).Assembly + .GetType("OpenApparatus.Unity.Editor.Importers.GltfEnvironmentPostprocessor"); + Assert.IsNotNull(type, "GltfEnvironmentPostprocessor type not found."); + var method = type.GetMethod("AttachComponents", + BindingFlags.NonPublic | BindingFlags.Static); + Assert.IsNotNull(method, "AttachComponents method not found."); + method.Invoke(null, new object[] { root }); + } + } +} diff --git a/Tests/Editor/JsonEnvironmentImporterTests.cs b/Tests/Editor/JsonEnvironmentImporterTests.cs new file mode 100644 index 0000000..40335bc --- /dev/null +++ b/Tests/Editor/JsonEnvironmentImporterTests.cs @@ -0,0 +1,86 @@ +using System.IO; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using OpenApparatus.Unity.Editor.Internal; +using OpenApparatus.Unity.Editor.Importers; + +namespace OpenApparatus.Unity.Tests.Editor +{ + public sealed class JsonEnvironmentImporterTests + { + const string FixturePath = "Packages/com.openapparatus.unity/Tests/Fixtures/single_room.json"; + const string ForeignPath = "Packages/com.openapparatus.unity/Tests/Fixtures/foreign.json"; + + [Test] + public void Discriminator_AcceptsOpenApparatusJson() + { + var text = File.ReadAllText(FixturePath); + Assert.IsTrue(JsonEnvironmentDiscriminator.IsOpenApparatus(text)); + } + + [Test] + public void Discriminator_RejectsForeignJson() + { + var text = File.ReadAllText(ForeignPath); + Assert.IsFalse(JsonEnvironmentDiscriminator.IsOpenApparatus(text)); + } + + [Test] + public void Discriminator_RejectsMalformedJson() + { + Assert.IsFalse(JsonEnvironmentDiscriminator.IsOpenApparatus("{ not json")); + } + + [Test] + public void Import_ProducesMultiRoomEnvironmentAsset() + { + var asset = AssetDatabase.LoadAssetAtPath(FixturePath); + Assert.IsNotNull(asset, "single_room.json 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); + } + + [Test] + public void Import_RouteWallPositionsThroughOpenApparatusSpace() + { + var asset = AssetDatabase.LoadAssetAtPath(FixturePath); + var firstWall = asset.Rooms[0].Walls[0]; + // South wall in fixture: start [0,0] -> end [7,0] in Studio coords. + // ToUnity negates X, so endLocal.x should be -7, startLocal.x should be 0. + Assert.AreEqual(0f, firstWall.StartLocal.x, 1e-4f); + Assert.AreEqual(-7f, firstWall.EndLocal.x, 1e-4f); + } + + [Test] + public void Import_RecognisesDoorwayPassage() + { + var asset = AssetDatabase.LoadAssetAtPath(FixturePath); + var northWall = asset.Rooms[0].Walls[2]; + Assert.AreEqual(PassageKind.Doorway, northWall.PassageKind); + Assert.AreEqual(1, northWall.Openings.Length); + Assert.AreEqual(1.2f, northWall.Openings[0].Width, 1e-4f); + } + + [Test] + public void Spawn_ProducesExpectedHierarchy() + { + var asset = AssetDatabase.LoadAssetAtPath(FixturePath); + GameObject root = null; + try + { + root = EnvironmentSpawner.Spawn(asset); + Assert.IsNotNull(root); + Assert.IsNotNull(root.GetComponent()); + Assert.AreEqual(1, root.GetComponentsInChildren().Length); + Assert.AreEqual(4, root.GetComponentsInChildren().Length); + Assert.AreEqual(1, root.GetComponentsInChildren().Length); + } + finally + { + if (root != null) Object.DestroyImmediate(root); + } + } + } +} diff --git a/Tests/Editor/OpenApparatus.Tests.Editor.asmdef b/Tests/Editor/OpenApparatus.Tests.Editor.asmdef new file mode 100644 index 0000000..0956fac --- /dev/null +++ b/Tests/Editor/OpenApparatus.Tests.Editor.asmdef @@ -0,0 +1,25 @@ +{ + "name": "OpenApparatus.Tests.Editor", + "rootNamespace": "OpenApparatus.Unity.Tests.Editor", + "references": [ + "OpenApparatus.Runtime", + "OpenApparatus.Editor", + "UnityEngine.TestRunner", + "UnityEditor.TestRunner" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll" + ], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Tests/Editor/OpenApparatusSpaceTests.cs b/Tests/Editor/OpenApparatusSpaceTests.cs new file mode 100644 index 0000000..4b2eeb1 --- /dev/null +++ b/Tests/Editor/OpenApparatusSpaceTests.cs @@ -0,0 +1,37 @@ +using NUnit.Framework; +using UnityEngine; +using OpenApparatus.Unity.Internal; + +namespace OpenApparatus.Unity.Tests.Editor +{ + public sealed class OpenApparatusSpaceTests + { + [Test] + public void ToUnity_NegatesX() + { + var unity = OpenApparatusSpace.ToUnity(new Vector3(1, 2, 3)); + Assert.AreEqual(new Vector3(-1, 2, 3), unity); + } + + [Test] + public void ToUnityXZ_NegatesX() + { + var xz = OpenApparatusSpace.ToUnityXZ(new Vector2(5, 7)); + Assert.AreEqual(new Vector2(-5, 7), xz); + } + + [Test] + public void YawToUnity_NegatesAngle() + { + Assert.AreEqual(-1.5f, OpenApparatusSpace.YawToUnity(1.5f), 1e-6f); + } + + [Test] + public void ToUnity_IsInvolution() + { + var original = new Vector3(2.5f, 1.0f, -3.5f); + var back = OpenApparatusSpace.ToUnity(OpenApparatusSpace.ToUnity(original)); + Assert.AreEqual(original, back); + } + } +} diff --git a/Tests/Editor/PrefabSubstitutionApplicatorTests.cs b/Tests/Editor/PrefabSubstitutionApplicatorTests.cs new file mode 100644 index 0000000..d446d20 --- /dev/null +++ b/Tests/Editor/PrefabSubstitutionApplicatorTests.cs @@ -0,0 +1,100 @@ +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using UnityEngine.TestTools; +using OpenApparatus.Unity.Editor.Internal; + +namespace OpenApparatus.Unity.Tests.Editor +{ + public sealed class PrefabSubstitutionApplicatorTests + { + const string FixturePath = "Packages/com.openapparatus.unity/Tests/Fixtures/single_room.json"; + + [Test] + public void Apply_NullTable_LeavesPlaceholdersUntouched() + { + var asset = AssetDatabase.LoadAssetAtPath(FixturePath); + asset.Substitution = null; + GameObject root = null; + try + { + root = EnvironmentSpawner.Spawn(asset); + var placeholders = root.GetComponentsInChildren(); + Assert.AreEqual(1, placeholders.Length); + } + finally + { + if (root != null) Object.DestroyImmediate(root); + } + } + + [Test] + public void Apply_MatchingEntry_ReplacesPlaceholderWithPrefab() + { + var asset = AssetDatabase.LoadAssetAtPath(FixturePath); + + var prefabSource = GameObject.CreatePrimitive(PrimitiveType.Cylinder); + prefabSource.name = "TestCupPrefab"; + + var table = ScriptableObject.CreateInstance(); + table.Entries = new[] + { + new SubstitutionEntry + { + ObjectType = "Cup", + Prefab = prefabSource, + ScaleMultiplier = Vector3.one, + } + }; + asset.Substitution = table; + + GameObject root = null; + try + { + root = EnvironmentSpawner.Spawn(asset); + Assert.AreEqual(0, root.GetComponentsInChildren().Length, + "Placeholder should have been destroyed."); + + bool foundCylinder = false; + foreach (var mf in root.GetComponentsInChildren()) + if (mf.sharedMesh != null && mf.sharedMesh.name.Contains("Cylinder")) + foundCylinder = true; + Assert.IsTrue(foundCylinder, "Substituted prefab should be present."); + } + finally + { + if (root != null) Object.DestroyImmediate(root); + Object.DestroyImmediate(prefabSource); + asset.Substitution = null; + Object.DestroyImmediate(table); + } + } + + [Test] + public void Apply_NullPrefabEntry_LeavesPlaceholder() + { + var asset = AssetDatabase.LoadAssetAtPath(FixturePath); + var table = ScriptableObject.CreateInstance(); + table.Entries = new[] + { + new SubstitutionEntry { ObjectType = "Cup", Prefab = null, ScaleMultiplier = Vector3.one } + }; + asset.Substitution = table; + + GameObject root = null; + try + { + LogAssert.Expect(LogType.Warning, new System.Text.RegularExpressions.Regex( + "PrefabSubstitutionTable has an entry for 'Cup' but Prefab is null")); + root = EnvironmentSpawner.Spawn(asset); + Assert.AreEqual(1, root.GetComponentsInChildren().Length); + } + finally + { + if (root != null) Object.DestroyImmediate(root); + asset.Substitution = null; + Object.DestroyImmediate(table); + } + } + } +} diff --git a/Tests/Fixtures/foreign.json b/Tests/Fixtures/foreign.json new file mode 100644 index 0000000..e9c3331 --- /dev/null +++ b/Tests/Fixtures/foreign.json @@ -0,0 +1,4 @@ +{ + "name": "not-openapparatus", + "data": [1, 2, 3] +} diff --git a/Tests/Fixtures/single_room.json b/Tests/Fixtures/single_room.json new file mode 100644 index 0000000..07ba3e1 --- /dev/null +++ b/Tests/Fixtures/single_room.json @@ -0,0 +1,74 @@ +{ + "schema": "openapparatus.environment", + "version": 4, + "parameters": { + "tileSize": 3.5, + "wallThickness": 0.2, + "wallHeight": 3.0, + "doorWidth": 1.2, + "doorHeight": 2.2, + "windowWidth": 1.0, + "windowHeight": 1.2, + "windowSillHeight": 0.9, + "gridSubdivision": 1, + "defaultObjectY": 0.0 + }, + "grid": { + "width": 2, + "length": 2, + "tiles": [[0, 0], [0, 0]] + }, + "objectSlots": [ + { "id": 1, "shape": "cube", "color": [0.9, 0.4, 0.4], "size": 0.3, "displayName": "Cup", "objectType": "Cup" } + ], + "rooms": [ + { + "id": 0, + "shape": { "type": "rectangle", "width": 7.0, "depth": 7.0 }, + "position": [0.0, 0.0], + "tiles": [[0, 0], [1, 0], [0, 1], [1, 1]], + "walls": [ + { + "number": 1, + "side": "south", + "start": [0.0, 0.0], + "end": [7.0, 0.0], + "neighborRoomId": null, + "passage": { "type": "closed" } + }, + { + "number": 2, + "side": "east", + "start": [7.0, 0.0], + "end": [7.0, 7.0], + "neighborRoomId": null, + "passage": { "type": "closed" } + }, + { + "number": 3, + "side": "north", + "start": [7.0, 7.0], + "end": [0.0, 7.0], + "neighborRoomId": null, + "passage": { + "type": "doorway", + "openings": [ + { "offsetAlongEdge": 2.9, "width": 1.2, "height": 2.2, "sillHeight": 0.0 } + ] + } + }, + { + "number": 4, + "side": "west", + "start": [0.0, 7.0], + "end": [0.0, 0.0], + "neighborRoomId": null, + "passage": { "type": "closed" } + } + ], + "objects": [ + { "slot": 1, "position": [3.5, 0.0, 3.5], "rotation": 0.0 } + ] + } + ] +} diff --git a/Tests/UnityProject/.gitignore b/Tests/UnityProject/.gitignore new file mode 100644 index 0000000..a4087a9 --- /dev/null +++ b/Tests/UnityProject/.gitignore @@ -0,0 +1,22 @@ +[Ll]ibrary/ +[Tt]emp/ +[Oo]bj/ +[Bb]uild/ +[Bb]uilds/ +[Ll]ogs/ +[Uu]ser[Ss]ettings/ + +*.csproj +*.unityproj +*.sln +*.suo +*.tmp +*.user +*.userprefs +*.pidb +*.booproj +*.svd +*.pdb +*.mdb +*.opendb +*.VC.db diff --git a/Tests/UnityProject/Packages/manifest.json b/Tests/UnityProject/Packages/manifest.json new file mode 100644 index 0000000..c17d335 --- /dev/null +++ b/Tests/UnityProject/Packages/manifest.json @@ -0,0 +1,10 @@ +{ + "dependencies": { + "com.openapparatus.unity": "file:../../..", + "com.unity.cloud.gltfast": "6.0.0", + "com.unity.nuget.newtonsoft-json": "3.2.1", + "com.unity.test-framework": "1.4.4", + "com.unity.ide.visualstudio": "2.0.22", + "com.unity.ide.rider": "3.0.27" + } +} diff --git a/Tests/UnityProject/ProjectSettings/ProjectVersion.txt b/Tests/UnityProject/ProjectSettings/ProjectVersion.txt new file mode 100644 index 0000000..3b8e4c6 --- /dev/null +++ b/Tests/UnityProject/ProjectSettings/ProjectVersion.txt @@ -0,0 +1,2 @@ +m_EditorVersion: 2022.3.40f1 +m_EditorVersionWithRevision: 2022.3.40f1 (cf6d70d9c5fe) diff --git a/build/publish-core-dll.ps1 b/build/publish-core-dll.ps1 new file mode 100644 index 0000000..a8e698d --- /dev/null +++ b/build/publish-core-dll.ps1 @@ -0,0 +1,44 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $false)] + [string] $CoreRepo = (Join-Path $PSScriptRoot "..\..\..\..\..\openapparatus-core"), + + [Parameter(Mandatory = $false)] + [ValidateSet("Debug", "Release")] + [string] $Configuration = "Release" +) + +$ErrorActionPreference = "Stop" + +$packageRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$pluginsDir = Join-Path $packageRoot "Plugins" +$dllName = "OpenApparatus.Core.dll" +$xmlName = "OpenApparatus.Core.xml" + +if (-not (Test-Path $CoreRepo)) { + throw "openapparatus-core repo not found at: $CoreRepo. Pass -CoreRepo if it lives elsewhere." +} + +$projectPath = Join-Path $CoreRepo "src\OpenApparatus.Core\OpenApparatus.Core.csproj" +if (-not (Test-Path $projectPath)) { + throw "Could not find OpenApparatus.Core.csproj under $CoreRepo. Is this the right repo?" +} + +Write-Host "Building $projectPath ($Configuration, netstandard2.1)..." +& dotnet build $projectPath -c $Configuration -f netstandard2.1 +if ($LASTEXITCODE -ne 0) { throw "dotnet build failed (exit $LASTEXITCODE)." } + +$outDir = Join-Path $CoreRepo "src\OpenApparatus.Core\bin\$Configuration\netstandard2.1" +$srcDll = Join-Path $outDir $dllName +$srcXml = Join-Path $outDir $xmlName +if (-not (Test-Path $srcDll)) { throw "Build succeeded but $dllName missing at $srcDll." } + +if (-not (Test-Path $pluginsDir)) { New-Item -ItemType Directory -Path $pluginsDir | Out-Null } + +Copy-Item $srcDll (Join-Path $pluginsDir $dllName) -Force +if (Test-Path $srcXml) { + Copy-Item $srcXml (Join-Path $pluginsDir $xmlName) -Force +} + +Write-Host "Published $dllName to $pluginsDir" +Write-Host "Commit the updated DLL to track the Core version your package targets." diff --git a/build/publish-core-dll.sh b/build/publish-core-dll.sh new file mode 100644 index 0000000..a1bf3f1 --- /dev/null +++ b/build/publish-core-dll.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PACKAGE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +CORE_REPO="${1:-$(cd "$PACKAGE_ROOT/../../../.." && pwd)/openapparatus-core}" +CONFIG="${2:-Release}" + +DLL_NAME="OpenApparatus.Core.dll" +XML_NAME="OpenApparatus.Core.xml" +PLUGINS_DIR="$PACKAGE_ROOT/Plugins" + +if [ ! -d "$CORE_REPO" ]; then + echo "openapparatus-core repo not found at: $CORE_REPO" >&2 + echo "Usage: $0 [Debug|Release]" >&2 + exit 1 +fi + +PROJECT_PATH="$CORE_REPO/src/OpenApparatus.Core/OpenApparatus.Core.csproj" +if [ ! -f "$PROJECT_PATH" ]; then + echo "Could not find OpenApparatus.Core.csproj under $CORE_REPO" >&2 + exit 1 +fi + +echo "Building $PROJECT_PATH ($CONFIG, netstandard2.1)..." +dotnet build "$PROJECT_PATH" -c "$CONFIG" -f netstandard2.1 + +OUT_DIR="$CORE_REPO/src/OpenApparatus.Core/bin/$CONFIG/netstandard2.1" +SRC_DLL="$OUT_DIR/$DLL_NAME" +SRC_XML="$OUT_DIR/$XML_NAME" + +if [ ! -f "$SRC_DLL" ]; then + echo "Build succeeded but $DLL_NAME missing at $SRC_DLL" >&2 + exit 1 +fi + +mkdir -p "$PLUGINS_DIR" +cp -f "$SRC_DLL" "$PLUGINS_DIR/$DLL_NAME" +[ -f "$SRC_XML" ] && cp -f "$SRC_XML" "$PLUGINS_DIR/$XML_NAME" + +echo "Published $DLL_NAME to $PLUGINS_DIR" +echo "Commit the updated DLL to track the Core version your package targets." diff --git a/package.json b/package.json index a49a00c..5aa51ed 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,14 @@ "changelogUrl": "https://github.com/OpenApparatus/unity/blob/main/CHANGELOG.md", "licensesUrl": "https://github.com/OpenApparatus/unity/blob/main/LICENSE", "dependencies": { - "com.unity.cloud.gltfast": "6.0.0" - } + "com.unity.cloud.gltfast": "6.0.0", + "com.unity.nuget.newtonsoft-json": "3.2.1" + }, + "samples": [ + { + "displayName": "Imported Environment", + "description": "Drag-and-drop demo: import a Studio JSON export and spawn it.", + "path": "Samples~/ImportedEnvironment" + } + ] }