How do we test if a modular building interior is enclosed.

Assuming we have a modular building system that is not voxel based, but object based, allowing stairs next to walls, doors, etc., something like Rust or Conan Exiles. Surely there are many different ways to store that data, and that is certainly a possible answer to the question, but let’s say we’re just using boxcasts to find when objects can “snap” onto one another.

I thinking:
Assuming each “area” is square can we iterate around from a starting point cast rays in all six directions looking for walls? Say we have our buildings all on a Building layer and so our rays are using a Building layermask and limited to 50 meters. If we can see a wall 10 meters west, we know we need to test Up, North, Down, and South for each “area” that the ray passed through. We keep adding blocks to test to our List, checking them against our already tested List, until we get a Null raycast(not enclosed), or we run out of blocks to test (enclosed)

Though I worry about the resource usage of this approach… I suppose it could just be a rule that a structure with more than say 30m gap anywhere will not be considered “enclosed.” Easy if 2D, but having to iterate all around in 3D… is there a better method?

Any ideas?

I threw this together in a text editor, probably some missing semicolons, will see if I can get some results… I made another one that limits the expansion from the start as well

using UnityEngine;
using System.Collections;
using System.Collections.Generic;

public static class InteriorCheck {
   
    Vector3 startingPoint;
    Dictionary<int, Dictionary<int, Dictionary<int, Area>>> areas;
    List<Int3> areasToCheck;
   
    public static bool CheckInterior (Vector3 _startingPoint) {
        startingPoint = _startingPoint + new Vector3(0, 1.5f, 0); //the center of an area
        areas = new Dictionary<int, Dictionary<int, Dictionary<int, Area>>>();
        areasToCheck = new List<Int3>();
       
        //add and check initial area
        AddArea(0, 0, 0);
        if (!CheckArea(0, 0, 0)) {
            Debug.Log("Not sealed");
            return false;
        }
       
        //iterate through areas to check
        while (areasToCheck.Count > 0) {
            if (!CheckArea(areasToCheck[0])) {
                Debug.Log("Not sealed");
                return false;
            }
            areasToCheck.RemoveAt(0);
        }
       
        return true;
    }

    static bool AddArea (int x, int y, int z) {
        if (!areas.ContainsKey(x)) {
            areas.Add(x, new Dictionary<int, Dictionary<int, Area>>());
        }
        if (!areas[x].ContainsKey(y)) {
            areas[x].Add(y, new Dictionary<int, Area>());
        }
        if (!areas[x][y].ContainsKey(z)) {
            areas[x, y].Add(z, new Area());
            return true;
        }
        return false;
    }
   
