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