feat(core): cavern graph v1, one continuous floor-capped tunnel into the landmark chamber (L46); R13

The flagged SW demo's ramp (57 carves + 107 support pillars, visibly ribbed) becomes one cubic-Bezier
sweep: closest point by 17-point seed plus 5 Newton steps, a cubic floor profile, a rounded 2.6 m bore,
and a hard floor cap applied after unions and relief that only adds support where the field is air.
Optional resolved cavern-graph records (one room, one edge) enter canonical identity only when present;
their bounds extend World::isTileShapeFree. Reachability passes (L43 control fails), headroom 2.8 m,
floor ripple 1.8 mm (L45 ramp control fails at 17.9 mm), shape limits met; demo off: owner hashes
identical to L45. fast/medium/full PASS. Research R13 (read-only): continuous SDF sweeps along curves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016dhxM43dnWvUu2B5Ph1iG1
This commit is contained in:
Fr0zkaandClaude Opus 5.5 committed 2026-10-24 09:53:06 +02:00
1 parent f13b145f23
commit e016f6a20f
14 files changed
+1753 -48

No files matched your search

+400 -2
View File
@@ -169,6 +169,8 @@ public:
featureIndices_=shape.BuildPassageFeatureTileList(
minimumSubvoxels_,maximumSubvoxels_);
stampIndices_=shape.BuildStampTileList(minimumSubvoxels_,maximumSubvoxels_);
cavernFeatureIndices_=shape.BuildCavernFeatureTileList(
minimumSubvoxels_,maximumSubvoxels_);
passageRoutes_.assign(shape.ResolvedLayout?shape.ResolvedLayout->passages.size():0,0);
passageColumns_=passageRoutes_;
passageMouthSupports_=passageRoutes_;
@@ -219,12 +221,16 @@ public:
std::size_t featureCount() const{return featureIndices_.size();}
bool hasStampCandidates() const{return !stampIndices_.empty();}
const std::vector<std::uint32_t>& stampCandidates() const{return stampIndices_;}
bool hasCavernCandidates() const{return !cavernFeatureIndices_.empty();}
const std::vector<std::uint32_t>& cavernFeatureCandidates() const
{return cavernFeatureIndices_;}
private:
const FStandaloneShapeWorld* shape_;
std::array<std::int64_t,3> minimumSubvoxels_,maximumSubvoxels_;
std::vector<std::uint32_t> featureIndices_;
std::vector<std::uint32_t> stampIndices_;
std::vector<std::uint32_t> cavernFeatureIndices_;
std::vector<std::uint8_t> passageRoutes_,passageColumns_,passageMouthSupports_;
bool routeFeatureOverlap_=false,columnZOverlap_=false;
bool mayContainResolvedBoundaryBand_=true;
@@ -1780,9 +1786,13 @@ public:
float ApplyResolvedStrateComposition(float density,float X,float Y,float Z) const
{
const float graphDensity=ApplyResolvedCavernGraph(density,X,Y,Z);
const float composed=ApplyResolvedStratePassageCarves(
ApplyResolvedStrateBoundaryBand(density,X,Y,Z),X,Y,Z);
return StampRuntime.empty()?composed:ApplyResolvedSdfStamps(composed,X,Y,Z);
ApplyResolvedStrateBoundaryBand(graphDensity,X,Y,Z),X,Y,Z);
const float stamped=StampRuntime.empty()?composed:ApplyResolvedSdfStamps(composed,X,Y,Z);
// Keep the explicit graph floor cap after smooth unions, relief, passages,
// and decorative stamps so nothing can reopen the tunnel floor below profile.
return ApplyResolvedCavernFloorCaps(stamped,X,Y,Z);
}
bool ResolvedPassageIntersectsBox(const int32 minimum[3],const int32 maximum[3]) const
@@ -3198,6 +3208,21 @@ private:
std::uint64_t irregularitySeed=0;
std::uint8_t quarterTurns=0;
};
struct FCavernRuntimeFeature
{
const VoxelForgeCore::ResolvedCavernRoomV1* room=nullptr;
const VoxelForgeCore::ResolvedCavernEdgeV1* edge=nullptr;
std::array<std::int64_t,3> boundsMinimumMicrometres{},boundsMaximumMicrometres{};
std::array<float,3> centerVoxels{},halfExtentsVoxels{};
std::array<std::array<float,3>,4> controlPointsVoxels{};
float floorVoxels=0.0f;
float boreRadiusVoxels=0.0f,boreHeightVoxels=0.0f;
float floorSupportDepthVoxels=0.0f;
float smoothWidthVoxels=0.0f;
float irregularityAmplitudeVoxels=0.0f;
float irregularityWavelengthVoxels=0.0f;
std::uint64_t irregularitySeed=0;
};
int BuildStampSpatialNode(std::size_t first,std::size_t end)
{
@@ -3346,6 +3371,104 @@ private:
return result;
}
void BuildCavernSpatialIndex()
{
CavernRuntime.clear();
if(!ResolvedLayout||!Owner||Owner->CoreVoxelSizeMicrons==0
||ResolvedLayout->cavernGraphVocabularyVersion
!=VoxelForgeCore::ResolvedLayoutCavernGraphVocabularyVersionV1)return;
const float micronsPerVoxel=static_cast<float>(Owner->CoreVoxelSizeMicrons);
for(const auto& room:ResolvedLayout->cavernRooms)
{
FCavernRuntimeFeature runtime;runtime.room=&room;
runtime.boundsMinimumMicrometres=room.boundsMinimumMicrometres;
runtime.boundsMaximumMicrometres=room.boundsMaximumMicrometres;
for(std::size_t axis=0;axis<3;++axis)
{
runtime.centerVoxels[axis]=static_cast<float>(
static_cast<double>(room.centerMicrometres[axis])/micronsPerVoxel);
runtime.halfExtentsVoxels[axis]=static_cast<float>(
static_cast<double>(room.halfExtentsMicrometres[axis])/micronsPerVoxel);
}
runtime.floorVoxels=static_cast<float>(
static_cast<double>(room.floorMicrometres)/micronsPerVoxel);
runtime.smoothWidthVoxels=static_cast<float>(
static_cast<double>(room.smoothWidthMicrometres)/micronsPerVoxel);
runtime.irregularityAmplitudeVoxels=static_cast<float>(
static_cast<double>(room.irregularityAmplitudeMicrometres)/micronsPerVoxel);
runtime.irregularityWavelengthVoxels=static_cast<float>(
static_cast<double>(room.irregularityWavelengthMicrometres)/micronsPerVoxel);
runtime.irregularitySeed=room.irregularitySeed;
CavernRuntime.push_back(runtime);
}
for(const auto& edge:ResolvedLayout->cavernEdges)
{
FCavernRuntimeFeature runtime;runtime.edge=&edge;
runtime.boundsMinimumMicrometres=edge.boundsMinimumMicrometres;
runtime.boundsMaximumMicrometres=edge.boundsMaximumMicrometres;
for(std::size_t point=0;point<4;++point)
for(std::size_t axis=0;axis<3;++axis)
runtime.controlPointsVoxels[point][axis]=static_cast<float>(
static_cast<double>(edge.controlPointsMicrometres[point][axis])
/micronsPerVoxel);
runtime.boreRadiusVoxels=static_cast<float>(
static_cast<double>(edge.boreRadiusMicrometres)/micronsPerVoxel);
runtime.boreHeightVoxels=static_cast<float>(
static_cast<double>(edge.boreHeightMicrometres)/micronsPerVoxel);
runtime.floorSupportDepthVoxels=static_cast<float>(
static_cast<double>(edge.floorSupportDepthMicrometres)/micronsPerVoxel);
runtime.smoothWidthVoxels=static_cast<float>(
static_cast<double>(edge.smoothWidthMicrometres)/micronsPerVoxel);
runtime.irregularityAmplitudeVoxels=static_cast<float>(
static_cast<double>(edge.irregularityAmplitudeMicrometres)/micronsPerVoxel);
runtime.irregularityWavelengthVoxels=static_cast<float>(
static_cast<double>(edge.irregularityWavelengthMicrometres)/micronsPerVoxel);
runtime.irregularitySeed=edge.irregularitySeed;
CavernRuntime.push_back(runtime);
}
}
std::vector<std::uint32_t> BuildCavernFeatureTileList(
const std::array<std::int64_t,3>& minimumSubvoxels,
const std::array<std::int64_t,3>& maximumSubvoxels) const
{
std::vector<std::uint32_t> result;
if(CavernRuntime.empty())return result;
if(!Owner||Owner->CoreVoxelSizeMicrons==0)
{
result.resize(CavernRuntime.size());
for(std::size_t i=0;i<result.size();++i)result[i]=static_cast<std::uint32_t>(i);
return result;
}
constexpr long double Q=VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel;
std::array<long double,3> minimumMicrometres{},maximumMicrometres{};
for(std::size_t axis=0;axis<3;++axis)
{
if(minimumSubvoxels[axis]>maximumSubvoxels[axis])
{
result.resize(CavernRuntime.size());
for(std::size_t i=0;i<result.size();++i)
result[i]=static_cast<std::uint32_t>(i);
return result;
}
minimumMicrometres[axis]=static_cast<long double>(minimumSubvoxels[axis])
*Owner->CoreVoxelSizeMicrons/Q;
maximumMicrometres[axis]=static_cast<long double>(maximumSubvoxels[axis])
*Owner->CoreVoxelSizeMicrons/Q;
}
for(std::size_t index=0;index<CavernRuntime.size();++index)
{
const auto& feature=CavernRuntime[index];bool overlap=true;
for(std::size_t axis=0;axis<3;++axis)
overlap=overlap&&feature.boundsMaximumMicrometres[axis]
>=minimumMicrometres[axis]
&&feature.boundsMinimumMicrometres[axis]
<=maximumMicrometres[axis];
if(overlap)result.push_back(static_cast<std::uint32_t>(index));
}
return result;
}
int BuildPassageNearestNode(std::size_t passageIndex,std::vector<std::size_t> segments)
{
const auto& points=ResolvedLayout->passages[passageIndex].controlPoints;
@@ -3519,6 +3642,7 @@ private:
void BuildPassageSpatialIndices()
{
BuildStampSpatialIndex();
BuildCavernSpatialIndex();
PassageTileFeatures.clear();PassageFeatureBounds.clear();
PassageFeatureOrder.clear();PassageFeatureNodes.clear();
PassageFeatureBoundsFailOpen=false;
@@ -4459,6 +4583,19 @@ public:
static_cast<std::int64_t>(BMax)*Q};
if(!BuildStampTileList(minimum,maximum).empty())return false;
}
if(!CavernRuntime.empty())
{
constexpr std::int64_t Q=VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel;
const std::array<std::int64_t,3> minimum={
static_cast<std::int64_t>(Origin.X-Pad)*Q,
static_cast<std::int64_t>(Origin.Y-Pad)*Q,
static_cast<std::int64_t>(BMin)*Q};
const std::array<std::int64_t,3> maximum={
static_cast<std::int64_t>(Origin.X+(Cells+1)*Step)*Q,
static_cast<std::int64_t>(Origin.Y+(Cells+1)*Step)*Q,
static_cast<std::int64_t>(BMax)*Q};
if(!BuildCavernFeatureTileList(minimum,maximum).empty())return false;
}
const float LayoutWidth=static_cast<float>(GetResolvedLayoutChunkWidthVoxels());
const int32 FirstChunk=FMath::FloorToInt(static_cast<float>(BMin)/LayoutWidth);
const int32 LastChunk=FMath::FloorToInt(static_cast<float>(BMax)/LayoutWidth);
@@ -4563,6 +4700,20 @@ public:
if(!BuildStampTileList(minimum,maximum).empty())
return Finish(EVoxelTileClass::Mixed,true);
}
if(!CavernRuntime.empty())
{
constexpr std::int64_t Q=VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel;
const std::array<std::int64_t,3> minimum={
static_cast<std::int64_t>(BMin[0])*Q,
static_cast<std::int64_t>(BMin[1])*Q,
static_cast<std::int64_t>(BMin[2])*Q};
const std::array<std::int64_t,3> maximum={
static_cast<std::int64_t>(BMax[0])*Q,
static_cast<std::int64_t>(BMax[1])*Q,
static_cast<std::int64_t>(BMax[2])*Q};
if(!BuildCavernFeatureTileList(minimum,maximum).empty())
return Finish(EVoxelTileClass::Mixed,true);
}
if(ResolvedPassageIntersectsBox(BMin,BMax))
return Finish(EVoxelTileClass::Mixed,true);
if(!EditBrushes.empty()&&EditBoundsMin.X<=BMax[0]&&EditBoundsMax.X>=BMin[0]
@@ -5727,6 +5878,252 @@ private:
apply(static_cast<std::uint32_t>(index));
return composed;
}
float ApplyResolvedCavernGraph(float density,float X,float Y,float Z) const
{
if(CavernRuntime.empty()||!Owner||Owner->CoreVoxelSizeMicrons==0)return density;
const auto* tileQueries=ActivePassageTileQueries;
const bool scoped=tileQueries&&tileQueries->matches(*this);
if(scoped&&!tileQueries->hasCavernCandidates())return density;
const auto* bound=FindBoundStratumForManagerZ(Z);
if(!bound)return density;
const double micronsPerVoxel=Owner->CoreVoxelSizeMicrons;
const std::array<double,3> worldMicrometres={X*micronsPerVoxel,
Y*micronsPerVoxel,Z*micronsPerVoxel};
float composed=density;
const auto apply=[&](std::uint32_t index)
{
if(index>=CavernRuntime.size())return;
const auto& feature=CavernRuntime[index];
const std::int32_t strateIndex=feature.room?feature.room->strateIndex
:feature.edge?feature.edge->strateIndex:-1;
if(strateIndex!=bound->StrateIndex)return;
for(std::size_t axis=0;axis<3;++axis)
if(worldMicrometres[axis]<feature.boundsMinimumMicrometres[axis]
||worldMicrometres[axis]>feature.boundsMaximumMicrometres[axis])return;
if(feature.room)
{
const float localX=X-feature.centerVoxels[0];
const float localY=Y-feature.centerVoxels[1];
const float localZ=Z-feature.centerVoxels[2];
if(Z<feature.floorVoxels)return;
float distance=StampEllipsoidSdf(localX,localY,localZ,
feature.halfExtentsVoxels);
if(feature.irregularityAmplitudeVoxels>0.0f)
{
const float fade=SmoothUnit((Z-feature.floorVoxels)/0.8f);
distance+=StampValueNoise(localX,localY,localZ,
feature.irregularityWavelengthVoxels,feature.irregularitySeed)
*feature.irregularityAmplitudeVoxels*fade;
}
composed=SmoothMaxDistance(composed,-distance,feature.smoothWidthVoxels);
const float horizontal=std::sqrt(
(localX*localX)/(feature.halfExtentsVoxels[0]
*feature.halfExtentsVoxels[0])
+(localY*localY)/(feature.halfExtentsVoxels[1]
*feature.halfExtentsVoxels[1]));
if(horizontal<=1.0f)
composed=std::min(composed,Z-feature.floorVoxels);
return;
}
if(!feature.edge)return;
const auto bezier=[&](float t,std::size_t axis)
{
const float one=1.0f-t;
return one*one*one*feature.controlPointsVoxels[0][axis]
+3.0f*one*one*t*feature.controlPointsVoxels[1][axis]
+3.0f*one*t*t*feature.controlPointsVoxels[2][axis]
+t*t*t*feature.controlPointsVoxels[3][axis];
};
const auto derivative=[&](float t,std::size_t axis)
{
const float one=1.0f-t;
return 3.0f*one*one*(feature.controlPointsVoxels[1][axis]
-feature.controlPointsVoxels[0][axis])
+6.0f*one*t*(feature.controlPointsVoxels[2][axis]
-feature.controlPointsVoxels[1][axis])
+3.0f*t*t*(feature.controlPointsVoxels[3][axis]
-feature.controlPointsVoxels[2][axis]);
};
const auto secondDerivative=[&](float t,std::size_t axis)
{
return 6.0f*(1.0f-t)*(feature.controlPointsVoxels[2][axis]
-2.0f*feature.controlPointsVoxels[1][axis]
+feature.controlPointsVoxels[0][axis])
+6.0f*t*(feature.controlPointsVoxels[3][axis]
-2.0f*feature.controlPointsVoxels[2][axis]
+feature.controlPointsVoxels[1][axis]);
};
float t=0.0f,bestDistanceSquared=std::numeric_limits<float>::infinity();
for(int sample=0;sample<=16;++sample)
{
const float candidate=static_cast<float>(sample)/16.0f;
const float dx=X-bezier(candidate,0),dy=Y-bezier(candidate,1);
const float distanceSquared=dx*dx+dy*dy;
if(distanceSquared<bestDistanceSquared)
{bestDistanceSquared=distanceSquared;t=candidate;}
}
for(int iteration=0;iteration<5;++iteration)
{
const float dx=bezier(t,0)-X,dy=bezier(t,1)-Y;
const float vx=derivative(t,0),vy=derivative(t,1);
const float ax=secondDerivative(t,0),ay=secondDerivative(t,1);
const float denominator=vx*vx+vy*vy+dx*ax+dy*ay;
if(std::abs(denominator)<1.0e-5f)break;
t=std::clamp(t-(dx*vx+dy*vy)/denominator,0.0f,1.0f);
}
const float centerX=bezier(t,0),centerY=bezier(t,1);
const float floorZ=bezier(t,2);
const float radial=std::hypot(X-centerX,Y-centerY);
const float radius=feature.boreRadiusVoxels;
const float vertical=(Z-floorZ)/feature.boreHeightVoxels;
const float normalized=std::sqrt((radial/radius)*(radial/radius)
+vertical*vertical);
float distance=(normalized-1.0f)*std::min(radius,feature.boreHeightVoxels);
if(feature.irregularityAmplitudeVoxels>0.0f)
{
const float fade=SmoothUnit((Z-floorZ)/0.8f);
distance+=StampValueNoise(X-feature.controlPointsVoxels[0][0],
Y-feature.controlPointsVoxels[0][1],Z-floorZ,
feature.irregularityWavelengthVoxels,feature.irregularitySeed)
*feature.irregularityAmplitudeVoxels*fade;
}
if(Z<floorZ)
{
if(composed>0.0f&&feature.floorSupportDepthVoxels>0.0f
&&Z>=floorZ-feature.floorSupportDepthVoxels
&&radial<radius-0.12f)
{
const float supportDistance=std::max(radial-(radius-0.12f),
std::max(floorZ-feature.floorSupportDepthVoxels-Z,Z-floorZ));
composed=std::min(composed,supportDistance);
}
return;
}
composed=SmoothMaxDistance(composed,-distance,feature.smoothWidthVoxels);
// The hard profile cap is deliberately applied after the smooth union and
// relief. Below it, native rock remains untouched; the narrow support above
// fills only baseline air.
if(radial<=radius)
composed=std::min(composed,Z-floorZ);
};
if(scoped)
for(const auto index:tileQueries->cavernFeatureCandidates())apply(index);
else
for(std::size_t index=0;index<CavernRuntime.size();++index)
apply(static_cast<std::uint32_t>(index));
return composed;
}
float ApplyResolvedCavernFloorCaps(float density,float X,float Y,float Z) const
{
if(CavernRuntime.empty()||!Owner||Owner->CoreVoxelSizeMicrons==0)return density;
const auto* tileQueries=ActivePassageTileQueries;
const bool scoped=tileQueries&&tileQueries->matches(*this);
if(scoped&&!tileQueries->hasCavernCandidates())return density;
const auto* bound=FindBoundStratumForManagerZ(Z);
if(!bound)return density;
const double micronsPerVoxel=Owner->CoreVoxelSizeMicrons;
const std::array<double,3> worldMicrometres={X*micronsPerVoxel,
Y*micronsPerVoxel,Z*micronsPerVoxel};
float capped=density;
const auto apply=[&](std::uint32_t index)
{
if(index>=CavernRuntime.size())return;
const auto& feature=CavernRuntime[index];
const std::int32_t strateIndex=feature.room?feature.room->strateIndex
:feature.edge?feature.edge->strateIndex:-1;
if(strateIndex!=bound->StrateIndex)return;
for(std::size_t axis=0;axis<3;++axis)
if(worldMicrometres[axis]<feature.boundsMinimumMicrometres[axis]
||worldMicrometres[axis]>feature.boundsMaximumMicrometres[axis])return;
if(feature.room)
{
if(Z<feature.floorVoxels)return;
const float localX=X-feature.centerVoxels[0];
const float localY=Y-feature.centerVoxels[1];
const float horizontal=std::sqrt(
(localX*localX)/(feature.halfExtentsVoxels[0]
*feature.halfExtentsVoxels[0])
+(localY*localY)/(feature.halfExtentsVoxels[1]
*feature.halfExtentsVoxels[1]));
if(horizontal<=1.0f)
capped=std::min(capped,Z-feature.floorVoxels);
return;
}
if(!feature.edge)return;
const auto bezier=[&](float t,std::size_t axis)
{
const float one=1.0f-t;
return one*one*one*feature.controlPointsVoxels[0][axis]
+3.0f*one*one*t*feature.controlPointsVoxels[1][axis]
+3.0f*one*t*t*feature.controlPointsVoxels[2][axis]
+t*t*t*feature.controlPointsVoxels[3][axis];
};
const auto derivative=[&](float t,std::size_t axis)
{
const float one=1.0f-t;
return 3.0f*one*one*(feature.controlPointsVoxels[1][axis]
-feature.controlPointsVoxels[0][axis])
+6.0f*one*t*(feature.controlPointsVoxels[2][axis]
-feature.controlPointsVoxels[1][axis])
+3.0f*t*t*(feature.controlPointsVoxels[3][axis]
-feature.controlPointsVoxels[2][axis]);
};
const auto secondDerivative=[&](float t,std::size_t axis)
{
return 6.0f*(1.0f-t)*(feature.controlPointsVoxels[2][axis]
-2.0f*feature.controlPointsVoxels[1][axis]
+feature.controlPointsVoxels[0][axis])
+6.0f*t*(feature.controlPointsVoxels[3][axis]
-2.0f*feature.controlPointsVoxels[2][axis]
+feature.controlPointsVoxels[1][axis]);
};
float t=0.0f,bestDistanceSquared=std::numeric_limits<float>::infinity();
for(int sample=0;sample<=16;++sample)
{
const float candidate=static_cast<float>(sample)/16.0f;
const float dx=X-bezier(candidate,0),dy=Y-bezier(candidate,1);
const float distanceSquared=dx*dx+dy*dy;
if(distanceSquared<bestDistanceSquared)
{bestDistanceSquared=distanceSquared;t=candidate;}
}
for(int iteration=0;iteration<5;++iteration)
{
const float dx=bezier(t,0)-X,dy=bezier(t,1)-Y;
const float vx=derivative(t,0),vy=derivative(t,1);
const float ax=secondDerivative(t,0),ay=secondDerivative(t,1);
const float denominator=vx*vx+vy*vy+dx*ax+dy*ay;
if(std::abs(denominator)<1.0e-5f)break;
t=std::clamp(t-(dx*vx+dy*vy)/denominator,0.0f,1.0f);
}
const float floorZ=bezier(t,2);
const float radial=std::hypot(X-bezier(t,0),Y-bezier(t,1));
if(Z<floorZ)
{
// Re-evaluate the narrow fallback after passages and stamps, since
// those later operations may open air beneath an otherwise native
// floor. Negative native density is preserved by the min operation.
if(capped>0.0f&&feature.floorSupportDepthVoxels>0.0f
&&Z>=floorZ-feature.floorSupportDepthVoxels
&&radial<feature.boreRadiusVoxels-0.12f)
{
const float supportDistance=std::max(radial-
(feature.boreRadiusVoxels-0.12f),
std::max(floorZ-feature.floorSupportDepthVoxels-Z,Z-floorZ));
capped=std::min(capped,supportDistance);
}
return;
}
if(radial<=feature.boreRadiusVoxels)
capped=std::min(capped,Z-floorZ);
};
if(scoped)
for(const auto index:tileQueries->cavernFeatureCandidates())apply(index);
else
for(std::size_t index=0;index<CavernRuntime.size();++index)
apply(static_cast<std::uint32_t>(index));
return capped;
}
static float SmoothUnit(float V)
{
V=std::clamp(V,0.0f,1.0f);
@@ -8977,6 +9374,7 @@ private:
std::vector<FStampSpatialNode> StampSpatialNodes;
std::vector<std::uint32_t> StampSpatialOrder;
std::vector<FStampRuntime> StampRuntime;
std::vector<FCavernRuntimeFeature> CavernRuntime;
std::vector<VoxelForgeCore::WorldAssemblyFeatureBoundsV1> PassageFeatureBounds;
std::vector<FPassageTileFeature> PassageTileFeatures;
std::vector<std::uint32_t> PassageFeatureOrder;