    static bool CheckArea (int x, int y, int z) {
        Vector3 start = startingPoint + new Vector3(x * 3f, y * 3f, z * 3f);
        Ray nRay = new Ray(start, Vector3.forward);
        Ray eRay = new Ray(start, Vector3.right);
        Ray sRay = new Ray(start, Vector3.back);
        Ray wRay = new Ray(start, Vector3.left);
        Ray uRay = new Ray(start, Vector3.up);
        Ray dRay = new Ray(start, Vector3.down);
       
        RaycastHit hit;
        // north
        if (!areas[x][y][z].n) {
            if (Physics.Raycast(nRay, out hit, 30f, GlobalMasks.buildingMask)) { //max ten units
                areas[x, y, z].n = true;
                for (int i = 1; i * 3 < hit.distance; i++) {
                    //if the area is newly added to the dictionary, add it to the ToCheck list
                    if (AddArea(x + i, y, z)) { areasToCheck.Add(new Int3(x + i, y, z)); }
                    areas[x + i][y][z].n = true;
                    areas[x + i][y][z].s = true;
                }
            }
            else { return false; }
        }
        // east
        if (!areas[x][y][z].e) {
            if (Physics.Raycast(eRay, out hit, 30f, GlobalMasks.buildingMask)) { //max ten units
                areas[x, y, z].e = true;
                for (int i = 1; i * 3 < hit.distance; i++) {
                    //if the area is newly added to the dictionary, add it to the ToCheck list
                    if (AddArea(x, y, z - i)) { areasToCheck.Add(new Int3(x, y, z - i)); }
                    areas[x][y][z - i].e = true;
                    areas[x][y][z - i].w = true;
                }
            }
            else { return false; }
        }
        // south
        if (!areas[x][y][z].s) {
            if (Physics.Raycast(sRay, out hit, 30f, GlobalMasks.buildingMask)) { //max ten units
                areas[x, y, z].s = true;
                for (int i = 1; i * 3 < hit.distance; i++) {
                    //if the area is newly added to the dictionary, add it to the ToCheck list
                    if (AddArea(x - i, y, z)) { areasToCheck.Add(new Int3(x - i, y, z)); }
                    areas[x - i][y][z].n = true;
                    areas[x - i][y][z].s = true;
                }
            }
            else { return false; }
        }
        // west
        if (!areas[x][y][z].w) {
            if (Physics.Raycast(wRay, out hit, 30f, GlobalMasks.buildingMask)) { //max ten units
                areas[x, y, z].w = true;
                for (int i = 1; i * 3 < hit.distance; i++) {
                    //if the area is newly added to the dictionary, add it to the ToCheck list
                    if (AddArea(x, y, z + i)) { areasToCheck.Add(new Int3(x, y, z + i)); }
                    areas[x][y][z + i].e = true;
                    areas[x][y][z + i].w = true;
                }
            }
            else { return false; }
        }
        // up
        if (!areas[x][y][z].u) {
            if (Physics.Raycast(uRay, out hit, 30f, GlobalMasks.buildingMask)) { //max ten units
                areas[x, y, z].u = true;
                for (int i = 1; i * 3 < hit.distance; i++) {
                    //if the area is newly added to the dictionary, add it to the ToCheck list
                    if (AddArea(x, y + i, z)) { areasToCheck.Add(new Int3(x, y + i, z)); }
                    areas[x][y + i][z].u = true;
                    areas[x][y + i][z].d = true;
                }
            }
            else { return false; }
        }
        // down
        if (!areas[x][y][z].d) {
            if (Physics.Raycast(dRay, out hit, 30f, GlobalMasks.buildingMask)) { //max ten units
                areas[x, y, z].d = true;
                for (int i = 1; i * 3 < hit.distance; i++) {
                    //if the area is newly added to the dictionary, add it to the ToCheck list
                    if (AddArea(x, y - i, z)) { areasToCheck.Add(new Int3(x, y - i, z)); }
                    areas[x][y - i][z].u = true;
                    areas[x][y - i][z].d = true;
                }
            }
            else { return false; }
        }
    }
   
}

public class Area {
    public bool n = false;
    public bool e = false;
    public bool s = false;
    public bool w = false;
    public bool u = false;
    public bool d = false;
}

public class Int3 {
    public int x = 0;
    public int y = 0;
    public int z = 0;
   
    public Int3 (int _x, int _y, int _z) {
        x = _x;
        y = _y;
        z = _z;
    }
}

It seems that you are at least close to the solution but I can’t wrap my head around your code now.
What do you achieve with “or (int i = 1; i * 3 < hit.distance; i++)”?

I would do it with a floodfill / dijkstra’s algorithm and a 3D Grid, your solution is somewhat similar. But I wonder if it works for all possibilities, Floodfill certainly will.

If you are not familiar with those algorithms then you should google them. What they do is to start at one point and from that go to all neighbors, but only to those which are not blocked. If no block is left to go to then this area will recieve an id.

Your way would be that you start at one block, make your raycasts and if valid you go over to its neighbors. You can use raycast e.g. like Vector3.forward+Block size and check if you hit a wall. If yes then this block is sealed and you skip it.
The iteration ends when no passable neighbor is left then the area is enclosed.

As I am in love with grids I would probably do it that way. I would setup a 3D grid (areas[x, y, z]) where each node is a block and each has up to 6 neighbors. Then when I place walls I would
A) cut the connection between two nodes (wall is on the edge)
B) mark this area as occupied (wall is a node itself)
Then you can simply run a basic flood fill algorithm on that resulting grid.

In both ways you have to limit your area where you perform the action. This can become more complicated depending on your scene. But once implemented the system is capable of local changes. For instance if you place a wall at an empty place (all neighbors are empty) you know that there is no possibility that it can create an encapsulated area.

Let me know if I shall explain my method in more detail or if what you did already works for you. I just think that the grid example can be greatly optimized. If you like I can explain that in more detail, I wanted to write that up anyway at some time.

If you go for the grid approach you can simply choose a wall to start from then follow its neighbours, if you get back to the beginning its an enclosed loop.

