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