diff --git a/OpenApparatus.sln b/OpenApparatus.sln index 1331336..452b860 100644 --- a/OpenApparatus.sln +++ b/OpenApparatus.sln @@ -11,6 +11,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenApparatus.Core.Tests", "tests\OpenApparatus.Core.Tests\OpenApparatus.Core.Tests.csproj", "{A12EE324-4F41-4629-B08F-C8A0B338BE22}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenApparatus.IO", "src\OpenApparatus.IO\OpenApparatus.IO.csproj", "{962B8D1B-CCE8-49AB-92FF-2A1FDFA9701E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -45,6 +47,18 @@ Global {A12EE324-4F41-4629-B08F-C8A0B338BE22}.Release|x64.Build.0 = Release|Any CPU {A12EE324-4F41-4629-B08F-C8A0B338BE22}.Release|x86.ActiveCfg = Release|Any CPU {A12EE324-4F41-4629-B08F-C8A0B338BE22}.Release|x86.Build.0 = Release|Any CPU + {962B8D1B-CCE8-49AB-92FF-2A1FDFA9701E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {962B8D1B-CCE8-49AB-92FF-2A1FDFA9701E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {962B8D1B-CCE8-49AB-92FF-2A1FDFA9701E}.Debug|x64.ActiveCfg = Debug|Any CPU + {962B8D1B-CCE8-49AB-92FF-2A1FDFA9701E}.Debug|x64.Build.0 = Debug|Any CPU + {962B8D1B-CCE8-49AB-92FF-2A1FDFA9701E}.Debug|x86.ActiveCfg = Debug|Any CPU + {962B8D1B-CCE8-49AB-92FF-2A1FDFA9701E}.Debug|x86.Build.0 = Debug|Any CPU + {962B8D1B-CCE8-49AB-92FF-2A1FDFA9701E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {962B8D1B-CCE8-49AB-92FF-2A1FDFA9701E}.Release|Any CPU.Build.0 = Release|Any CPU + {962B8D1B-CCE8-49AB-92FF-2A1FDFA9701E}.Release|x64.ActiveCfg = Release|Any CPU + {962B8D1B-CCE8-49AB-92FF-2A1FDFA9701E}.Release|x64.Build.0 = Release|Any CPU + {962B8D1B-CCE8-49AB-92FF-2A1FDFA9701E}.Release|x86.ActiveCfg = Release|Any CPU + {962B8D1B-CCE8-49AB-92FF-2A1FDFA9701E}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -52,5 +66,6 @@ Global GlobalSection(NestedProjects) = preSolution {E7756B98-B771-4FBC-B857-51EAC08D7703} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {A12EE324-4F41-4629-B08F-C8A0B338BE22} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {962B8D1B-CCE8-49AB-92FF-2A1FDFA9701E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/src/OpenApparatus.IO/Exporters/GltfExporter.cs b/src/OpenApparatus.IO/Exporters/GltfExporter.cs new file mode 100644 index 0000000..e5e8ff9 --- /dev/null +++ b/src/OpenApparatus.IO/Exporters/GltfExporter.cs @@ -0,0 +1,541 @@ +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using OpenApparatus.Geometry; +using OpenApparatus.Topology; +using SharpGLTF.Geometry; +using SharpGLTF.Geometry.VertexTypes; +using SharpGLTF.Materials; +using SharpGLTF.Scenes; +using SharpGLTF.Schema2; + +namespace OpenApparatus.IO.Exporters; + +/// +/// Writes a generated MultiRoomEnvironment as a glTF 2.0 file (.glb binary or +/// .gltf JSON+bin). Unlike OBJ, glTF natively supports scene hierarchy: the +/// output puts every room under its own named parent node, and each per-room +/// mesh (floor, ceiling, wall_<i>) is a child node under that parent. +/// Importers (Unity, Blender, Three.js, glTF web viewers) all reconstruct the +/// hierarchy automatically — no post-processing required. +/// +/// Internal walls are split by face normal so each room owns the body face it +/// can see — RoomA gets +N, RoomB gets -N. Frame pieces (top, bottom, caps, +/// tunnel jambs / lintels / sills / thresholds) belong to the lower-id room +/// and carry a unique material per (room, part) so re-skinning one room never +/// alters the other room's view of a shared wall. +/// +/// Coordinate handedness: glTF is right-handed, Unity is left-handed, and +/// Unity's glTF importers (UnityGLTF, glTFast) compensate by negating X on +/// import. Without intervention that turns the studio's "+X = east, right on +/// the 2D map" into "-X = left in Unity," so the imported scene reads as a +/// left/right mirror of the editor. We pre-mirror X in the writer (positions, +/// normals, object translations / Y-rotations, and triangle winding) so the +/// importer's flip cancels out and Unity sees the same orientation as the 2D +/// view. Right-handed viewers (Blender, Three.js) will see this as the actual +/// mirror — that's the trade-off; Unity is the primary target. +/// +public static class GltfExporter +{ + /// + /// Builds the scene and writes it to . The format is + /// chosen from the file extension: .glb writes a single binary file, .gltf + /// writes the JSON descriptor with a sidecar .bin for vertex data. + /// + public static void Export( + string path, + MultiRoomEnvironment plan, + float wallThickness, + float wallHeight, + IReadOnlyCollection? multiColorRoomIds = null, + IReadOnlyDictionary? roomFloorColors = null, + IReadOnlyDictionary? roomCeilingColors = null, + IReadOnlyDictionary? roomSingleWallColors = null, + IReadOnlyDictionary<(int RoomId, int MidX, int MidZ), Vector3>? perWallColors = null, + IReadOnlyList? objects = null, + IReadOnlyList? objectTypes = null) + { + var model = BuildModel(plan, wallThickness, wallHeight, + multiColorRoomIds, roomFloorColors, roomCeilingColors, + roomSingleWallColors, perWallColors, objects, objectTypes); + // SharpGLTF picks GLB vs glTF+bin from the extension on Save. + model.Save(path); + } + + public static ModelRoot BuildModel( + MultiRoomEnvironment plan, + float wallThickness, + float wallHeight, + IReadOnlyCollection? multiColorRoomIds = null, + IReadOnlyDictionary? roomFloorColors = null, + IReadOnlyDictionary? roomCeilingColors = null, + IReadOnlyDictionary? roomSingleWallColors = null, + IReadOnlyDictionary<(int RoomId, int MidX, int MidZ), Vector3>? perWallColors = null, + IReadOnlyList? objects = null, + IReadOnlyList? objectTypes = null) + { + ObjectType? TypeAt(int slot1Based) + { + int idx = slot1Based - 1; + if (objectTypes is null || idx < 0 || idx >= objectTypes.Count) return null; + return objectTypes[idx]; + } + var interiorBuilder = new RectangleInteriorBuilder(); + var wallBuilder = new BoundaryWallBuilder(); + + // Pre-build walls so the same MeshData backs both rooms' splits. + var wallMeshes = new Dictionary(); + foreach (var adj in plan.Adjacencies) + wallMeshes[adj] = wallBuilder.Build(adj, wallThickness, wallHeight); + + var scene = new SceneBuilder("OpenApparatus"); + var rootNode = new NodeBuilder("OpenApparatus"); + + foreach (var room in plan.Rooms) + { + var interior = interiorBuilder.Build(room, wallThickness, wallHeight); + var roomAdjacencies = new List(); + foreach (var adj in plan.Adjacencies) + if (adj.RoomA == room || adj.RoomB == room) + roomAdjacencies.Add(adj); + + var roomNode = rootNode.CreateNode($"Room_{room.Id}"); + + // -------- Floor -------- + var floorColor = roomFloorColors != null && roomFloorColors.TryGetValue(room.Id, out var fc) + ? (fc.X, fc.Y, fc.Z) : FloorColor; + var floorMb = NewMesh($"room_{room.Id}_floor"); + var floorPrim = floorMb.UsePrimitive(MakeMaterial( + $"{ObjExporter.FloorMaterialPrefix}_Room{room.Id}", floorColor)); + AddSubmeshTriangles(floorPrim, interior, SubmeshIndex.Floor); + foreach (var adj in roomAdjacencies) + { + if (LowerIdOwner(adj).Id != room.Id) continue; + AddSubmeshTriangles(floorPrim, wallMeshes[adj], SubmeshIndex.Floor); + } + AddMeshIfNonEmpty(scene, roomNode, floorMb, $"room_{room.Id}_floor"); + + // -------- Ceiling -------- + var ceilingColor = roomCeilingColors != null && roomCeilingColors.TryGetValue(room.Id, out var cc) + ? (cc.X, cc.Y, cc.Z) : CeilingColor; + var ceilingMb = NewMesh($"room_{room.Id}_ceiling"); + var ceilingPrim = ceilingMb.UsePrimitive(MakeMaterial( + $"{ObjExporter.CeilingMaterialPrefix}_Room{room.Id}", ceilingColor)); + AddSubmeshTriangles(ceilingPrim, interior, SubmeshIndex.Ceiling); + AddMeshIfNonEmpty(scene, roomNode, ceilingMb, $"room_{room.Id}_ceiling"); + + // -------- Walls -------- + bool roomMulti = multiColorRoomIds != null && multiColorRoomIds.Contains(room.Id); + if (roomMulti) + { + // Per-wall meshes so each adjacency carries its own material. + for (int i = 0; i < roomAdjacencies.Count; i++) + { + var adj = roomAdjacencies[i]; + var wall = wallMeshes[adj]; + var seg = adj.SharedSegment; + var nrm3 = new Vector3(seg.Normal.X, 0f, seg.Normal.Y); + bool roomIsA = adj.RoomA == room; + bool isLowerOwner = LowerIdOwner(adj).Id == room.Id; + + int wallNum = i + 1; + var color = ResolveWallColor(perWallColors, roomSingleWallColors, room.Id, adj); + var wallMb = NewMesh($"room_{room.Id}_wall_{wallNum}"); + var wallPrim = wallMb.UsePrimitive(MakeMaterial( + $"{ObjExporter.WallsMaterialPrefix}{wallNum}_Room{room.Id}", color)); + AddSplitWallTriangles( + wallPrim, wall, SubmeshIndex.Walls, nrm3, + includeBodyA: roomIsA, + includeBodyB: !roomIsA, + includeFrame: isLowerOwner); + AddMeshIfNonEmpty(scene, roomNode, wallMb, $"room_{room.Id}_wall_{wallNum}"); + } + } + else + { + // Single combined walls mesh for the room. Use the room's chosen + // single-wall color, falling back to the neutral default. + var singleColor = roomSingleWallColors != null && roomSingleWallColors.TryGetValue(room.Id, out var sw) + ? (sw.X, sw.Y, sw.Z) : WallsColor; + var wallsMb = NewMesh($"room_{room.Id}_walls"); + var wallsPrim = wallsMb.UsePrimitive(MakeMaterial( + $"{ObjExporter.WallsMaterialPrefix}_Room{room.Id}", singleColor)); + + foreach (var adj in roomAdjacencies) + { + var wall = wallMeshes[adj]; + var seg = adj.SharedSegment; + var nrm3 = new Vector3(seg.Normal.X, 0f, seg.Normal.Y); + bool roomIsA = adj.RoomA == room; + bool isLowerOwner = LowerIdOwner(adj).Id == room.Id; + AddSplitWallTriangles( + wallsPrim, wall, SubmeshIndex.Walls, nrm3, + includeBodyA: roomIsA, + includeBodyB: !roomIsA, + includeFrame: isLowerOwner); + } + AddMeshIfNonEmpty(scene, roomNode, wallsMb, $"room_{room.Id}_walls"); + } + + // Per-room object instances. Each one becomes a child node of the + // room called "slot__"; a Unity post-import script can + // swap these for prefabs by parsing that name. + if (objects != null) + { + int objCount = 0; + NodeBuilder? objectsParent = null; + for (int oi = 0; oi < objects.Count; oi++) + { + var obj = objects[oi]; + if (obj.OwningRoomId != room.Id) continue; + var slot = TypeAt(obj.Slot); + if (slot is null) continue; + + objectsParent ??= roomNode.CreateNode($"room_{room.Id}_objects"); + var inst = objectsParent.CreateNode($"slot_{obj.Slot}_{objCount}"); + // Mirror X on translation; rotation around Y becomes its + // negative under the same mirror (clockwise becomes CCW). + inst = inst.WithLocalTranslation(MirrorX(obj.Position)); + if (obj.Rotation != 0f) + inst = inst.WithLocalRotation(Quaternion.CreateFromAxisAngle(Vector3.UnitY, -obj.Rotation)); + + var mb = NewMesh($"slot_{obj.Slot}_{objCount}_mesh"); + var prim = mb.UsePrimitive(MakeMaterial( + $"OpenApparatus_Slot{obj.Slot}", (slot.Color.X, slot.Color.Y, slot.Color.Z))); + AddPrimitiveShape(prim, slot.Shape, slot.Size); + scene.AddRigidMesh(mb, inst); + objCount++; + } + } + } + + // Outside objects — anything whose owning room id doesn't match an + // existing room (typically -1) lands in a sibling 'Outside' node so + // importers don't drop it on the floor. + if (objects != null) + { + var validRoomIds = new HashSet(); + foreach (var r in plan.Rooms) validRoomIds.Add(r.Id); + int outsideCount = 0; + NodeBuilder? outsideParent = null; + for (int oi = 0; oi < objects.Count; oi++) + { + var obj = objects[oi]; + if (validRoomIds.Contains(obj.OwningRoomId)) continue; + var slot = TypeAt(obj.Slot); + if (slot is null) continue; + + outsideParent ??= rootNode.CreateNode("Outside"); + var inst = outsideParent.CreateNode($"slot_{obj.Slot}_{outsideCount}"); + inst = inst.WithLocalTranslation(MirrorX(obj.Position)); + if (obj.Rotation != 0f) + inst = inst.WithLocalRotation(Quaternion.CreateFromAxisAngle(Vector3.UnitY, -obj.Rotation)); + + var mb = NewMesh($"outside_slot_{obj.Slot}_{outsideCount}_mesh"); + var prim = mb.UsePrimitive(MakeMaterial( + $"OpenApparatus_Slot{obj.Slot}", (slot.Color.X, slot.Color.Y, slot.Color.Z))); + AddPrimitiveShape(prim, slot.Shape, slot.Size); + scene.AddRigidMesh(mb, inst); + outsideCount++; + } + } + + scene.AddNode(rootNode); + return scene.ToGltf2(); + } + + /// Generates the geometry for one object slot's primitive at the + /// origin (rotation/translation are applied at the node level). Sized so + /// the largest extent is roughly metres. + static void AddPrimitiveShape(IPrimitiveBuilder prim, ObjectShape shape, float size) + { + switch (shape) + { + case ObjectShape.Cube: BuildBox(prim, size, size, size); break; + case ObjectShape.Sphere: BuildSphere(prim, size * 0.5f, 10, 16); break; + case ObjectShape.Cylinder: BuildCylinder(prim, size * 0.5f, size, 16); break; + case ObjectShape.SquatCylinder: BuildCylinder(prim, size * 0.5f, size * 0.4f, 16); break; + case ObjectShape.Cone: BuildCone(prim, size * 0.5f, size, 16); break; + case ObjectShape.Capsule: BuildCapsule(prim, size * 0.3f, size, 10, 16); break; + case ObjectShape.Pyramid: BuildPyramid(prim, size); break; + } + } + + static void BuildBox(IPrimitiveBuilder prim, float w, float h, float d) + { + float hx = w * 0.5f, hy = h * 0.5f, hz = d * 0.5f; + // Six faces. Vertices wound CCW from outside. + // -y (bottom) + AddQuad(prim, new(-hx, 0, -hz), new(hx, 0, -hz), new(hx, 0, hz), new(-hx, 0, hz), new(0, -1, 0)); + // +y (top) + AddQuad(prim, new(-hx, h, hz), new(hx, h, hz), new(hx, h, -hz), new(-hx, h, -hz), new(0, 1, 0)); + // -z + AddQuad(prim, new(-hx, 0, -hz), new(-hx, h, -hz), new(hx, h, -hz), new(hx, 0, -hz), new(0, 0, -1)); + // +z + AddQuad(prim, new(hx, 0, hz), new(hx, h, hz), new(-hx, h, hz), new(-hx, 0, hz), new(0, 0, 1)); + // -x + AddQuad(prim, new(-hx, 0, hz), new(-hx, h, hz), new(-hx, h, -hz), new(-hx, 0, -hz), new(-1, 0, 0)); + // +x + AddQuad(prim, new(hx, 0, -hz), new(hx, h, -hz), new(hx, h, hz), new(hx, 0, hz), new(1, 0, 0)); + } + + static void BuildSphere(IPrimitiveBuilder prim, float r, int stacks, int slices) + { + // Origin at the bottom of the sphere — the editor's Y is "metres above + // the floor" so a Y of 0 should place the sphere's lowest point on the + // floor. Stacks = latitude rings, slices = longitude segments. + for (int i = 0; i < stacks; i++) + { + double phi1 = System.Math.PI * (i / (double)stacks); + double phi2 = System.Math.PI * ((i + 1) / (double)stacks); + for (int j = 0; j < slices; j++) + { + double theta1 = 2 * System.Math.PI * (j / (double)slices); + double theta2 = 2 * System.Math.PI * ((j + 1) / (double)slices); + Vector3 P(double phi, double theta) => new( + r + (float)(r * System.Math.Sin(phi) * System.Math.Cos(theta)) - r, + r + (float)(r * System.Math.Cos(phi)), + (float)(r * System.Math.Sin(phi) * System.Math.Sin(theta))); + var v00 = P(phi1, theta1); + var v01 = P(phi1, theta2); + var v10 = P(phi2, theta1); + var v11 = P(phi2, theta2); + AddTri(prim, v00, v01, v11, ApproxNormal(v00, v01, v11)); + AddTri(prim, v00, v11, v10, ApproxNormal(v00, v11, v10)); + } + } + } + + static void BuildCylinder(IPrimitiveBuilder prim, float r, float h, int slices) + { + for (int i = 0; i < slices; i++) + { + double t1 = 2 * System.Math.PI * (i / (double)slices); + double t2 = 2 * System.Math.PI * ((i + 1) / (double)slices); + float c1 = (float)System.Math.Cos(t1), s1 = (float)System.Math.Sin(t1); + float c2 = (float)System.Math.Cos(t2), s2 = (float)System.Math.Sin(t2); + // Side + var n1 = new Vector3(c1, 0, s1); + var n2 = new Vector3(c2, 0, s2); + AddTri(prim, new(r * c1, 0, r * s1), new(r * c2, 0, r * s2), new(r * c2, h, r * s2), n1); + AddTri(prim, new(r * c1, 0, r * s1), new(r * c2, h, r * s2), new(r * c1, h, r * s1), n1); + // Top cap + AddTri(prim, new(0, h, 0), new(r * c1, h, r * s1), new(r * c2, h, r * s2), new(0, 1, 0)); + // Bottom cap + AddTri(prim, new(0, 0, 0), new(r * c2, 0, r * s2), new(r * c1, 0, r * s1), new(0, -1, 0)); + } + } + + static void BuildCone(IPrimitiveBuilder prim, float r, float h, int slices) + { + for (int i = 0; i < slices; i++) + { + double t1 = 2 * System.Math.PI * (i / (double)slices); + double t2 = 2 * System.Math.PI * ((i + 1) / (double)slices); + float c1 = (float)System.Math.Cos(t1), s1 = (float)System.Math.Sin(t1); + float c2 = (float)System.Math.Cos(t2), s2 = (float)System.Math.Sin(t2); + var p1 = new Vector3(r * c1, 0, r * s1); + var p2 = new Vector3(r * c2, 0, r * s2); + var apex = new Vector3(0, h, 0); + AddTri(prim, p1, p2, apex, ApproxNormal(p1, p2, apex)); + AddTri(prim, new(0, 0, 0), p2, p1, new(0, -1, 0)); + } + } + + static void BuildCapsule(IPrimitiveBuilder prim, float r, float h, int stacks, int slices) + { + // Cylindrical body + half-spheres on each end. Simplification: render + // as a stretched sphere at the centre. Acceptable for stand-in geometry. + BuildCylinder(prim, r, h, slices); + // Top hemisphere + for (int i = 0; i < stacks; i++) + { + double phi1 = (System.Math.PI * 0.5) * (i / (double)stacks); + double phi2 = (System.Math.PI * 0.5) * ((i + 1) / (double)stacks); + for (int j = 0; j < slices; j++) + { + double theta1 = 2 * System.Math.PI * (j / (double)slices); + double theta2 = 2 * System.Math.PI * ((j + 1) / (double)slices); + Vector3 P(double phi, double theta) => new( + (float)(r * System.Math.Sin(phi) * System.Math.Cos(theta)), + h + (float)(r * System.Math.Cos(phi)), + (float)(r * System.Math.Sin(phi) * System.Math.Sin(theta))); + var v00 = P(phi2, theta1); + var v01 = P(phi2, theta2); + var v10 = P(phi1, theta1); + var v11 = P(phi1, theta2); + AddTri(prim, v00, v01, v11, ApproxNormal(v00, v01, v11)); + AddTri(prim, v00, v11, v10, ApproxNormal(v00, v11, v10)); + } + } + } + + static void BuildPyramid(IPrimitiveBuilder prim, float size) + { + float h = size, hx = size * 0.5f; + var p1 = new Vector3(-hx, 0, -hx); + var p2 = new Vector3( hx, 0, -hx); + var p3 = new Vector3( hx, 0, hx); + var p4 = new Vector3(-hx, 0, hx); + var apex = new Vector3(0, h, 0); + AddQuad(prim, p1, p4, p3, p2, new(0, -1, 0)); + AddTri(prim, p1, p2, apex, ApproxNormal(p1, p2, apex)); + AddTri(prim, p2, p3, apex, ApproxNormal(p2, p3, apex)); + AddTri(prim, p3, p4, apex, ApproxNormal(p3, p4, apex)); + AddTri(prim, p4, p1, apex, ApproxNormal(p4, p1, apex)); + } + + static Vector3 ApproxNormal(Vector3 a, Vector3 b, Vector3 c) + { + var n = Vector3.Cross(b - a, c - a); + var len = n.Length(); + return len > 1e-6f ? n / len : new Vector3(0, 1, 0); + } + + /// Mirror across the X=0 plane. Applied to every position and + /// normal we emit so Unity's importer-side X flip cancels back to the + /// authored orientation. + static Vector3 MirrorX(Vector3 v) => new(-v.X, v.Y, v.Z); + + static void AddQuad(IPrimitiveBuilder prim, Vector3 a, Vector3 b, Vector3 c, Vector3 d, Vector3 normal) + { + var n = MirrorX(normal); + var va = new VertexBuilder( + new VertexPositionNormal(MirrorX(a), n), new VertexTexture1(Vector2.Zero)); + var vb = new VertexBuilder( + new VertexPositionNormal(MirrorX(b), n), new VertexTexture1(Vector2.Zero)); + var vc = new VertexBuilder( + new VertexPositionNormal(MirrorX(c), n), new VertexTexture1(Vector2.Zero)); + var vd = new VertexBuilder( + new VertexPositionNormal(MirrorX(d), n), new VertexTexture1(Vector2.Zero)); + // Mirroring across X reverses triangle orientation; swap the last two + // verts so winding (and therefore front-facing) stays correct. + prim.AddTriangle(va, vc, vb); + prim.AddTriangle(va, vd, vc); + } + + static void AddTri(IPrimitiveBuilder prim, Vector3 a, Vector3 b, Vector3 c, Vector3 normal) + { + var n = MirrorX(normal); + var va = new VertexBuilder( + new VertexPositionNormal(MirrorX(a), n), new VertexTexture1(Vector2.Zero)); + var vb = new VertexBuilder( + new VertexPositionNormal(MirrorX(b), n), new VertexTexture1(Vector2.Zero)); + var vc = new VertexBuilder( + new VertexPositionNormal(MirrorX(c), n), new VertexTexture1(Vector2.Zero)); + prim.AddTriangle(va, vc, vb); + } + + static MeshBuilder NewMesh(string name) + => new(name); + + static void AddSubmeshTriangles( + IPrimitiveBuilder prim, MeshData src, int submeshIndex) + { + var tris = src.SubmeshIndices[submeshIndex]; + for (int i = 0; i < tris.Length; i += 3) + AddTriangle(prim, src, tris[i], tris[i + 1], tris[i + 2]); + } + + static void AddSplitWallTriangles( + IPrimitiveBuilder prim, MeshData wall, int submesh, Vector3 nrm3, + bool includeBodyA, bool includeBodyB, bool includeFrame) + { + const float ALIGN = 0.9f; + var tris = wall.SubmeshIndices[submesh]; + for (int t = 0; t < tris.Length; t += 3) + { + int a = tris[t + 0]; + int b = tris[t + 1]; + int c = tris[t + 2]; + // Quads share a normal across all 4 verts, so any vertex characterizes the face. + var n = wall.Normals[a]; + float dot = Vector3.Dot(n, nrm3); + + bool keep = dot > ALIGN ? includeBodyA + : dot < -ALIGN ? includeBodyB + : includeFrame; + if (!keep) continue; + AddTriangle(prim, wall, a, b, c); + } + } + + static void AddTriangle(IPrimitiveBuilder prim, MeshData src, int ia, int ib, int ic) + { + var va = MakeVertex(src, ia); + var vb = MakeVertex(src, ib); + var vc = MakeVertex(src, ic); + // Winding reversed because MakeVertex mirrors X (see class summary). + prim.AddTriangle(va, vc, vb); + } + + static VertexBuilder MakeVertex( + MeshData src, int idx) + { + var pos = new VertexPositionNormal(MirrorX(src.Vertices[idx]), MirrorX(src.Normals[idx])); + var uv = new VertexTexture1(src.Uv0[idx]); + return new VertexBuilder(pos, uv); + } + + static void AddMeshIfNonEmpty( + SceneBuilder scene, + NodeBuilder parent, + MeshBuilder mesh, + string nodeName) + { + bool hasGeometry = false; + foreach (var prim in mesh.Primitives) + { + if (prim.Triangles.Count > 0) { hasGeometry = true; break; } + } + if (!hasGeometry) return; + + var leaf = parent.CreateNode(nodeName); + scene.AddRigidMesh(mesh, leaf); + } + + static MaterialBuilder MakeMaterial(string name, (float r, float g, float b) color) + { + return new MaterialBuilder(name) + .WithMetallicRoughnessShader() + .WithChannelParam(KnownChannel.BaseColor, + KnownProperty.RGBA, + new Vector4(color.r, color.g, color.b, 1f)) + .WithChannelParam(KnownChannel.MetallicRoughness, + KnownProperty.MetallicFactor, 0f) + .WithChannelParam(KnownChannel.MetallicRoughness, + KnownProperty.RoughnessFactor, 0.85f); + } + + static Room LowerIdOwner(Adjacency adj) + { + if (adj.IsOuter) return adj.RoomA; + return adj.RoomA.Id < adj.RoomB!.Id ? adj.RoomA : adj.RoomB; + } + + /// Per-wall color resolution order: per-wall override → room's single + /// wall color → neutral default. + static (float r, float g, float b) ResolveWallColor( + IReadOnlyDictionary<(int RoomId, int MidX, int MidZ), Vector3>? perWall, + IReadOnlyDictionary? roomSingle, + int roomId, Adjacency adj) + { + if (perWall != null && perWall.TryGetValue(WallColorKey(roomId, adj), out var v)) + return (v.X, v.Y, v.Z); + if (roomSingle != null && roomSingle.TryGetValue(roomId, out var s)) + return (s.X, s.Y, s.Z); + return WallsColor; + } + + public static (int RoomId, int MidX, int MidZ) WallColorKey(int roomId, Adjacency adj) + { + var mid = adj.SharedSegment.Midpoint; + return (roomId, + (int)System.Math.Round(mid.X * 1000), + (int)System.Math.Round(mid.Y * 1000)); + } + + static readonly (float r, float g, float b) FloorColor = (0.55f, 0.42f, 0.30f); + static readonly (float r, float g, float b) WallsColor = (0.78f, 0.78f, 0.80f); + static readonly (float r, float g, float b) CeilingColor = (0.92f, 0.92f, 0.90f); +} diff --git a/src/OpenApparatus.IO/Exporters/JsonExporter.cs b/src/OpenApparatus.IO/Exporters/JsonExporter.cs new file mode 100644 index 0000000..164ea97 --- /dev/null +++ b/src/OpenApparatus.IO/Exporters/JsonExporter.cs @@ -0,0 +1,395 @@ +using System.Collections.Generic; +using System.IO; +using System.Numerics; +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenApparatus.Topology; + +namespace OpenApparatus.IO.Exporters; + +/// +/// Writes the editor's authored state to a self-describing JSON document. Each +/// room is a top-level container that carries its own walls — each wall is +/// described from that room's perspective (start/end follow the room's interior +/// being on the +N side; is null for +/// outer walls). Internal walls therefore appear once in each adjoining room. +/// +public static class JsonExporter +{ + public const int SchemaVersion = 3; + + public static void Export(TextWriter w, EnvironmentDocument doc) + { + var options = new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + w.Write(JsonSerializer.Serialize(doc, options)); + } + + public static EnvironmentDocument BuildDocument( + int[,] roomGrid, + float tileSize, + float wallThickness, + float wallHeight, + float doorWidth, + float doorHeight, + float windowWidth, + float windowHeight, + float windowSillHeight, + MultiRoomEnvironment? environment, + int gridSubdivision = 1, + float defaultObjectY = 0f, + IReadOnlyList? objects = null, + IReadOnlyList? objectTypes = null, + PlacementConstraints? constraints = null) + { + int gridW = roomGrid.GetLength(0); + int gridL = roomGrid.GetLength(1); + var tiles = new int[gridW][]; + for (int x = 0; x < gridW; x++) + { + tiles[x] = new int[gridL]; + for (int z = 0; z < gridL; z++) + tiles[x][z] = roomGrid[x, z]; + } + + var doc = new EnvironmentDocument + { + Version = SchemaVersion, + Parameters = new ParametersSection + { + TileSize = tileSize, + WallThickness = wallThickness, + WallHeight = wallHeight, + DoorWidth = doorWidth, + DoorHeight = doorHeight, + WindowWidth = windowWidth, + WindowHeight = windowHeight, + WindowSillHeight = windowSillHeight, + GridSubdivision = gridSubdivision, + DefaultObjectY = defaultObjectY, + }, + Grid = new GridSection + { + Width = gridW, + Length = gridL, + Tiles = tiles, + }, + ObjectSlots = BuildSlotDefinitions(objectTypes), + PlacementConstraints = constraints is null ? null : new PlacementConstraintsSection + { + ObjectToObjectEnabled = constraints.ObjectToObjectEnabled, + ObjectToObjectMin = constraints.ObjectToObjectMin, + ObjectToObjectMax = constraints.ObjectToObjectMax, + ObjectToObjectAcrossConnectedRooms = constraints.ObjectToObjectAcrossConnectedRooms, + DoorToObjectEnabled = constraints.DoorToObjectEnabled, + DoorToObjectMin = constraints.DoorToObjectMin, + DoorToObjectMax = constraints.DoorToObjectMax, + DoorAppliesToEveryDoor = constraints.DoorAppliesToEveryDoor, + DoorAngleBandEnabled = constraints.DoorAngleBandEnabled, + DoorAngleMinDeg = constraints.DoorAngleMinDeg, + DoorAngleMaxDeg = constraints.DoorAngleMaxDeg, + ObjectToWallEnabled = constraints.ObjectToWallEnabled, + ObjectToWallMin = constraints.ObjectToWallMin, + PerRoomCountsEnabled = constraints.PerRoomCountsEnabled, + PerRoomCountMin = constraints.PerRoomCountMin, + PerRoomCountMax = constraints.PerRoomCountMax, + HighlightViolations = constraints.HighlightViolations, + ShowAllConstraints = constraints.ShowAllConstraints, + HighlightMode = constraints.HighlightMode.ToString(), + }, + }; + + if (environment is null) return doc; + + // Collect tiles per room from the grid (cheap second pass — keeps the + // JSON readable without forcing callers to compute it themselves). + var tilesByRoom = new Dictionary>(); + for (int x = 0; x < gridW; x++) + for (int z = 0; z < gridL; z++) + { + int id = roomGrid[x, z]; + if (id < 0) continue; + if (!tilesByRoom.TryGetValue(id, out var list)) + tilesByRoom[id] = list = new List(); + list.Add(new[] { x, z }); + } + + foreach (var room in environment.Rooms) + { + var entry = new RoomEntry + { + Id = room.Id, + Position = new[] { room.Position.X, room.Position.Y }, + Tiles = tilesByRoom.TryGetValue(room.Id, out var t) ? t : new List(), + Shape = ShapeFor(room), + }; + + int wallNumber = 1; + foreach (var adj in environment.Adjacencies) + { + if (adj.RoomA != room && adj.RoomB != room) continue; + + bool roomIsA = adj.RoomA == room; + var seg = adj.SharedSegment; + // Orient the wall segment so the room being described is on the + // +N (left of Start→End) side. RoomA is already on +N; flip for + // RoomB. + var start = roomIsA ? seg.Start : seg.End; + var end = roomIsA ? seg.End : seg.Start; + + var wall = new WallEntry + { + Number = wallNumber++, + Side = SideLabel(room, seg), + Start = new[] { start.X, start.Y }, + End = new[] { end.X, end.Y }, + NeighborRoomId = roomIsA ? adj.RoomB?.Id : adj.RoomA.Id, + Passage = PassageFor(adj.Passage), + }; + entry.Walls.Add(wall); + } + + // Per-room object instances. Rooms without objects emit an empty + // list (omitted by JsonIgnoreCondition.WhenWritingNull when the + // collection itself is null — we leave it null to keep small docs + // tidy). + if (objects != null) + { + List? objList = null; + foreach (var o in objects) + { + if (o.OwningRoomId != room.Id) continue; + objList ??= new List(); + objList.Add(new ObjectInstanceEntry + { + Slot = o.Slot, + Position = new[] { o.Position.X, o.Position.Y, o.Position.Z }, + Rotation = o.Rotation, + }); + } + entry.Objects = objList; + } + + doc.Rooms.Add(entry); + } + + // Outside section: anything with OwningRoomId == -1 (or pointing at a + // room that doesn't exist in the env) gets bucketed here so importers + // can still find every object. + if (objects != null) + { + var validRoomIds = new HashSet(); + foreach (var r in environment.Rooms) validRoomIds.Add(r.Id); + List? outsideList = null; + foreach (var o in objects) + { + if (validRoomIds.Contains(o.OwningRoomId)) continue; + outsideList ??= new List(); + outsideList.Add(new ObjectInstanceEntry + { + Slot = o.Slot, + Position = new[] { o.Position.X, o.Position.Y, o.Position.Z }, + Rotation = o.Rotation, + }); + } + if (outsideList != null) + doc.Outside = new OutsideSection { Objects = outsideList }; + } + + return doc; + } + + static List BuildSlotDefinitions(IReadOnlyList? types) + { + var list = new List(types?.Count ?? 0); + if (types is null) return list; + for (int i = 0; i < types.Count; i++) + { + var t = types[i]; + list.Add(new ObjectSlotEntry + { + Id = i + 1, + Shape = t.Shape.ToString().ToLowerInvariant(), + Color = new[] { t.Color.X, t.Color.Y, t.Color.Z }, + Size = t.Size, + DisplayName = t.Name, + }); + } + return list; + } + + static ShapeSection ShapeFor(Room room) => room.Shape switch + { + RectangleShape r => new ShapeSection { Type = "rectangle", Width = r.Width, Depth = r.Depth }, + _ => new ShapeSection { Type = room.Shape.GetType().Name }, + }; + + static PassageSection PassageFor(Passage p) + { + switch (p) + { + case Passage.Open: + return new PassageSection { Type = "open" }; + case Passage.Closed: + return new PassageSection { Type = "closed" }; + case Passage.Doorway dw: + var ops = new List(); + foreach (var op in dw.Openings) + ops.Add(new OpeningEntry + { + OffsetAlongEdge = op.OffsetAlongEdge, + Width = op.Width, + Height = op.Height, + SillHeight = op.SillHeight, + }); + return new PassageSection { Type = "doorway", Openings = ops }; + default: + return new PassageSection { Type = "closed" }; + } + } + + /// + /// Best-effort cardinal label for a wall on a rectangular room (north / south / + /// east / west). Useful as a hint when reading the JSON; not authoritative. + /// + static string? SideLabel(Room room, EdgeSegment seg) + { + if (room.Shape is not RectangleShape) return null; + var mid = seg.Midpoint; + var center = room.Position + new Vector2( + ((RectangleShape)room.Shape).Width * 0.5f, + ((RectangleShape)room.Shape).Depth * 0.5f); + float dx = mid.X - center.X; + float dz = mid.Y - center.Y; + if (System.Math.Abs(dx) > System.Math.Abs(dz)) + return dx > 0 ? "east" : "west"; + return dz > 0 ? "north" : "south"; + } + + public sealed class EnvironmentDocument + { + public int Version { get; set; } + public ParametersSection Parameters { get; set; } = new(); + public GridSection Grid { get; set; } = new(); + public List ObjectSlots { get; set; } = new(); + public List Rooms { get; set; } = new(); + /// Objects placed outside any room (OwningRoomId == -1). + /// Null when there are none, so small documents stay tidy. + public OutsideSection? Outside { get; set; } + /// Active placement constraints. Null = constraints feature + /// not used; otherwise a snapshot of every threshold + toggle. + public PlacementConstraintsSection? PlacementConstraints { get; set; } + } + + public sealed class PlacementConstraintsSection + { + public bool ObjectToObjectEnabled { get; set; } + public float ObjectToObjectMin { get; set; } + public float ObjectToObjectMax { get; set; } + public bool ObjectToObjectAcrossConnectedRooms { get; set; } + public bool DoorToObjectEnabled { get; set; } + public float DoorToObjectMin { get; set; } + public float DoorToObjectMax { get; set; } + public bool DoorAppliesToEveryDoor { get; set; } + public bool DoorAngleBandEnabled { get; set; } + public float DoorAngleMinDeg { get; set; } + public float DoorAngleMaxDeg { get; set; } + public bool ObjectToWallEnabled { get; set; } + public float ObjectToWallMin { get; set; } + public bool PerRoomCountsEnabled { get; set; } + public int PerRoomCountMin { get; set; } + public int PerRoomCountMax { get; set; } + public bool HighlightViolations { get; set; } = true; + public bool ShowAllConstraints { get; set; } = true; + /// Stringified + /// (Area / PlacementGrid). String rather than int so the file stays + /// human-readable. + public string HighlightMode { get; set; } = "Area"; + } + + public sealed class OutsideSection + { + public List Objects { get; set; } = new(); + } + + public sealed class ParametersSection + { + 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; } = 1; + public float DefaultObjectY { get; set; } + } + + public sealed class GridSection + { + public int Width { get; set; } + public int Length { get; set; } + public int[][] Tiles { get; set; } = System.Array.Empty(); + } + + public sealed class RoomEntry + { + public int Id { get; set; } + public ShapeSection Shape { get; set; } = new(); + public float[] Position { get; set; } = System.Array.Empty(); + public List Tiles { get; set; } = new(); + public List Walls { get; set; } = new(); + public List? Objects { get; set; } + } + + public sealed class ObjectSlotEntry + { + public int Id { get; set; } + public string Shape { get; set; } = ""; + public float[] Color { get; set; } = System.Array.Empty(); + public float Size { get; set; } + public string DisplayName { get; set; } = ""; + } + + public sealed class ObjectInstanceEntry + { + public int Slot { get; set; } + public float[] Position { get; set; } = System.Array.Empty(); + public float Rotation { get; set; } + } + + public sealed class ShapeSection + { + public string Type { get; set; } = ""; + public float? Width { get; set; } + public float? Depth { get; set; } + } + + public sealed class WallEntry + { + public int Number { get; set; } + public string? Side { get; set; } + public float[] Start { get; set; } = System.Array.Empty(); + public float[] End { get; set; } = System.Array.Empty(); + public int? NeighborRoomId { get; set; } + public PassageSection Passage { get; set; } = new(); + } + + public sealed class PassageSection + { + public string Type { get; set; } = "closed"; + public List? Openings { get; set; } + } + + public sealed class OpeningEntry + { + public float OffsetAlongEdge { get; set; } + public float Width { get; set; } + public float Height { get; set; } + public float SillHeight { get; set; } + } +} diff --git a/src/OpenApparatus.IO/Exporters/ObjExporter.cs b/src/OpenApparatus.IO/Exporters/ObjExporter.cs new file mode 100644 index 0000000..fd809f8 --- /dev/null +++ b/src/OpenApparatus.IO/Exporters/ObjExporter.cs @@ -0,0 +1,363 @@ +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Numerics; +using OpenApparatus.Geometry; +using OpenApparatus.Topology; + +namespace OpenApparatus.IO.Exporters; + +/// +/// Writes a generated MultiRoomEnvironment as Wavefront .obj geometry. Two +/// outputs are supported: +/// +/// • — one .obj with every room as a separate +/// group/object (`g`/`o`). Unity's built-in OBJ importer collapses the whole +/// file into a single Mesh asset (groups become sub-meshes), so this is best +/// for tools like Blender that respect `o`. +/// +/// • — one .obj per room, all sharing one .mtl. +/// This is the Unity-friendly path: each file imports as its own model +/// prefab which the user parents under a single GameObject to recover the +/// per-room hierarchy. +/// +/// In both modes each room emits one floor, one ceiling, and one wall_<i> +/// per adjacency it touches. Internal walls split on face normal — RoomA owns +/// the +N body face, RoomB owns the -N body face, frame pieces (top, bottom, +/// caps, tunnel jambs/lintels/sills/thresholds) belong to the lower-id room. +/// Each piece carries a unique material name so re-skinning one room never +/// alters the other's view of the shared wall. +/// +public static class ObjExporter +{ + public const string FloorMaterialPrefix = "OpenApparatus_Floor"; + public const string WallsMaterialPrefix = "OpenApparatus_Walls"; + public const string CeilingMaterialPrefix = "OpenApparatus_Ceiling"; + + /// + /// Writes one .obj containing every room. Returns the materials referenced + /// across all rooms so the caller can drop them into a sidecar .mtl. + /// + public static IReadOnlyList ExportCombined( + TextWriter w, + MultiRoomEnvironment plan, + float wallThickness, + float wallHeight, + string? mtlLibFileName = null) + { + var slots = new List(); + var rooms = BuildRoomGroups(plan, wallThickness, wallHeight, slots); + + var pool = new GeometryPool(); + var groups = new List(); + foreach (var room in rooms) + foreach (var g in room.Groups) + groups.Add(g.RebaseInto(pool)); + + WriteFile(w, mtlLibFileName, + header: $"{plan.Rooms.Count} rooms, {plan.Adjacencies.Count} walls, {groups.Count} groups", + pool, groups); + + return slots; + } + + /// + /// Writes one .obj per room into using + /// as the prefix (files become + /// {baseName}_room_{id}.obj). Each file is fully self-contained and + /// references the shared . + /// Returns the materials referenced across all rooms. + /// + public static IReadOnlyList ExportPerRoom( + string folder, + string baseName, + MultiRoomEnvironment plan, + float wallThickness, + float wallHeight, + string? mtlLibFileName = null) + { + var slots = new List(); + var rooms = BuildRoomGroups(plan, wallThickness, wallHeight, slots); + + Directory.CreateDirectory(folder); + foreach (var room in rooms) + { + var pool = new GeometryPool(); + var groups = new List(); + foreach (var g in room.Groups) + groups.Add(g.RebaseInto(pool)); + + string path = Path.Combine(folder, $"{baseName}_room_{room.RoomId}.obj"); + using var w = new StreamWriter(path); + WriteFile(w, mtlLibFileName, + header: $"room #{room.RoomId} — {groups.Count} groups", + pool, groups); + } + + return slots; + } + + static List BuildRoomGroups( + MultiRoomEnvironment plan, float wallThickness, float wallHeight, + List slotsOut) + { + var interiorBuilder = new RectangleInteriorBuilder(); + var wallBuilder = new BoundaryWallBuilder(); + + // Pre-build walls once so the same MeshData backs both rooms' splits. + var wallMeshes = new Dictionary(); + foreach (var adj in plan.Adjacencies) + wallMeshes[adj] = wallBuilder.Build(adj, wallThickness, wallHeight); + + var result = new List(); + foreach (var room in plan.Rooms) + { + var interior = interiorBuilder.Build(room, wallThickness, wallHeight); + var roomAdjacencies = new List(); + foreach (var adj in plan.Adjacencies) + if (adj.RoomA == room || adj.RoomB == room) + roomAdjacencies.Add(adj); + + var rg = new RoomGroups(room.Id); + + // Floor — interior + threshold-floor strips from owned walls. + var floor = new SourceTriBucket(); + floor.AddSubmesh(interior, SubmeshIndex.Floor); + foreach (var adj in roomAdjacencies) + { + if (LowerIdOwner(adj).Id != room.Id) continue; + floor.AddSubmesh(wallMeshes[adj], SubmeshIndex.Floor); + } + rg.TryAdd(floor, + $"room_{room.Id}_floor", + $"{FloorMaterialPrefix}_Room{room.Id}", + FloorColor, slotsOut); + + // Ceiling. + var ceiling = new SourceTriBucket(); + ceiling.AddSubmesh(interior, SubmeshIndex.Ceiling); + rg.TryAdd(ceiling, + $"room_{room.Id}_ceiling", + $"{CeilingMaterialPrefix}_Room{room.Id}", + CeilingColor, slotsOut); + + // Walls — one per adjacency, split by face-normal alignment. + for (int i = 0; i < roomAdjacencies.Count; i++) + { + var adj = roomAdjacencies[i]; + var wall = wallMeshes[adj]; + var seg = adj.SharedSegment; + var nrm3 = new Vector3(seg.Normal.X, 0f, seg.Normal.Y); + bool roomIsA = adj.RoomA == room; + + var wallBucket = new SourceTriBucket(); + AddSplitWallFaces( + wallBucket, wall, SubmeshIndex.Walls, nrm3, + includeBodyA: roomIsA, + includeBodyB: !roomIsA, + includeFrame: LowerIdOwner(adj).Id == room.Id); + + int wallNum = i + 1; + rg.TryAdd(wallBucket, + $"room_{room.Id}_wall_{wallNum}", + $"{WallsMaterialPrefix}{wallNum}_Room{room.Id}", + WallsColor, slotsOut); + } + + result.Add(rg); + } + return result; + } + + static void WriteFile( + TextWriter w, string? mtlLibFileName, + string header, GeometryPool pool, List groups) + { + w.WriteLine("# OpenApparatus floor-plan export"); + w.WriteLine($"# {header}"); + if (!string.IsNullOrEmpty(mtlLibFileName)) + w.WriteLine($"mtllib {mtlLibFileName}"); + w.WriteLine(); + + for (int i = 0; i < pool.Vertices.Count; i++) + { + var v = pool.Vertices[i]; + w.WriteLine(string.Format(CultureInfo.InvariantCulture, + "v {0:F6} {1:F6} {2:F6}", v.X, v.Y, v.Z)); + } + for (int i = 0; i < pool.Normals.Count; i++) + { + var n = pool.Normals[i]; + w.WriteLine(string.Format(CultureInfo.InvariantCulture, + "vn {0:F6} {1:F6} {2:F6}", n.X, n.Y, n.Z)); + } + for (int i = 0; i < pool.Uvs.Count; i++) + { + var u = pool.Uvs[i]; + w.WriteLine(string.Format(CultureInfo.InvariantCulture, + "vt {0:F6} {1:F6}", u.X, u.Y)); + } + w.WriteLine(); + + foreach (var g in groups) + { + // Both `o` and `g` for maximum importer compatibility — Unity keys + // sub-meshes off `g`; Blender / Maya prefer `o`. `s off` disables + // smoothing groups so face normals stay sharp at edges. + w.WriteLine($"o {g.Name}"); + w.WriteLine($"g {g.Name}"); + w.WriteLine($"usemtl {g.Material}"); + w.WriteLine("s off"); + for (int i = 0; i < g.Triangles.Count; i += 3) + { + int a = g.Triangles[i + 0] + 1; + int b = g.Triangles[i + 1] + 1; + int c = g.Triangles[i + 2] + 1; + w.WriteLine($"f {a}/{a}/{a} {b}/{b}/{b} {c}/{c}/{c}"); + } + w.WriteLine(); + } + } + + static void AddSplitWallFaces( + SourceTriBucket bucket, MeshData wall, int submesh, Vector3 nrm3, + bool includeBodyA, bool includeBodyB, bool includeFrame) + { + const float ALIGN = 0.9f; + var tris = wall.SubmeshIndices[submesh]; + for (int t = 0; t < tris.Length; t += 3) + { + int a = tris[t + 0]; + int b = tris[t + 1]; + int c = tris[t + 2]; + // Quads share a normal across all 4 verts, so any vertex's normal + // characterizes the face. + var n = wall.Normals[a]; + float dot = Vector3.Dot(n, nrm3); + + bool keep = dot > ALIGN ? includeBodyA + : dot < -ALIGN ? includeBodyB + : includeFrame; + if (!keep) continue; + bucket.AddTriangle(wall, a, b, c); + } + } + + static Room LowerIdOwner(Adjacency adj) + { + if (adj.IsOuter) return adj.RoomA; + return adj.RoomA.Id < adj.RoomB!.Id ? adj.RoomA : adj.RoomB; + } + + /// + /// Writes the sidecar .mtl. Pass the slot list returned by either export + /// path so every material referenced by the .obj files is defined. + /// + public static void WriteMtl(TextWriter w, IReadOnlyList slots) + { + w.WriteLine("# OpenApparatus material library"); + w.WriteLine(); + // Dedupe — combined and per-room exports build the same slot set; + // callers may pass either or merge them. + var seen = new HashSet(); + foreach (var slot in slots) + { + if (!seen.Add(slot.Name)) continue; + WriteMaterial(w, slot.Name, slot.Kd); + } + } + + static void WriteMaterial(TextWriter w, string name, (float r, float g, float b) kd) + { + w.WriteLine($"newmtl {name}"); + w.WriteLine(string.Format(CultureInfo.InvariantCulture, + "Kd {0:F4} {1:F4} {2:F4}", kd.r, kd.g, kd.b)); + w.WriteLine("Ka 0.0000 0.0000 0.0000"); + w.WriteLine("Ks 0.0000 0.0000 0.0000"); + w.WriteLine("Ns 10.0000"); + w.WriteLine("d 1.0000"); + w.WriteLine("illum 1"); + w.WriteLine(); + } + + static readonly (float r, float g, float b) FloorColor = (0.55f, 0.42f, 0.30f); + static readonly (float r, float g, float b) WallsColor = (0.78f, 0.78f, 0.80f); + static readonly (float r, float g, float b) CeilingColor = (0.92f, 0.92f, 0.90f); + + public readonly record struct MaterialSlot(string Name, (float r, float g, float b) Kd); + + // ---- internal helpers ------------------------------------------------------ + + /// Buffers triangles by source MeshData reference for later rebasing. + sealed class SourceTriBucket + { + public readonly List<(MeshData Src, int A, int B, int C)> Tris = new(); + + public void AddSubmesh(MeshData src, int submeshIndex) + { + var tris = src.SubmeshIndices[submeshIndex]; + for (int i = 0; i < tris.Length; i += 3) + Tris.Add((src, tris[i], tris[i + 1], tris[i + 2])); + } + + public void AddTriangle(MeshData src, int ia, int ib, int ic) + => Tris.Add((src, ia, ib, ic)); + } + + sealed class GeometryPool + { + public List Vertices { get; } = new(); + public List Normals { get; } = new(); + public List Uvs { get; } = new(); + readonly Dictionary<(MeshData, int), int> _index = new(); + + public int Add(MeshData src, int srcIdx) + { + var key = (src, srcIdx); + if (_index.TryGetValue(key, out var existing)) return existing; + int idx = Vertices.Count; + _index[key] = idx; + Vertices.Add(src.Vertices[srcIdx]); + Normals.Add(src.Normals[srcIdx]); + Uvs.Add(src.Uv0[srcIdx]); + return idx; + } + } + + sealed record GroupRecord(string Name, string Material, List Triangles); + + sealed class PendingGroup + { + public string Name = ""; + public string Material = ""; + public SourceTriBucket Bucket = new(); + + public GroupRecord RebaseInto(GeometryPool pool) + { + var rebased = new List(Bucket.Tris.Count * 3); + foreach (var t in Bucket.Tris) + { + rebased.Add(pool.Add(t.Src, t.A)); + rebased.Add(pool.Add(t.Src, t.B)); + rebased.Add(pool.Add(t.Src, t.C)); + } + return new GroupRecord(Name, Material, rebased); + } + } + + sealed class RoomGroups + { + public int RoomId { get; } + public List Groups { get; } = new(); + public RoomGroups(int roomId) { RoomId = roomId; } + + public void TryAdd( + SourceTriBucket bucket, string name, string material, + (float r, float g, float b) color, List slotsOut) + { + if (bucket.Tris.Count == 0) return; + slotsOut.Add(new MaterialSlot(material, color)); + Groups.Add(new PendingGroup { Name = name, Material = material, Bucket = bucket }); + } + } +} diff --git a/src/OpenApparatus.IO/OpenApparatus.IO.csproj b/src/OpenApparatus.IO/OpenApparatus.IO.csproj new file mode 100644 index 0000000..4ead8c9 --- /dev/null +++ b/src/OpenApparatus.IO/OpenApparatus.IO.csproj @@ -0,0 +1,26 @@ + + + + + net8.0 + 12 + enable + enable + OpenApparatus.IO + OpenApparatus.IO + true + $(NoWarn);CS1591 + + + + + + + + + + + diff --git a/src/OpenApparatus.IO/PlacementConstraints.cs b/src/OpenApparatus.IO/PlacementConstraints.cs new file mode 100644 index 0000000..9addf76 --- /dev/null +++ b/src/OpenApparatus.IO/PlacementConstraints.cs @@ -0,0 +1,114 @@ +namespace OpenApparatus.IO; + +/// How the placement-constraint overlay paints valid sub-cells. +/// tints only the cells whose centre satisfies every active +/// constraint — a fast approximation of the continuous valid region. +/// additionally tints partially-valid cells in +/// yellow (any corner inside the valid region but the centre out), so the +/// user can see the fuzzy boundary at the resolution of the placement grid. +public enum ConstraintHighlightMode +{ + Area = 0, + PlacementGrid = 1, +} + +/// +/// Spatial constraints applied to object placements so behavioural studies +/// aren't confounded by accidental layout effects (objects clustered near a +/// door, isolated in a corner, etc.). +/// +/// Constraints are validation only — they never block placement or +/// export. The editor draws compliance overlays (zones, exclusion discs, +/// violator rings) so the user can see immediately whether a placement +/// satisfies them. +/// +/// Bound floats use 0 to mean "no bound on this side". A min of 0 imposes no +/// lower bound (everything passes); a max of 0 imposes no upper bound. When +/// both are 0 for a constraint group the group is effectively no-op even with +/// its enabled flag set. +/// +public sealed class PlacementConstraints +{ + // ---- Object ↔ Object ---- + public bool ObjectToObjectEnabled { get; set; } + public float ObjectToObjectMin { get; set; } + public float ObjectToObjectMax { get; set; } + + /// When true, the object-to-object constraint applies to every + /// pair whose rooms are connected by a traversable passage (open or + /// doorway), not just within a single room. Closed walls are ignored. + public bool ObjectToObjectAcrossConnectedRooms { get; set; } + + // ---- Door → Object ---- + public bool DoorToObjectEnabled { get; set; } + public float DoorToObjectMin { get; set; } + public float DoorToObjectMax { get; set; } + + /// When true (default), an object must satisfy the door + /// constraint relative to every door of its room. When false, + /// satisfying any one door is enough. + public bool DoorAppliesToEveryDoor { get; set; } = true; + + public bool DoorAngleBandEnabled { get; set; } + public float DoorAngleMinDeg { get; set; } + public float DoorAngleMaxDeg { get; set; } + + // ---- Object → Wall ---- + public bool ObjectToWallEnabled { get; set; } + public float ObjectToWallMin { get; set; } + + // ---- Per-room counts ---- + public bool PerRoomCountsEnabled { get; set; } + public int PerRoomCountMin { get; set; } + public int PerRoomCountMax { get; set; } + + // ---- Visualisation ---- + public bool HighlightViolations { get; set; } = true; + + /// When true, the valid-placement overlay is drawn for every + /// room (each tinted with that room's wall colour so they read as + /// distinct). When false, the overlay is scoped to the room currently in + /// focus — the room containing the selected sub-cell or selected object — + /// and is hidden entirely otherwise. Default true so first-time users see + /// the full picture. + public bool ShowAllConstraints { get; set; } = true; + + /// How the valid-region overlay paints sub-cells. See + /// . + public ConstraintHighlightMode HighlightMode { get; set; } = ConstraintHighlightMode.Area; + + /// Field-by-field copy from another instance. Used by + /// project-file load to refresh the existing VM-owned Constraints + /// instance without breaking bindings to it. + public void CopyFrom(PlacementConstraints o) + { + ObjectToObjectEnabled = o.ObjectToObjectEnabled; + ObjectToObjectMin = o.ObjectToObjectMin; + ObjectToObjectMax = o.ObjectToObjectMax; + ObjectToObjectAcrossConnectedRooms = o.ObjectToObjectAcrossConnectedRooms; + DoorToObjectEnabled = o.DoorToObjectEnabled; + DoorToObjectMin = o.DoorToObjectMin; + DoorToObjectMax = o.DoorToObjectMax; + DoorAppliesToEveryDoor = o.DoorAppliesToEveryDoor; + DoorAngleBandEnabled = o.DoorAngleBandEnabled; + DoorAngleMinDeg = o.DoorAngleMinDeg; + DoorAngleMaxDeg = o.DoorAngleMaxDeg; + ObjectToWallEnabled = o.ObjectToWallEnabled; + ObjectToWallMin = o.ObjectToWallMin; + PerRoomCountsEnabled = o.PerRoomCountsEnabled; + PerRoomCountMin = o.PerRoomCountMin; + PerRoomCountMax = o.PerRoomCountMax; + HighlightViolations = o.HighlightViolations; + ShowAllConstraints = o.ShowAllConstraints; + HighlightMode = o.HighlightMode; + } +} + +/// One reason an object (or a room, for count constraints) violates +/// the active set of placement constraints. +public sealed class ConstraintViolation +{ + public int? ObjectIndex { get; set; } + public int? RoomId { get; set; } + public string Message { get; set; } = ""; +} diff --git a/src/OpenApparatus.IO/ProjectFile.cs b/src/OpenApparatus.IO/ProjectFile.cs new file mode 100644 index 0000000..7182d76 --- /dev/null +++ b/src/OpenApparatus.IO/ProjectFile.cs @@ -0,0 +1,112 @@ +using System.Collections.Generic; + +namespace OpenApparatus.IO; + +/// Serializable mirror of every authored field on the editor. Each +/// property is written in camelCase; null / default values are omitted on write +/// to keep saves small. +/// +/// Editors are responsible for converting their own state into a +/// before calling , and +/// applying a loaded file back to their state after calling +/// . The desktop Avalonia editor implements this +/// via MainWindowViewModel.ToProjectFile() / RestoreFromProjectFile(). +/// +public sealed class ProjectFile +{ + public string? Version { get; set; } = ProjectIO.CurrentVersion; + public string? Title { get; set; } + + // Grid + measurements + public int GridWidth { get; set; } + public int GridLength { get; set; } + 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; } + + // Defaults + public float[]? DefaultFloorColor { get; set; } + public float[]? DefaultCeilingColor { get; set; } + + // Tile → room ownership grid (flattened row-major). + public int[]? RoomGrid { get; set; } + + // Per-room palettes / state. + public Dictionary? RoomFloorColors { get; set; } + public Dictionary? RoomCeilingColors { get; set; } + public Dictionary? RoomSingleWallColors { get; set; } + public Dictionary? RoomNames { get; set; } + public List? MultiColorRoomIds { get; set; } + + // Per-wall colour overrides keyed by "{roomId}_{midXmm}_{midZmm}". + public Dictionary? WallColors { get; set; } + + // Passage overrides — adjacency identity is reconstructed from + // start/end mm-coordinates, since the in-memory Adjacency object + // doesn't survive serialization. + public List? PassageOverrides { get; set; } + + // Object types + instances. + public List? ObjectTypes { get; set; } + public List? Objects { get; set; } + + // Camera state. + public string? CameraView { get; set; } + public double ZoomFactor { get; set; } + public double PanOffsetX { get; set; } + public double PanOffsetY { get; set; } + public float IsoYaw { get; set; } + public float IsoPitch { get; set; } + public float IsoDistance { get; set; } + public float IsoPivotX { get; set; } + public float IsoPivotY { get; set; } + public float IsoPivotZ { get; set; } + + // Placement constraints — straight POCO copy. + public PlacementConstraints? Constraints { get; set; } +} + +public sealed class PassageOverrideEntry +{ + public float StartX { get; set; } + public float StartZ { get; set; } + public float EndX { get; set; } + public float EndZ { get; set; } + public string Kind { get; set; } = "Closed"; // Closed / Open / Doorway + public List? Openings { get; set; } +} + +public sealed class OpeningEntry +{ + public float Offset { get; set; } + public float Width { get; set; } + public float Height { get; set; } + public float SillHeight { get; set; } + public bool HingeAtEnd { get; set; } + public bool SwingNegative { get; set; } +} + +public sealed class ObjectTypeEntry +{ + public string Name { get; set; } = ""; + public string Shape { get; set; } = "Cube"; + public float[]? Color { get; set; } + public float Size { get; set; } = 0.3f; +} + +public sealed class ObjectInstanceEntry +{ + public int Slot { get; set; } + public int OwningRoomId { get; set; } = -1; + public float X { get; set; } + public float Y { get; set; } + public float Z { get; set; } + public float Rotation { get; set; } +} diff --git a/src/OpenApparatus.IO/ProjectIO.cs b/src/OpenApparatus.IO/ProjectIO.cs new file mode 100644 index 0000000..420ad48 --- /dev/null +++ b/src/OpenApparatus.IO/ProjectIO.cs @@ -0,0 +1,70 @@ +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenApparatus.IO; + +/// +/// Studio project persistence — round-trips the editor's authored state to a +/// single JSON file. Distinct from , which +/// produces a downstream-consumer schema; this format is editor-internal and is +/// preserved exactly across save / open so reopening reconstructs the canvas as +/// the user left it. +/// +/// Versioned so future schema changes can migrate older saves. v1 covers grid +/// dimensions, defaults, room ownership, passages, all colour palettes (per-room +/// + per-wall), object types + instances, project title, placement constraints, +/// and the camera state for both 2D and 3D views. +/// +/// This class is the UI-agnostic half of project I/O — it knows nothing about +/// any particular editor (Avalonia VM, React state, etc.). The editor is +/// responsible for converting its own state into a +/// before calling , and applying a loaded file back to its +/// state after calling . +/// +public static class ProjectIO +{ + public const string CurrentVersion = "1.0"; + public const string FileExtension = ".oapp"; + + static readonly JsonSerializerOptions s_options = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + /// Serializes to . + public static void Save(string path, ProjectFile file) + { + var json = JsonSerializer.Serialize(file, s_options); + File.WriteAllText(path, json); + } + + /// Reads and parses a project file from . + /// Throws for empty files or unsupported + /// versions. + public static ProjectFile Load(string path) + { + var json = File.ReadAllText(path); + return Parse(json); + } + + /// Parses a project file from a raw JSON string. Useful when the + /// JSON comes from somewhere other than a local file (e.g. a browser File + /// API upload). + public static ProjectFile Parse(string json) + { + var doc = JsonSerializer.Deserialize(json, s_options) + ?? throw new InvalidDataException("Project file is empty."); + if (doc.Version is null || !doc.Version.StartsWith("1.")) + throw new InvalidDataException( + $"Unsupported project version '{doc.Version}'. This studio reads v1.x."); + return doc; + } + + /// Serializes a project file to a JSON string. Mirror of + /// for in-memory roundtrips. + public static string Stringify(ProjectFile file) + => JsonSerializer.Serialize(file, s_options); +} diff --git a/src/OpenApparatus.IO/RoomObject.cs b/src/OpenApparatus.IO/RoomObject.cs new file mode 100644 index 0000000..07a3429 --- /dev/null +++ b/src/OpenApparatus.IO/RoomObject.cs @@ -0,0 +1,45 @@ +using System.Numerics; + +namespace OpenApparatus.IO; + +/// +/// One placed object in a room. Position is world XYZ in metres; Rotation is +/// radians around Y (yaw). OwningRoomId is the room the object belongs to — +/// re-evaluated on Rebuild() so an object whose room shrinks or moves is +/// reassigned to whatever room now contains its world position. +/// is a 1-based index into the VM's ObjectTypes list. +/// +public sealed class RoomObject +{ + public int OwningRoomId { get; set; } + public int Slot { get; set; } + public Vector3 Position { get; set; } + public float Rotation { get; set; } +} + +/// Shape primitive for object types. Procedural geometry is generated +/// in GltfExporter at export time; the editor view renders a 2D icon +/// that matches the same shape category. +public enum ObjectShape +{ + Cube, + Sphere, + Cylinder, + Cone, + Capsule, + Pyramid, + SquatCylinder, +} + +/// +/// One user-editable object type. The user starts with one of these in the +/// inspector and can add more via the 'Add object type' button. Hotkey +/// 1..ObjectTypes.Count places an instance of the matching type. +/// +public sealed class ObjectType +{ + public string Name { get; set; } = ""; + public ObjectShape Shape { get; set; } + public Vector3 Color { get; set; } + public float Size { get; set; } = 0.30f; +}