Yes, but it would not account for room in room situations and you have to deal with crossings of multiple rooms. I would use this algorithm to form the limit area and then perform a floodfill within that.

the *3 is just because my building areas are 3meters in size, but I was using a 1unit method of calculating.

The thing is… I never built a static grid into my game on purpose, I like the freedom of being able to choose position and rotation freely, but it brings in a lot of problems with it too. Currently I’m just doing raycast checks to look for hits against either buildings or terrain when you first “select” an area. If you select a building it attaches to that grid and lets you move around on it selecting walls/floors etc:

in other words I’m not storing references to the buildings anymore… If I had a reference grid then yes I see going through a floodfill… Maybe I should make a “structure” per building grid and store references like that rather than trying to store each terrains entire building data in a giant table.

With the “structure” approach I could easily consider block outside the structure, basically anything not stored on the structure grid as empty and potentially reduce calculations… I guess I’m having a hard time wrapping my head around floodfill in 3d. If it’s an infinite space I want to make sure that it doesn’t just flood out into the open, but then I’m forced to place hard limitation on the distance the checks can travel, so players would have to know, a structure with any area larger than 20 units will never register as enclosed, or whatever… if it was limited to not crossing into “empty” that wouldn’t be a problem though…

Also the wikipedia on floodfill mentions stack limitations which is why I didn’t go that route with my scripted musing. Basically any cell you cross for the first time that has not been tested, test it, but that’s up to 6 cells to begin with and 5 each there after, etc. I was worried that would overflow and tossed them into a list for looping over.

With the raycasting method of course I can’t thread that, and is runs into a lot of side problems like non-airtight “buildings” like stairs, ramps, etc…

I’m thinking I’ll have to look into putting things back on a referenceable structure grid.

I need to just run some real world tests… I’m imagining a 3d floodfill test could get out of hand fast, but it would probably not be that bad on a grid with a max 30-40 unit size. That’d be 90-120meters in my game and more than acceptable to be understood as “not allowed” for a properly enclosed structure.

I’d like to imagine people trying to build giant castles and what not, but that doesn’t mean they have industrial central heating in a fantasy game ;p

Yea it would be bonkers for the whole map, you could use a local grid. You could also use the raycast method, instead of usign the grid you do a raycast in each direction of the length of a node. If you hit a wall you skip, if you don’t then this is passable. No need to store anything but sounds more expensive to calculate.

It is very fast if you can limit operations. But if you go 40 stages into the sky then it could get slow quickly.

Also, will you have to define a room as a 2D surface or as a volume? That can make things different as well.

I used to store each 1024m terrains buildings each in a double jagged dictionary =]
Now they’re just GameObjects with no sense of one another until I boxcheck/raycast the area.

Well for realism I was thinking of a 3d approach. You know you could have a 5x5 floor and a 3x3 2nd floor and the walls would be “enclosed” but the gaps in between would be a huge leak.

For a raycast approach I wonder if I should yield a fixedupdate every 20 steps and just let it go until it finds a hole or completes an enclosure… per floor in 2D, ZX, then check for vertical gaps… vertical checks should be XY and ZY bands… I was thinking of trying to make vertical checks per floor… a slice approach makes more sense to me now though… hmm… 3D just makes my head hurt thinking about lumps and dips in the walls XD

Instead of raycasts why not use overlap spheres: Unity - Scripting API: Physics.OverlapSphere

Cuts down on the directional guess work, just do one at each connection point.

1 Like

I put this together today and will try it out tomorrow (Saturday here)

using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using System.Threading;

public class InteriorCheck : MonoBehaviour {
 
    int maxDistanceToCheck = 5; //will turn up after testing time spent and threading
 
    int startingX;
    int startingY;
    int startingZ;
 
    Structure structure;
    Dictionary<int, Dictionary<int, HashSet>> checkedAreas;
 
    bool airtight;
    bool threadIsActive = false;
    float startTime;
 
    public void CheckInterior (Structure _structure, int x, int y, int z) {
        startTime = Time.timeSinceLevelLoad;
     
        startingX = x;
        startingY = y;
        startingZ = z;
     
        structure = _structure;
        checkedAreas = new Dictionary<int, Dictionary<int, HashSet>>();
     
        airtight = true;
     
        bool useThreading = false;
     
        if (useThreading) {
            Debug.Log("using threading");
            StartCoroutine(CheckInteriorThreaded());
        }
        else {
            Debug.Log("not using threading");
            //start from the beginning
            CheckArea(x, y, z)
         
            //assign the result
            structure.airtight = airtight;
         
            Debug.Log("Time spent checking interior: " + (Time.timeSinceLevelLoad - startTime).ToString());
        }
    }
 
