diff --git a/src/OpenApparatus.Studio/GlobalUsings.cs b/src/OpenApparatus.Studio/GlobalUsings.cs
new file mode 100644
index 0000000..2d9749a
--- /dev/null
+++ b/src/OpenApparatus.Studio/GlobalUsings.cs
@@ -0,0 +1,9 @@
+// Bring the OpenApparatus.IO library types into scope without touching every
+// existing .cs file's `using` block. The exporter classes and the POCO types
+// (PlacementConstraints, RoomObject, ObjectType, ProjectFile, ...) used to
+// live in OpenApparatus.Studio.Services / .ViewModels namespaces and are now
+// referenced unqualified throughout the codebase. After their move into the
+// shared OpenApparatus.IO library, these globals re-establish those names
+// without per-file edits.
+global using OpenApparatus.IO;
+global using OpenApparatus.IO.Exporters;
diff --git a/src/OpenApparatus.Studio/OpenApparatus.Studio.csproj b/src/OpenApparatus.Studio/OpenApparatus.Studio.csproj
index a020d81..ba4a44c 100644
--- a/src/OpenApparatus.Studio/OpenApparatus.Studio.csproj
+++ b/src/OpenApparatus.Studio/OpenApparatus.Studio.csproj
@@ -43,5 +43,6 @@
+
diff --git a/src/OpenApparatus.Studio/Services/GltfExporter.cs b/src/OpenApparatus.Studio/Services/GltfExporter.cs
deleted file mode 100644
index 0664155..0000000
--- a/src/OpenApparatus.Studio/Services/GltfExporter.cs
+++ /dev/null
@@ -1,542 +0,0 @@
-using System.Collections.Generic;
-using System.Linq;
-using System.Numerics;
-using OpenApparatus.Geometry;
-using OpenApparatus.Studio.ViewModels;
-using OpenApparatus.Topology;
-using SharpGLTF.Geometry;
-using SharpGLTF.Geometry.VertexTypes;
-using SharpGLTF.Materials;
-using SharpGLTF.Scenes;
-using SharpGLTF.Schema2;
-
-namespace OpenApparatus.Studio.Services;
-
-///
-/// 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.Studio/Services/JsonExporter.cs b/src/OpenApparatus.Studio/Services/JsonExporter.cs
deleted file mode 100644
index 44ac445..0000000
--- a/src/OpenApparatus.Studio/Services/JsonExporter.cs
+++ /dev/null
@@ -1,396 +0,0 @@
-using System.Collections.Generic;
-using System.IO;
-using System.Numerics;
-using System.Text.Json;
-using System.Text.Json.Serialization;
-using OpenApparatus.Studio.ViewModels;
-using OpenApparatus.Topology;
-
-namespace OpenApparatus.Studio.Services;
-
-///
-/// 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.Studio/Services/ObjExporter.cs b/src/OpenApparatus.Studio/Services/ObjExporter.cs
deleted file mode 100644
index c8bdcf8..0000000
--- a/src/OpenApparatus.Studio/Services/ObjExporter.cs
+++ /dev/null
@@ -1,363 +0,0 @@
-using System.Collections.Generic;
-using System.Globalization;
-using System.IO;
-using System.Numerics;
-using OpenApparatus.Geometry;
-using OpenApparatus.Topology;
-
-namespace OpenApparatus.Studio.Services;
-
-///
-/// 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.Studio/Services/ProjectIO.cs b/src/OpenApparatus.Studio/Services/ProjectIO.cs
deleted file mode 100644
index b28199d..0000000
--- a/src/OpenApparatus.Studio/Services/ProjectIO.cs
+++ /dev/null
@@ -1,232 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Numerics;
-using System.Text.Json;
-using System.Text.Json.Serialization;
-using OpenApparatus.Studio.ViewModels;
-
-namespace OpenApparatus.Studio.Services;
-
-///
-/// 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.
-///
-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,
- };
-
- public static void Save(string path, MainWindowViewModel vm)
- {
- var doc = ProjectFile.From(vm);
- var json = JsonSerializer.Serialize(doc, s_options);
- File.WriteAllText(path, json);
- }
-
- public static void Load(string path, MainWindowViewModel vm)
- {
- var json = File.ReadAllText(path);
- 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.");
- doc.Apply(vm);
- }
-}
-
-/// Serializable mirror of every authored field on the VM. Each
-/// property is written in camelCase; null / default values are omitted on
-/// write to keep saves small.
-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; }
-
- // ── Build from VM ──
- public static ProjectFile From(MainWindowViewModel vm)
- {
- var f = new ProjectFile
- {
- Title = vm.ProjectTitle,
- GridWidth = vm.GridWidth,
- GridLength = vm.GridLength,
- TileSize = vm.TileSize,
- WallThickness = vm.WallThickness,
- WallHeight = vm.WallHeight,
- DoorWidth = vm.DoorWidth,
- DoorHeight = vm.DoorHeight,
- WindowWidth = vm.WindowWidth,
- WindowHeight = vm.WindowHeight,
- WindowSillHeight = vm.WindowSillHeight,
- GridSubdivision = vm.GridSubdivision,
- DefaultObjectY = vm.DefaultObjectY,
- DefaultFloorColor = ColorToArr(vm.DefaultFloorColor),
- DefaultCeilingColor = ColorToArr(vm.DefaultCeilingColor),
- CameraView = vm.CameraView.ToString(),
- ZoomFactor = vm.ZoomFactor,
- PanOffsetX = vm.PanOffsetX,
- PanOffsetY = vm.PanOffsetY,
- IsoYaw = vm.IsoYaw,
- IsoPitch = vm.IsoPitch,
- IsoDistance = vm.IsoDistance,
- IsoPivotX = vm.IsoPivotX,
- IsoPivotY = vm.IsoPivotY,
- IsoPivotZ = vm.IsoPivotZ,
- Constraints = vm.Constraints,
- };
-
- // Flatten RoomGrid.
- var grid = new int[vm.GridWidth * vm.GridLength];
- for (int x = 0; x < vm.GridWidth; x++)
- for (int z = 0; z < vm.GridLength; z++)
- grid[x * vm.GridLength + z] = vm.RoomGrid[x, z];
- f.RoomGrid = grid;
-
- f.RoomFloorColors = vm.RoomFloorColors.ToDictionary(kv => kv.Key, kv => ColorToArr(kv.Value));
- f.RoomCeilingColors = vm.RoomCeilingColors.ToDictionary(kv => kv.Key, kv => ColorToArr(kv.Value));
- f.RoomSingleWallColors = vm.RoomSingleWallColors.ToDictionary(kv => kv.Key, kv => ColorToArr(kv.Value));
- f.RoomNames = vm.RoomNames.ToDictionary(kv => kv.Key, kv => kv.Value);
- f.MultiColorRoomIds = vm.MultiColorRoomIds.ToList();
-
- f.WallColors = vm.WallColors.ToDictionary(
- kv => $"{kv.Key.RoomId}_{kv.Key.MidX}_{kv.Key.MidZ}",
- kv => ColorToArr(kv.Value));
-
- f.PassageOverrides = vm.SerializePassageOverrides();
-
- f.ObjectTypes = vm.ObjectTypes
- .Select(t => new ObjectTypeEntry
- {
- Name = t.Name,
- Shape = t.Shape.ToString(),
- Color = ColorToArr(t.Color),
- Size = t.Size,
- }).ToList();
- f.Objects = vm.Objects
- .Select(o => new ObjectInstanceEntry
- {
- Slot = o.Slot,
- OwningRoomId = o.OwningRoomId,
- X = o.Position.X, Y = o.Position.Y, Z = o.Position.Z,
- Rotation = o.Rotation,
- }).ToList();
- return f;
- }
-
- public void Apply(MainWindowViewModel vm)
- => vm.RestoreFromProjectFile(this);
-
- static float[] ColorToArr(Vector3 v) => new[] { v.X, v.Y, v.Z };
-}
-
-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.Studio/ViewModels/MainWindowViewModel.cs b/src/OpenApparatus.Studio/ViewModels/MainWindowViewModel.cs
index 1f89ec3..83d393e 100644
--- a/src/OpenApparatus.Studio/ViewModels/MainWindowViewModel.cs
+++ b/src/OpenApparatus.Studio/ViewModels/MainWindowViewModel.cs
@@ -1597,9 +1597,9 @@ void Redo()
/// Snapshots passage overrides into a flat list of entries
/// keyed by world-mm Start coordinates. Used by ProjectIO.Save.
- public List SerializePassageOverrides()
+ public List SerializePassageOverrides()
{
- var list = new List();
+ var list = new List();
foreach (var kv in _passageOverrides)
{
var (passage, start) = kv.Value;
@@ -1616,7 +1616,7 @@ void Redo()
Passage.Doorway => "Doorway",
_ => "Closed",
};
- var entry = new OpenApparatus.Studio.Services.PassageOverrideEntry
+ var entry = new PassageOverrideEntry
{
StartX = start.X, StartZ = start.Y,
EndX = endX, EndZ = endZ,
@@ -1625,7 +1625,7 @@ void Redo()
if (passage is Passage.Doorway d)
{
entry.Openings = d.Openings.Select(o =>
- new OpenApparatus.Studio.Services.OpeningEntry
+ new OpeningEntry
{
Offset = o.OffsetAlongEdge,
Width = o.Width,
@@ -1640,9 +1640,85 @@ void Redo()
return list;
}
+ /// Snapshots the entire VM state into a serializable
+ /// . Hand the result to
+ /// (or ) to persist; the round-trip
+ /// reconstructs the canvas exactly via .
+ public ProjectFile ToProjectFile()
+ {
+ var f = new ProjectFile
+ {
+ Title = ProjectTitle,
+ GridWidth = GridWidth,
+ GridLength = GridLength,
+ TileSize = TileSize,
+ WallThickness = WallThickness,
+ WallHeight = WallHeight,
+ DoorWidth = DoorWidth,
+ DoorHeight = DoorHeight,
+ WindowWidth = WindowWidth,
+ WindowHeight = WindowHeight,
+ WindowSillHeight = WindowSillHeight,
+ GridSubdivision = GridSubdivision,
+ DefaultObjectY = DefaultObjectY,
+ DefaultFloorColor = ColorToArr(DefaultFloorColor),
+ DefaultCeilingColor = ColorToArr(DefaultCeilingColor),
+ CameraView = CameraView.ToString(),
+ ZoomFactor = ZoomFactor,
+ PanOffsetX = PanOffsetX,
+ PanOffsetY = PanOffsetY,
+ IsoYaw = IsoYaw,
+ IsoPitch = IsoPitch,
+ IsoDistance = IsoDistance,
+ IsoPivotX = IsoPivotX,
+ IsoPivotY = IsoPivotY,
+ IsoPivotZ = IsoPivotZ,
+ Constraints = Constraints,
+ };
+
+ // Flatten RoomGrid.
+ var grid = new int[GridWidth * GridLength];
+ for (int x = 0; x < GridWidth; x++)
+ for (int z = 0; z < GridLength; z++)
+ grid[x * GridLength + z] = RoomGrid[x, z];
+ f.RoomGrid = grid;
+
+ f.RoomFloorColors = RoomFloorColors.ToDictionary(kv => kv.Key, kv => ColorToArr(kv.Value));
+ f.RoomCeilingColors = RoomCeilingColors.ToDictionary(kv => kv.Key, kv => ColorToArr(kv.Value));
+ f.RoomSingleWallColors = RoomSingleWallColors.ToDictionary(kv => kv.Key, kv => ColorToArr(kv.Value));
+ f.RoomNames = RoomNames.ToDictionary(kv => kv.Key, kv => kv.Value);
+ f.MultiColorRoomIds = MultiColorRoomIds.ToList();
+
+ f.WallColors = WallColors.ToDictionary(
+ kv => $"{kv.Key.RoomId}_{kv.Key.MidX}_{kv.Key.MidZ}",
+ kv => ColorToArr(kv.Value));
+
+ f.PassageOverrides = SerializePassageOverrides();
+
+ f.ObjectTypes = ObjectTypes
+ .Select(t => new ObjectTypeEntry
+ {
+ Name = t.Name,
+ Shape = t.Shape.ToString(),
+ Color = ColorToArr(t.Color),
+ Size = t.Size,
+ }).ToList();
+ f.Objects = Objects
+ .Select(o => new ObjectInstanceEntry
+ {
+ Slot = o.Slot,
+ OwningRoomId = o.OwningRoomId,
+ X = o.Position.X, Y = o.Position.Y, Z = o.Position.Z,
+ Rotation = o.Rotation,
+ }).ToList();
+ return f;
+
+ static float[] ColorToArr(System.Numerics.Vector3 v) => new[] { v.X, v.Y, v.Z };
+ }
+
/// Replaces the entire VM state with the contents of a
/// project file. Used by ProjectIO.Load.
- public void RestoreFromProjectFile(OpenApparatus.Studio.Services.ProjectFile f)
+ public void RestoreFromProjectFile(ProjectFile f)
{
// Push undo so the user can step back to whatever was open.
PushUndo();
@@ -1785,7 +1861,7 @@ public void RestoreFromProjectFile(OpenApparatus.Studio.Services.ProjectFile f)
{
"Open" => Passage.Open.Instance,
"Doorway" => new Passage.Doorway(
- (po.Openings ?? new List())
+ (po.Openings ?? new List())
.Select(o => new Opening(
offsetAlongEdge: o.Offset,
width: o.Width,
@@ -2731,7 +2807,7 @@ async Task SaveProjectAsync(Window? owner)
}
try
{
- OpenApparatus.Studio.Services.ProjectIO.Save(ProjectFilePath, this);
+ ProjectIO.Save(ProjectFilePath, ToProjectFile());
HasUnsavedChanges = false;
OpenApparatus.Studio.Services.Toasts.Default.ShowSuccess(
$"Saved → {System.IO.Path.GetFileName(ProjectFilePath)}");
@@ -2750,7 +2826,7 @@ async Task SaveProjectAsAsync(Window? owner)
{
Title = "Save scene",
SuggestedFileName = string.IsNullOrEmpty(ProjectTitle) ? "scene" : ProjectTitle,
- DefaultExtension = OpenApparatus.Studio.Services.ProjectIO.FileExtension.TrimStart('.'),
+ DefaultExtension = ProjectIO.FileExtension.TrimStart('.'),
FileTypeChoices = new[]
{
new FilePickerFileType("OpenApparatus project (*.oapp)")
@@ -2762,7 +2838,7 @@ async Task SaveProjectAsAsync(Window? owner)
try
{
string path = file.Path.LocalPath;
- OpenApparatus.Studio.Services.ProjectIO.Save(path, this);
+ ProjectIO.Save(path, ToProjectFile());
ProjectFilePath = path;
ProjectTitle = System.IO.Path.GetFileNameWithoutExtension(path);
HasUnsavedChanges = false;
@@ -2799,7 +2875,7 @@ async Task OpenProjectAsync(Window? owner)
try
{
string path = file.Path.LocalPath;
- OpenApparatus.Studio.Services.ProjectIO.Load(path, this);
+ RestoreFromProjectFile(ProjectIO.Load(path));
ProjectFilePath = path;
ProjectTitle = System.IO.Path.GetFileNameWithoutExtension(path);
var settings = OpenApparatus.Studio.Services.AppSettings.LoadOrDefault();
@@ -2850,7 +2926,7 @@ public void OpenProjectFromPath(string path)
{
try
{
- OpenApparatus.Studio.Services.ProjectIO.Load(path, this);
+ RestoreFromProjectFile(ProjectIO.Load(path));
ProjectFilePath = path;
ProjectTitle = System.IO.Path.GetFileNameWithoutExtension(path);
var settings = OpenApparatus.Studio.Services.AppSettings.LoadOrDefault();
diff --git a/src/OpenApparatus.Studio/ViewModels/PlacementConstraints.cs b/src/OpenApparatus.Studio/ViewModels/PlacementConstraints.cs
deleted file mode 100644
index 67ffde4..0000000
--- a/src/OpenApparatus.Studio/ViewModels/PlacementConstraints.cs
+++ /dev/null
@@ -1,114 +0,0 @@
-namespace OpenApparatus.Studio.ViewModels;
-
-/// 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.Studio/ViewModels/RoomObject.cs b/src/OpenApparatus.Studio/ViewModels/RoomObject.cs
deleted file mode 100644
index 8c43378..0000000
--- a/src/OpenApparatus.Studio/ViewModels/RoomObject.cs
+++ /dev/null
@@ -1,45 +0,0 @@
-using System.Numerics;
-
-namespace OpenApparatus.Studio.ViewModels;
-
-///
-/// 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;
-}
diff --git a/src/OpenApparatus.Studio/Views/GridEditorView.cs b/src/OpenApparatus.Studio/Views/GridEditorView.cs
index 2982a17..3ffc4d7 100644
--- a/src/OpenApparatus.Studio/Views/GridEditorView.cs
+++ b/src/OpenApparatus.Studio/Views/GridEditorView.cs
@@ -1752,7 +1752,7 @@ Point ToScreen(System.Numerics.Vector2 worldXz)
}
// Objects belonging to this room.
- var objs = new List();
+ var objs = new List();
foreach (var o in vm.Objects)
if (o.OwningRoomId == room.Id) objs.Add(o);
diff --git a/src/OpenApparatus.Studio/Views/RoomEditorPanel.axaml.cs b/src/OpenApparatus.Studio/Views/RoomEditorPanel.axaml.cs
index 6dff720..6963a01 100644
--- a/src/OpenApparatus.Studio/Views/RoomEditorPanel.axaml.cs
+++ b/src/OpenApparatus.Studio/Views/RoomEditorPanel.axaml.cs
@@ -241,7 +241,7 @@ void BuildSingleObjectEditor(int idx)
/// Same chrome as AddInspectorHeader, but with a 26-px swatch
/// preview on the left showing the object type's colour + a glyph
/// for its shape. Only used for object selections.
- void AddInspectorHeaderWithObjectPreview(string title, string? subtitle, OpenApparatus.Studio.ViewModels.ObjectType? type)
+ void AddInspectorHeaderWithObjectPreview(string title, string? subtitle, ObjectType? type)
{
var headerStack = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 10, Margin = new Thickness(0, 0, 0, subtitle is null ? 8 : 2) };
if (type is not null)
@@ -417,15 +417,15 @@ static string WallCompassDirection(OpenApparatus.Topology.Adjacency adj)
return n.Y > 0 ? "south" : "north";
}
- static string ShapeGlyph(OpenApparatus.Studio.ViewModels.ObjectShape shape) => shape switch
+ static string ShapeGlyph(ObjectShape shape) => shape switch
{
- OpenApparatus.Studio.ViewModels.ObjectShape.Cube => "■",
- OpenApparatus.Studio.ViewModels.ObjectShape.Sphere => "●",
- OpenApparatus.Studio.ViewModels.ObjectShape.Cylinder => "▮",
- OpenApparatus.Studio.ViewModels.ObjectShape.SquatCylinder => "▬",
- OpenApparatus.Studio.ViewModels.ObjectShape.Cone => "▲",
- OpenApparatus.Studio.ViewModels.ObjectShape.Capsule => "⬭",
- OpenApparatus.Studio.ViewModels.ObjectShape.Pyramid => "◆",
+ ObjectShape.Cube => "■",
+ ObjectShape.Sphere => "●",
+ ObjectShape.Cylinder => "▮",
+ ObjectShape.SquatCylinder => "▬",
+ ObjectShape.Cone => "▲",
+ ObjectShape.Capsule => "⬭",
+ ObjectShape.Pyramid => "◆",
_ => "?",
};
@@ -560,7 +560,7 @@ void AddObjectEditorControls(StackPanel host, int idx)
/// Apply a side-effecting change to the selected object's mutable
/// fields, then bump EditVersion so the editor view repaints.
- void MutateSelectedObject(System.Action change)
+ void MutateSelectedObject(System.Action change)
{
var sel = _vm?.SelectedObject;
if (sel is null) return;
@@ -568,7 +568,7 @@ void MutateSelectedObject(System.Action