    IEnumerator CheckInteriorThreaded () {
            while (threadIsActive) {
                yield return new WaitForSeconds(1f);
                Debug.Log("Waiting for thread");
            }
            threadIsActive = true;
            Thread thread = new Thread(CheckInteriorViaThread);
            thread.Start();
    }
 
    void CheckInteriorViaThread () {
        //start from the beginning
        CheckArea(x, y, z)
     
        //assign the result
        structure.airtight = airtight;
     
        Debug.Log("Time spent checking interior: " + (Time.timeSinceLevelLoad - startTime).ToString());
    }

    bool CheckArea (int x, int y, int z) {
        //don't go more than maxDistanceToCheck from the start. This is the only way airtight gets set to false
        if (Mathf.Abs(startingX - x) > maxDistanceToCheck) { airtight = false; }
        if (Mathf.Abs(startingY - y) > maxDistanceToCheck) { airtight = false; }
        if (Mathf.Abs(startingZ - z) > maxDistanceToCheck) { airtight = false; }
     
        //cancel all further operations if a leak has been found
        if (!airtight) { return; }
     
        //don't double check
        if (HasAreaBeenChecked(x, y, z)) { return; }
     
        //Add to checked list first to prevent looping back onto this spot
        AddCheckedArea(x, y, z);
     
        //if the area doesn't exist, just keep moving
        if (!structure.areas.ContainsKey(x) || !structure.areas[x].ContainsKey(y) || !structure.areas[x][y].Contains(z)) {
            if (!HasAreaBeenChecked(x + 1, y, z)) { CheckArea(x + 1, y, z); } //N
            if (!HasAreaBeenChecked(x, y, z - 1)) { CheckArea(x, y, z - 1); } //E
            if (!HasAreaBeenChecked(x - 1, y, z)) { CheckArea(x - 1, y, z); } //S
            if (!HasAreaBeenChecked(x, y, z + 1)) { CheckArea(x, y, z + 1); } //W
            if (!HasAreaBeenChecked(x, y + 1, z)) { CheckArea(x, y + 1, z); } //U
            if (!HasAreaBeenChecked(x, y - 1, z)) { CheckArea(x, y - 1, z); } //D
        }
        //if the area contains a foundation it is airtight
        else if (structure.areas[x][y][z].floor.buildingRecipe == BuildingRecipes.WoodFoundation ||
            structure.areas[x][y][z].floor.buildingRecipe == BuildingRecipes.StoneFoundation) {
            return;
        }
        //if the area does exist and is not a foundation test it
        else {
            //if an airtight building exists don't move in that direction, can still flood from other directions
            if (!IsEdgeAirtight(x + 1, y, z, BuildingSlots.SouthEdge)) { CheckArea(x + 1, y, z); } //N
            if (!IsEdgeAirtight(x, y, z, BuildingSlots.EastEdge)) { CheckArea(x, y, z); } //E
            if (!IsEdgeAirtight(x, y, z, BuildingSlots.SouthEdge)) { CheckArea(x, y, z); } //S
            if (!IsEdgeAirtight(x, y, z + 1, BuildingSlots.EastEdge)) { CheckArea(x, y, z + 1); } //W
            if (!IsEdgeAirtight(x, y + 1, z, BuildingSlots.Floor)) { CheckArea(x, y + 1, z); } //U
            if (!IsEdgeAirtight(x, y, z, BuildingSlots.Floor)) { CheckArea(x, y, z); } //D
        }
    }
 
    bool HasAreaBeenChecked (int x, int y, int z) {
        if (checkedAreas.ContainsKey(x) && checkedAreas[x].ContainsKey(y) && checkedAreas[x][y].Contains(z)) {
            return true;
        }
        return false;
    }
 
    void AddCheckedArea (int x, int z) {
        if (!checkedAreas.ContainsKey(x)) {
            checkedAreas.Add(x, new Dictionary<int, HashSet<int>>());
            checkedAreas[x].Add(y, new HashSet<int>());
            checkedAreas[x][y].Add(z);
        }
        else if (!checkedAreas[x].ContainsKey(y)) {
            checkedAreas[x].Add(y, new HashSet<int>());
            checkedAreas[x][y].Add(z);
        }
        else if (!checkedAreas[x][y].Contains(z)) {
            checkedAreas[x][y].Add(z);
        }
        else { Debug.LogError("already checked area being added"); }
    }
 
    bool AreaExists (int x, int y, int z) {
        if (!structure.areas.ContainsKey(x) || !structure.areas[x].ContainsKey(y) || !structure.areas[x][y].Contains(z)) {
            return false;
        }
        return true;
    }
 
    bool IsEdgeAirtight (int x, int y, int z, BuildingSlots slot) {
        //if the area does not exist the edge cannot be airtight
        if (!AreaExists(x, y, z)) {
            return false;
        }
        switch (slot) {
        case BuildingSlots.SouthEdge:
            if (IsRecipeAirTight(structure.area[x][y][z].southEdge)) { return true; }
            else { return false; }
        case BuildingSlots.EastEdge:
            if (IsRecipeAirTight(structure.area[x][y][z].eastEdge)) { return true; }
            else { return false; }
        case BuildingSlots.Floor:
            if (IsRecipeAirTight(structure.area[x][y][z].floor)) { return true; }
            else { return false; }
        }
    }
 
    bool IsRecipeAirtight (Building building) {
        //if it's a wall
        if (building.buildingRecipe == BuildingRecipes.WoodWall ||
            building.buildingRecipe == BuildingRecipes.StoneWall) {
            return true;
        }
        //if it's a floor
        else if (building.buildingRecipe == BuildingRecipes.WoodFloor ||
            building.buildingRecipe == BuildingRecipes.StoneFloor) {
            return true;
        }
        //if it's a doorway with a door
        else if (building.buildingRecipe == BuildingRecipes.WoodDoorway && building.upgrade == BuildingUpgrades.WoodDoor) {
            return true;
        }
        //wood window
        //stone door
        //stone window
        return false;
    }
 
}

public class Structure {
 
    public Vector3 localPosition; //local start position on the terrain
    public float rotation;
 
    public Dictionary<int, Dictionary<int, Dictionary<int, BuildingArea>>> areas; //x,y,z
 
    public bool airtight = false;
 
}

public class BuildingArea {
 
    public Building eastEdge = null;
    public Building southEdge = null; //north, west, and ceiling are south, east, and floor on neighbors
    public Building center = null;
    public Building floor = null;
 
}

public class Building {
 
    public BuildingRecipes buildingRecipe = BuildingRecipes.Null; //enum
    public BuildingSlot slot = BuildingSlot.Null; //enum
    public BuildingUpgrades upgrade = BuildingUpgrades.Null; //enum

}

I’m imagining a standalone structure grid per building started, sort of like Empyrion (awesome game if you haven’t played it) or at least that what it feels like in Empyrion… That way I can run it off thread using only data and have it checking to make sure walls and things are not stairs/things that should not be considered “airtight”.

The problem with this in-game would be limiting the size of a particular building. Off thread though, I might be able to check 50x50x50 or more per frame on most machines, and that would be a lot more than necessary. Ultimately the outer border is the only way to limit the math from going off into space, and the only way that a “leak” can be detected. You should never be able to reach that border, and if you don’t you’re airtight, otherwise in a 3D scenario there is no other way to tell. Consider a spiral, you could run into all four corners over and over, but ultimately the space is not closed.

I think making it clear that each building is separate, with perhaps a name and information panel, and maybe a toggle to show how far the building can be built before it hits it’s max size would be player friendly.

The only problem I still foresee is interaction with the ground, and buildings with multiple rooms. If someone tries to lay foundations across a 100x100 area and then build on top of that, the buildings will basically all become big chunks of rooms attached to one another as far as the Structure system is concerned. Might have to educate players on starting new buildings per building. This could also lead to saving buildings for trading/quick building/copying, again like Empyrion. If the performance is good enough I will remove the size limitation and use the 3d min/maxs of the Structure grid.

Rooms
Still need to come up with something for rooms though… Currently I’m developing this because I want to allow players to summon NPCs like Terraria and Starbound do. When a summoning item is placed the interior check engages. So it doesn’t have to happen on everything, but for warmth/wind tests it would also be useful. When the checks run from a summoning item it can be per room and work fine. If all structures run this check every time they are changed it might be… overboard.