wip(assembler): round L32 -- smooth Gradient interface at full amplitude (no rings, not flat)
Band faces now use signed distance to the displaced solid intervals, continuous across bands, so MC interpolates the interface instead of terracing at density levels. The Gradient amplitude is restored to width/8 (about 4 m peak-to-peak at 16 m). Ring/terrace audit at LOD0-8 on the FI floor and SW ceiling passes; negative controls: L30 (rings) and L31 (flat) both fail. Hole/interface audits, 16 passage rows and 24/24 determinism pass. Evaluator 20. Cost vs L31 SW +1.9%, FI +1.5%. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CSFYgsdW8ejZSyiMf4qRSX
This commit is contained in:
1 parent
28069f0d98
commit
d0158c6019
31 files changed
+2207
-111
No files matched your search
@@ -250,9 +250,8 @@ namespace VoxelForgeCore
|
||||
inline constexpr std::int64_t WorldAssemblySubvoxelsPerVoxel = 256;
|
||||
inline constexpr std::uint32_t WorldAssemblySchemaVersion = 2;
|
||||
// Version 14 adds shared passage columns; version 16 smooths owner Gradient faces;
|
||||
// version 18 limits FloatingIslands Gradient face displacement to one voxel;
|
||||
// version 19 reduces it to one quarter voxel to remove the residual floor contour.
|
||||
inline constexpr std::uint32_t WorldAssemblyEvaluatorVersion = 19;
|
||||
// version 20 restores full Gradient amplitude and uses a continuous signed-distance band union.
|
||||
inline constexpr std::uint32_t WorldAssemblyEvaluatorVersion = 20;
|
||||
|
||||
enum class FieldChannel : std::uint32_t
|
||||
{
|
||||
|
||||
@@ -1217,6 +1217,82 @@ inline void AppendFarStratumShells(const FStandaloneOwnerWorld& World,const FSta
|
||||
Mesh.NumSurfaceVertices=OldSurfaceVertices+AddedVertices;
|
||||
}
|
||||
|
||||
// Fill only underside cells that marching cubes missed. Active transitions use the
|
||||
// same displaced face and passage-mouth fade as the signed-distance field.
|
||||
struct FTransitionFaceReplacement
|
||||
{
|
||||
double BaseZVoxels=0.0;
|
||||
double MaximumDisplacementVoxels=0.0;
|
||||
std::vector<uint8> Cells;
|
||||
};
|
||||
|
||||
inline int32 RemoveCoarseTransitionFaceTriangles(FVoxelMeshData& Mesh,
|
||||
const FIntVector& Origin,int32 Step,int32 GridSide,double VoxelSizeCm,
|
||||
const std::vector<FTransitionFaceReplacement>& Replacements,bool bUpward)
|
||||
{
|
||||
if(Step<=0||GridSide<2||VoxelSizeCm<=0.0||Replacements.empty())return 0;
|
||||
const int32 TriangleCount=Mesh.Triangles.Num()/3;
|
||||
const int32 OldCeilingStart=std::max(0,TriangleCount-Mesh.NumCeilingTriangles);
|
||||
TArray<uint8> OldMasks=Mesh.TriangleSkirtFaceMasks;
|
||||
OldMasks.SetNum(TriangleCount);
|
||||
TArray<int32> KeptTriangles;
|
||||
TArray<uint8> KeptMasks;
|
||||
KeptTriangles.Reserve(Mesh.Triangles.Num());
|
||||
KeptMasks.Reserve(TriangleCount);
|
||||
int32 Removed=0,RemovedCeiling=0;
|
||||
const int32 CellSide=GridSide-1;
|
||||
for(int32 Triangle=0;Triangle<TriangleCount;++Triangle)
|
||||
{
|
||||
const int32 IA=Mesh.Triangles[Triangle*3];
|
||||
const int32 IB=Mesh.Triangles[Triangle*3+1];
|
||||
const int32 IC=Mesh.Triangles[Triangle*3+2];
|
||||
bool bRemove=false;
|
||||
if(OldMasks[Triangle]==0&&IA>=0&&IB>=0&&IC>=0
|
||||
&&IA<Mesh.Vertices.Num()&&IB<Mesh.Vertices.Num()&&IC<Mesh.Vertices.Num())
|
||||
{
|
||||
const FVector& A=Mesh.Vertices[IA];
|
||||
const FVector& B=Mesh.Vertices[IB];
|
||||
const FVector& C=Mesh.Vertices[IC];
|
||||
const FVector GeometricNormal=FVector::CrossProduct(B-A,C-A);
|
||||
const double NormalLength=GeometricNormal.Size();
|
||||
const double Facing=NormalLength>1.0e-12?GeometricNormal.Z/NormalLength:0.0;
|
||||
if((bUpward&&Facing>0.35)||(!bUpward&&Facing< -0.35))
|
||||
{
|
||||
const double X=(A.X+B.X+C.X)/(3.0*VoxelSizeCm);
|
||||
const double Y=(A.Y+B.Y+C.Y)/(3.0*VoxelSizeCm);
|
||||
const double Z=(A.Z+B.Z+C.Z)/(3.0*VoxelSizeCm);
|
||||
const int32 CellX=static_cast<int32>(std::floor((X-Origin.X)/Step));
|
||||
const int32 CellY=static_cast<int32>(std::floor((Y-Origin.Y)/Step));
|
||||
if(CellX>=0&&CellX<CellSide&&CellY>=0&&CellY<CellSide)
|
||||
{
|
||||
const std::size_t Cell=static_cast<std::size_t>(CellY)*CellSide+CellX;
|
||||
for(const auto& Replacement:Replacements)
|
||||
{
|
||||
if(Cell>=Replacement.Cells.size()||!Replacement.Cells[Cell])continue;
|
||||
const double Margin=Replacement.MaximumDisplacementVoxels+4.0;
|
||||
if(Z>=Replacement.BaseZVoxels-Margin
|
||||
&&Z<=Replacement.BaseZVoxels+Margin)
|
||||
{bRemove=true;break;}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if(bRemove)
|
||||
{
|
||||
++Removed;
|
||||
if(Triangle>=OldCeilingStart)++RemovedCeiling;
|
||||
continue;
|
||||
}
|
||||
KeptTriangles.Add(IA);KeptTriangles.Add(IB);KeptTriangles.Add(IC);
|
||||
KeptMasks.Add(OldMasks[Triangle]);
|
||||
}
|
||||
if(Removed==0)return 0;
|
||||
Mesh.Triangles=std::move(KeptTriangles);
|
||||
Mesh.TriangleSkirtFaceMasks=std::move(KeptMasks);
|
||||
Mesh.NumCeilingTriangles=std::max(0,Mesh.NumCeilingTriangles-RemovedCeiling);
|
||||
return Removed;
|
||||
}
|
||||
|
||||
inline void AppendStratumBoundaryCeilings(const FStandaloneOwnerWorld& World,
|
||||
const FStandaloneShapeWorld& Shape,const FVoxelTileKey& Key,int32 Step,FVoxelMeshData& Mesh)
|
||||
{
|
||||
@@ -1230,9 +1306,14 @@ inline void AppendStratumBoundaryCeilings(const FStandaloneOwnerWorld& World,
|
||||
{
|
||||
double FaceZ=0.0;
|
||||
double Thickness=0.0;
|
||||
double MaximumDisplacementVoxels=0.0;
|
||||
std::vector<double> FaceHeights;
|
||||
std::vector<uint8> SolidAbove;
|
||||
const VoxelForgeCore::ResolvedLayoutStratumV2* Stratum=nullptr;
|
||||
bool bTransition=false;
|
||||
};
|
||||
std::vector<FCandidate> Candidates;
|
||||
std::vector<FTransitionFaceReplacement> Replacements;
|
||||
const double CoreMicrons=std::max<std::uint32_t>(1,World.CoreVoxelSizeMicrons);
|
||||
for(const FStrateSlot& Slot:World.Manager->GetLayout())
|
||||
{
|
||||
@@ -1243,19 +1324,34 @@ inline void AppendStratumBoundaryCeilings(const FStandaloneOwnerWorld& World,
|
||||
Candidate.FaceZ=static_cast<double>(Stratum->ceiling.bottomMicrometres)/CoreMicrons;
|
||||
Candidate.Thickness=static_cast<double>(Stratum->ceiling.topMicrometres
|
||||
-Stratum->ceiling.bottomMicrometres)/CoreMicrons;
|
||||
if(Candidate.FaceZ<Origin.Z-1.0e-6||Candidate.FaceZ>Origin.Z+Extent+1.0e-6)continue;
|
||||
Candidate.Stratum=Stratum;
|
||||
const auto* transitionUpper=Shape.FindResolvedLayoutStratumByOrder(Stratum->order-1);
|
||||
const bool bTransition=transitionUpper
|
||||
&&Shape.IsResolvedLayoutTransitionActive(*transitionUpper);
|
||||
const double displacement=bTransition?static_cast<double>(
|
||||
Shape.ResolvedTransitionMaximumFaceDisplacementMicrometres(*transitionUpper,*Stratum))
|
||||
/CoreMicrons:0.0;
|
||||
if(Candidate.FaceZ<Origin.Z-displacement-1.0e-6
|
||||
||Candidate.FaceZ>Origin.Z+Extent+displacement+1.0e-6)continue;
|
||||
Candidate.bTransition=bTransition;
|
||||
Candidate.MaximumDisplacementVoxels=displacement;
|
||||
const double Epsilon=std::min(0.5,Candidate.Thickness*0.25);
|
||||
if(Epsilon<=0.0)continue;
|
||||
Candidate.SolidAbove.resize(static_cast<std::size_t>(GridSide)*GridSide,0);
|
||||
const std::size_t GridCount=static_cast<std::size_t>(GridSide)*GridSide;
|
||||
Candidate.FaceHeights.resize(GridCount,Candidate.FaceZ);
|
||||
Candidate.SolidAbove.resize(GridCount,0);
|
||||
bool AnySolidAbove=false;
|
||||
for(int32 Y=0;Y<GridSide;++Y)for(int32 X=0;X<GridSide;++X)
|
||||
{
|
||||
const double WorldX=Origin.X+X*GridStep;
|
||||
const double WorldY=Origin.Y+Y*GridStep;
|
||||
const std::size_t GridIndex=static_cast<std::size_t>(Y*GridSide+X);
|
||||
Candidate.bTransition=Shape.ResolvedCeilingBottomAt(*Candidate.Stratum,
|
||||
WorldX,WorldY,Candidate.FaceHeights[GridIndex])||Candidate.bTransition;
|
||||
const float Above=Shape.DensityAt(static_cast<float>(WorldX),static_cast<float>(WorldY),
|
||||
static_cast<float>(Candidate.FaceZ+Epsilon));
|
||||
static_cast<float>(Candidate.FaceHeights[GridIndex]+Epsilon));
|
||||
const bool bSolidAbove=std::isfinite(Above)&&Above<0.0f;
|
||||
Candidate.SolidAbove[static_cast<std::size_t>(Y*GridSide+X)]=bSolidAbove?1:0;
|
||||
Candidate.SolidAbove[GridIndex]=bSolidAbove?1:0;
|
||||
AnySolidAbove=AnySolidAbove||bSolidAbove;
|
||||
}
|
||||
if(AnySolidAbove)Candidates.push_back(std::move(Candidate));
|
||||
@@ -1277,8 +1373,19 @@ inline void AppendStratumBoundaryCeilings(const FStandaloneOwnerWorld& World,
|
||||
std::vector<int32> NewTriangles;
|
||||
for(const FCandidate& Candidate:Candidates)
|
||||
{
|
||||
std::vector<int32> VertexMap(static_cast<std::size_t>(GridSide)*GridSide,INDEX_NONE);
|
||||
const double CeilingZ=Candidate.FaceZ;
|
||||
const std::size_t GridCount=static_cast<std::size_t>(GridSide)*GridSide;
|
||||
std::vector<int32> VertexMap(GridCount,INDEX_NONE);
|
||||
FTransitionFaceReplacement Replacement;
|
||||
Replacement.BaseZVoxels=Candidate.FaceZ;
|
||||
Replacement.MaximumDisplacementVoxels=Candidate.MaximumDisplacementVoxels;
|
||||
Replacement.Cells.resize(static_cast<std::size_t>(GridSide-1)*(GridSide-1),0);
|
||||
const auto heightAt=[&](double X,double Y)
|
||||
{
|
||||
if(!Candidate.bTransition)return Candidate.FaceZ;
|
||||
double height=Candidate.FaceZ;
|
||||
Shape.ResolvedCeilingBottomAt(*Candidate.Stratum,X,Y,height);
|
||||
return height;
|
||||
};
|
||||
const auto AddVertex=[&](int32 X,int32 Y)
|
||||
{
|
||||
const std::size_t GridIndex=static_cast<std::size_t>(Y*GridSide+X);
|
||||
@@ -1286,8 +1393,20 @@ inline void AppendStratumBoundaryCeilings(const FStandaloneOwnerWorld& World,
|
||||
if(Mapped!=INDEX_NONE)return Mapped;
|
||||
Mapped=static_cast<int32>(NewVertices.size());
|
||||
const double WorldX=Origin.X+X*GridStep,WorldY=Origin.Y+Y*GridStep;
|
||||
NewVertices.emplace_back(WorldX*VoxelSizeCm,WorldY*VoxelSizeCm,CeilingZ*VoxelSizeCm);
|
||||
NewNormals.emplace_back(0.0,0.0,-1.0);
|
||||
const double FaceZ=Candidate.FaceHeights[GridIndex];
|
||||
FVector Normal(0.0,0.0,-1.0);
|
||||
if(Candidate.bTransition)
|
||||
{
|
||||
const double Delta=std::max(1.0,std::min<double>(GridStep,4.0));
|
||||
const double DX=(heightAt(WorldX+Delta,WorldY)-heightAt(WorldX-Delta,WorldY))
|
||||
/(2.0*Delta);
|
||||
const double DY=(heightAt(WorldX,WorldY+Delta)-heightAt(WorldX,WorldY-Delta))
|
||||
/(2.0*Delta);
|
||||
Normal=FVector(DX,DY,-1.0);
|
||||
Normal.Normalize();
|
||||
}
|
||||
NewVertices.emplace_back(WorldX*VoxelSizeCm,WorldY*VoxelSizeCm,FaceZ*VoxelSizeCm);
|
||||
NewNormals.push_back(Normal);
|
||||
if(bHasUVs)NewUVs.emplace_back(static_cast<float>(WorldX),static_cast<float>(WorldY));
|
||||
if(bHasColors)NewColors.push_back(Color);
|
||||
return Mapped;
|
||||
@@ -1300,15 +1419,40 @@ inline void AppendStratumBoundaryCeilings(const FStandaloneOwnerWorld& World,
|
||||
||!Candidate.SolidAbove[I01]||!Candidate.SolidAbove[I11])continue;
|
||||
const double CellX0=Origin.X+X*GridStep,CellY0=Origin.Y+Y*GridStep;
|
||||
const double CellX1=CellX0+GridStep,CellY1=CellY0+GridStep;
|
||||
// Suppress a coincident plane only when it covers the full cell. A hit at just the
|
||||
// center of a curved shell triangle is not enough to leave the rest of this cap cell open.
|
||||
if(MeshIndex.HasDownwardCellCoverage(CellX0,CellY0,CellX1,CellY1,CeilingZ,0.001))continue;
|
||||
bool fullyCovered=false;
|
||||
if(!Candidate.bTransition)
|
||||
fullyCovered=MeshIndex.HasDownwardCellCoverage(
|
||||
CellX0,CellY0,CellX1,CellY1,Candidate.FaceZ,0.001);
|
||||
else
|
||||
{
|
||||
fullyCovered=true;
|
||||
constexpr double CoverageSamples[5][2]={
|
||||
{0.0,0.0},{1.0,0.0},{0.0,1.0},{1.0,1.0},{0.5,0.5}};
|
||||
for(const auto& Sample:CoverageSamples)
|
||||
{
|
||||
const double U=Sample[0],V=Sample[1];
|
||||
const double SampleX=CellX0+(CellX1-CellX0)*U;
|
||||
const double SampleY=CellY0+(CellY1-CellY0)*V;
|
||||
const double SampleZ=heightAt(SampleX,SampleY);
|
||||
if(!MeshIndex.HasDownwardHit(SampleX,SampleY,SampleZ,0.25))
|
||||
fullyCovered=false;
|
||||
}
|
||||
}
|
||||
if(fullyCovered)continue;
|
||||
if(Key.Level>=4&&Candidate.bTransition)
|
||||
Replacement.Cells[static_cast<std::size_t>(Y)*(GridSide-1)+X]=1;
|
||||
const int32 A=AddVertex(X,Y),B=AddVertex(X+1,Y);
|
||||
const int32 C=AddVertex(X+1,Y+1),D=AddVertex(X,Y+1);
|
||||
NewTriangles.insert(NewTriangles.end(),{A,C,B,A,D,C});
|
||||
}
|
||||
if(Key.Level>=4&&Candidate.bTransition
|
||||
&&std::any_of(Replacement.Cells.begin(),Replacement.Cells.end(),
|
||||
[](uint8 Value){return Value!=0;}))
|
||||
Replacements.push_back(std::move(Replacement));
|
||||
}
|
||||
if(NewTriangles.empty())return;
|
||||
const int32 removedCoarse=RemoveCoarseTransitionFaceTriangles(Mesh,Origin,GridStep,
|
||||
GridSide,VoxelSizeCm,Replacements,false);
|
||||
|
||||
const int32 AddedVertices=static_cast<int32>(NewVertices.size());
|
||||
auto InsertVertices=[&](auto& Existing,const auto& Added)
|
||||
@@ -1347,9 +1491,8 @@ inline void AppendStratumBoundaryCeilings(const FStandaloneOwnerWorld& World,
|
||||
Mesh.NumCeilingTriangles+=static_cast<int32>(NewTriangles.size()/3);
|
||||
}
|
||||
|
||||
// Add the interior-facing top of each bedrock band. The density composition already
|
||||
// makes this band solid and editable; this planar closure keeps its visible floor
|
||||
// continuous when a coarse marching-cubes step would otherwise skip a sub-voxel band.
|
||||
// Fill only top cells that marching cubes missed. Active transitions use the same
|
||||
// displaced face and passage-mouth fade as the signed-distance field.
|
||||
inline void AppendStratumBoundaryFloors(const FStandaloneOwnerWorld& World,
|
||||
const FStandaloneShapeWorld& Shape,const FVoxelTileKey& Key,int32 Step,FVoxelMeshData& Mesh)
|
||||
{
|
||||
@@ -1358,8 +1501,16 @@ inline void AppendStratumBoundaryFloors(const FStandaloneOwnerWorld& World,
|
||||
const int32 Extent=Key.ExtentVoxels();
|
||||
if(Extent<=0||Extent%Step!=0)return;
|
||||
const int32 GridSide=Extent/Step+1;
|
||||
struct FCandidate { double FloorZ=0.0; double Thickness=0.0; };
|
||||
struct FCandidate
|
||||
{
|
||||
double FloorZ=0.0;
|
||||
double Thickness=0.0;
|
||||
double MaximumDisplacementVoxels=0.0;
|
||||
const VoxelForgeCore::ResolvedLayoutStratumV2* Stratum=nullptr;
|
||||
bool bTransition=false;
|
||||
};
|
||||
std::vector<FCandidate> Candidates;
|
||||
std::vector<FTransitionFaceReplacement> Replacements;
|
||||
const double CoreMicrons=std::max<std::uint32_t>(1,World.CoreVoxelSizeMicrons);
|
||||
for(const FStrateSlot& Slot:World.Manager->GetLayout())
|
||||
{
|
||||
@@ -1367,9 +1518,17 @@ inline void AppendStratumBoundaryFloors(const FStandaloneOwnerWorld& World,
|
||||
if(!Stratum||!Stratum->bedrock.presentInCurrentField
|
||||
||Stratum->bedrock.topMicrometres<=Stratum->bedrock.bottomMicrometres)continue;
|
||||
const double FloorZ=static_cast<double>(Stratum->bedrock.topMicrometres)/CoreMicrons;
|
||||
if(FloorZ<Origin.Z-1.0e-6||FloorZ>Origin.Z+Extent+1.0e-6)continue;
|
||||
const auto* transitionLower=Shape.FindResolvedLayoutStratumByOrder(Stratum->order+1);
|
||||
const bool bTransition=transitionLower
|
||||
&&Shape.IsResolvedLayoutTransitionActive(*Stratum);
|
||||
const double displacement=bTransition
|
||||
?static_cast<double>(Shape.ResolvedTransitionMaximumFaceDisplacementMicrometres(
|
||||
*Stratum,*transitionLower))/CoreMicrons
|
||||
:0.0;
|
||||
if(FloorZ<Origin.Z-displacement-1.0e-6
|
||||
||FloorZ>Origin.Z+Extent+displacement+1.0e-6)continue;
|
||||
Candidates.push_back({FloorZ,static_cast<double>(Stratum->bedrock.topMicrometres
|
||||
-Stratum->bedrock.bottomMicrometres)/CoreMicrons});
|
||||
-Stratum->bedrock.bottomMicrometres)/CoreMicrons,displacement,Stratum,bTransition});
|
||||
}
|
||||
if(Candidates.empty())return;
|
||||
|
||||
@@ -1390,16 +1549,34 @@ inline void AppendStratumBoundaryFloors(const FStandaloneOwnerWorld& World,
|
||||
{
|
||||
const double Epsilon=std::min(0.5,Candidate.Thickness*0.25);
|
||||
if(Epsilon<=0.0)continue;
|
||||
std::vector<uint8> SolidBelow(static_cast<std::size_t>(GridSide)*GridSide,0);
|
||||
const std::size_t GridCount=static_cast<std::size_t>(GridSide)*GridSide;
|
||||
std::vector<double> faceHeights(GridCount,Candidate.FloorZ);
|
||||
std::vector<uint8> solidBelow(GridCount,0);
|
||||
FTransitionFaceReplacement Replacement;
|
||||
Replacement.BaseZVoxels=Candidate.FloorZ;
|
||||
Replacement.MaximumDisplacementVoxels=Candidate.MaximumDisplacementVoxels;
|
||||
Replacement.Cells.resize(static_cast<std::size_t>(GridSide-1)*(GridSide-1),0);
|
||||
for(int32 Y=0;Y<GridSide;++Y)for(int32 X=0;X<GridSide;++X)
|
||||
{
|
||||
const double WorldX=Origin.X+X*Step,WorldY=Origin.Y+Y*Step;
|
||||
const std::size_t GridIndex=static_cast<std::size_t>(Y*GridSide+X);
|
||||
if(Candidate.bTransition)
|
||||
Shape.ResolvedBedrockTopAt(*Candidate.Stratum,WorldX,WorldY,
|
||||
faceHeights[GridIndex]);
|
||||
const float Density=Shape.DensityAt(static_cast<float>(WorldX),static_cast<float>(WorldY),
|
||||
static_cast<float>(Candidate.FloorZ-Epsilon));
|
||||
SolidBelow[static_cast<std::size_t>(Y*GridSide+X)]=
|
||||
static_cast<float>(faceHeights[GridIndex]-Epsilon));
|
||||
solidBelow[GridIndex]=
|
||||
std::isfinite(Density)&&Density<0.0f?1:0;
|
||||
}
|
||||
std::vector<int32> VertexMap(static_cast<std::size_t>(GridSide)*GridSide,INDEX_NONE);
|
||||
const auto heightAt=[&](double X,double Y)
|
||||
{
|
||||
if(!Candidate.bTransition)return Candidate.FloorZ;
|
||||
double height=Candidate.FloorZ;
|
||||
if(Candidate.bTransition)
|
||||
Shape.ResolvedBedrockTopAt(*Candidate.Stratum,X,Y,height);
|
||||
return height;
|
||||
};
|
||||
std::vector<int32> VertexMap(GridCount,INDEX_NONE);
|
||||
const auto AddVertex=[&](int32 X,int32 Y)
|
||||
{
|
||||
const std::size_t GridIndex=static_cast<std::size_t>(Y*GridSide+X);
|
||||
@@ -1407,9 +1584,20 @@ inline void AppendStratumBoundaryFloors(const FStandaloneOwnerWorld& World,
|
||||
if(Mapped!=INDEX_NONE)return Mapped;
|
||||
Mapped=static_cast<int32>(NewVertices.size());
|
||||
const double WorldX=Origin.X+X*Step,WorldY=Origin.Y+Y*Step;
|
||||
FVector Normal(0.0,0.0,1.0);
|
||||
if(Candidate.bTransition)
|
||||
{
|
||||
const double Delta=std::max(1.0,std::min<double>(Step,4.0));
|
||||
const double DX=(heightAt(WorldX+Delta,WorldY)-heightAt(WorldX-Delta,WorldY))
|
||||
/(2.0*Delta);
|
||||
const double DY=(heightAt(WorldX,WorldY+Delta)-heightAt(WorldX,WorldY-Delta))
|
||||
/(2.0*Delta);
|
||||
Normal=FVector(-DX,-DY,1.0);
|
||||
Normal.Normalize();
|
||||
}
|
||||
NewVertices.emplace_back(WorldX*VoxelSizeCm,WorldY*VoxelSizeCm,
|
||||
Candidate.FloorZ*VoxelSizeCm);
|
||||
NewNormals.emplace_back(0.0,0.0,1.0);
|
||||
faceHeights[GridIndex]*VoxelSizeCm);
|
||||
NewNormals.push_back(Normal);
|
||||
if(bHasUVs)NewUVs.emplace_back(static_cast<float>(WorldX),static_cast<float>(WorldY));
|
||||
if(bHasColors)NewColors.push_back(Color);
|
||||
return Mapped;
|
||||
@@ -1418,16 +1606,43 @@ inline void AppendStratumBoundaryFloors(const FStandaloneOwnerWorld& World,
|
||||
{
|
||||
const std::size_t I00=static_cast<std::size_t>(Y*GridSide+X);
|
||||
const std::size_t I10=I00+1,I01=I00+GridSide,I11=I01+1;
|
||||
if(!SolidBelow[I00]||!SolidBelow[I10]||!SolidBelow[I01]||!SolidBelow[I11])continue;
|
||||
if(!solidBelow[I00]||!solidBelow[I10]||!solidBelow[I01]||!solidBelow[I11])continue;
|
||||
const double X0=Origin.X+X*Step,Y0=Origin.Y+Y*Step;
|
||||
const double X1=X0+Step,Y1=Y0+Step;
|
||||
if(MeshIndex.HasUpwardCellCoverage(X0,Y0,X1,Y1,Candidate.FloorZ,0.001))continue;
|
||||
bool fullyCovered=false;
|
||||
if(!Candidate.bTransition)
|
||||
fullyCovered=MeshIndex.HasUpwardCellCoverage(
|
||||
X0,Y0,X1,Y1,Candidate.FloorZ,0.001);
|
||||
else
|
||||
{
|
||||
fullyCovered=true;
|
||||
constexpr double CoverageSamples[5][2]={
|
||||
{0.0,0.0},{1.0,0.0},{0.0,1.0},{1.0,1.0},{0.5,0.5}};
|
||||
for(const auto& Sample:CoverageSamples)
|
||||
{
|
||||
const double U=Sample[0],V=Sample[1];
|
||||
const double SampleX=X0+(X1-X0)*U;
|
||||
const double SampleY=Y0+(Y1-Y0)*V;
|
||||
const double SampleZ=heightAt(SampleX,SampleY);
|
||||
if(!MeshIndex.HasUpwardHit(SampleX,SampleY,SampleZ,0.25))
|
||||
fullyCovered=false;
|
||||
}
|
||||
}
|
||||
if(fullyCovered)continue;
|
||||
if(Key.Level>=4&&Candidate.bTransition)
|
||||
Replacement.Cells[static_cast<std::size_t>(Y)*(GridSide-1)+X]=1;
|
||||
const int32 A=AddVertex(X,Y),B=AddVertex(X+1,Y);
|
||||
const int32 C=AddVertex(X+1,Y+1),D=AddVertex(X,Y+1);
|
||||
NewTriangles.insert(NewTriangles.end(),{A,B,C,A,C,D});
|
||||
}
|
||||
if(Key.Level>=4&&Candidate.bTransition
|
||||
&&std::any_of(Replacement.Cells.begin(),Replacement.Cells.end(),
|
||||
[](uint8 Value){return Value!=0;}))
|
||||
Replacements.push_back(std::move(Replacement));
|
||||
}
|
||||
if(NewTriangles.empty())return;
|
||||
const int32 removedCoarse=RemoveCoarseTransitionFaceTriangles(Mesh,Origin,Step,
|
||||
GridSide,VoxelSizeCm,Replacements,true);
|
||||
|
||||
const int32 AddedVertices=static_cast<int32>(NewVertices.size());
|
||||
auto InsertVertices=[&](auto& Existing,const auto& Added)
|
||||
|
||||
@@ -71,8 +71,22 @@ namespace
|
||||
const std::int64_t ix = TransitionFloorDiv(x, cell), iy = TransitionFloorDiv(y, cell);
|
||||
const std::int64_t tx = TransitionFade(((x - ix * cell) * TransitionQ16) / cell);
|
||||
const std::int64_t ty = TransitionFade(((y - iy * cell) * TransitionQ16) / cell);
|
||||
std::uint64_t prefix=14695981039346656037ull;
|
||||
TransitionHashWord(prefix,static_cast<std::uint32_t>(seed));
|
||||
TransitionHashWord(prefix,static_cast<std::uint32_t>(interfaceIndex));
|
||||
const auto at = [&](std::int64_t dx, std::int64_t dy)
|
||||
{ return TransitionNoiseValue(seed, interfaceIndex, ix + dx, iy + dy, 0, salt); };
|
||||
{
|
||||
std::uint64_t hash=prefix;
|
||||
TransitionHashWord(hash,static_cast<std::uint64_t>(ix+dx));
|
||||
TransitionHashWord(hash,static_cast<std::uint64_t>(iy+dy));
|
||||
TransitionHashWord(hash,0);
|
||||
TransitionHashWord(hash,salt);
|
||||
hash ^= hash >> 30; hash *= 0xbf58476d1ce4e5b9ull;
|
||||
hash ^= hash >> 27; hash *= 0x94d049bb133111ebull;
|
||||
hash ^= hash >> 31;
|
||||
const std::uint32_t bits=static_cast<std::uint32_t>(hash>>32);
|
||||
return static_cast<std::int64_t>(bits>>15)-TransitionQ16;
|
||||
};
|
||||
return TransitionLerp(TransitionLerp(at(0, 0), at(1, 0), tx),
|
||||
TransitionLerp(at(0, 1), at(1, 1), tx), ty);
|
||||
}
|
||||
@@ -359,10 +373,7 @@ ResolvedLayoutTransitionBandsV1 EvaluateResolvedLayoutTransitionBandsV1(
|
||||
return result;
|
||||
}
|
||||
|
||||
// FloatingIslands floor faces retain the same deterministic Gradient field but
|
||||
// cap displacement at one quarter voxel so the transition does not read as a contour.
|
||||
const std::int64_t gradientDivisor=upper.generatorKind=="FloatingIslands"?256:8;
|
||||
const std::int64_t displacement = (noise * (width / gradientDivisor)) / TransitionQ16;
|
||||
const std::int64_t displacement = (noise * (width / 8)) / TransitionQ16;
|
||||
const std::int64_t interfaceZ = seam + displacement;
|
||||
result.upperBedrockBottomMicrometres = interfaceZ;
|
||||
result.upperBedrockTopMicrometres = interfaceZ + upperThickness;
|
||||
|
||||
@@ -270,7 +270,7 @@ public:
|
||||
{
|
||||
const std::int64_t width=std::max<std::int64_t>(0,Upper.transitionWidthMicrometres);
|
||||
if(Upper.transitionMode=="Gradient")
|
||||
return width/(Upper.generatorKind=="FloatingIslands"?256:8);
|
||||
return width/8;
|
||||
if(Upper.transitionMode!="Interleaved")return 0;
|
||||
const std::int64_t upperThickness=std::max<std::int64_t>(0,
|
||||
Upper.bedrock.topMicrometres-Upper.bedrock.bottomMicrometres);
|
||||
@@ -385,6 +385,70 @@ public:
|
||||
return weight;
|
||||
}
|
||||
|
||||
VoxelForgeCore::ResolvedLayoutTransitionBandsV1 ResolvedTransitionBandsAtXY(
|
||||
const VoxelForgeCore::ResolvedLayoutStratumV2& Upper,
|
||||
const VoxelForgeCore::ResolvedLayoutStratumV2& Lower,
|
||||
std::int64_t XMicrometres,std::int64_t YMicrometres,bool SkipPassageFade=false) const
|
||||
{
|
||||
auto bands=VoxelForgeCore::EvaluateResolvedLayoutTransitionBandsV1(Upper,Lower,
|
||||
Owner?Owner->Seed:0,ResolvedLayout?ResolvedLayout->strateLayoutChunkSizeMicrometres:0,
|
||||
XMicrometres,YMicrometres);
|
||||
constexpr std::int64_t TransitionQ16=1ll<<16;
|
||||
const std::int64_t weight=SkipPassageFade?TransitionQ16:
|
||||
ResolvedTransitionPassageWeightQ16(Upper,Lower,XMicrometres,YMicrometres);
|
||||
if(weight>=TransitionQ16)return bands;
|
||||
const std::int64_t seam=Upper.bedrock.bottomMicrometres;
|
||||
const std::int64_t upperThickness=Upper.bedrock.topMicrometres-seam;
|
||||
const std::int64_t lowerThickness=Lower.ceiling.topMicrometres
|
||||
-Lower.ceiling.bottomMicrometres;
|
||||
const auto scaled=[&](std::int64_t value){return (value*weight)/TransitionQ16;};
|
||||
bands.upperBedrockBottomMicrometres=seam+scaled(
|
||||
bands.upperBedrockBottomMicrometres-seam);
|
||||
bands.upperBedrockTopMicrometres=bands.upperBedrockBottomMicrometres+upperThickness;
|
||||
bands.lowerCeilingTopMicrometres=seam+scaled(
|
||||
bands.lowerCeilingTopMicrometres-seam);
|
||||
bands.lowerCeilingBottomMicrometres=bands.lowerCeilingTopMicrometres-lowerThickness;
|
||||
bands.crossingDepthMicrometres=std::max<std::int64_t>(0,
|
||||
bands.lowerCeilingTopMicrometres-bands.upperBedrockBottomMicrometres);
|
||||
return bands;
|
||||
}
|
||||
|
||||
bool ResolvedBedrockTopAt(const VoxelForgeCore::ResolvedLayoutStratumV2& Upper,
|
||||
double X,double Y,double& TopZ) const
|
||||
{
|
||||
const auto* lower=FindResolvedLayoutStratumByOrder(Upper.order+1);
|
||||
if(!lower||!Owner||Owner->CoreVoxelSizeMicrons==0
|
||||
||!IsResolvedLayoutTransitionActive(Upper))return false;
|
||||
const auto toMicrometres=[&](double value)
|
||||
{return static_cast<std::int64_t>(std::llround(value*Owner->CoreVoxelSizeMicrons));};
|
||||
const auto* tileQueries=ActivePassageTileQueries;
|
||||
const bool skipPassageFade=tileQueries&&tileQueries->matches(*this)
|
||||
&&!tileQueries->mayContainTransitionPassageFade();
|
||||
const auto bands=ResolvedTransitionBandsAtXY(Upper,*lower,toMicrometres(X),
|
||||
toMicrometres(Y),skipPassageFade);
|
||||
TopZ=static_cast<double>(bands.upperBedrockTopMicrometres)
|
||||
/Owner->CoreVoxelSizeMicrons;
|
||||
return std::isfinite(TopZ);
|
||||
}
|
||||
|
||||
bool ResolvedCeilingBottomAt(const VoxelForgeCore::ResolvedLayoutStratumV2& Lower,
|
||||
double X,double Y,double& BottomZ) const
|
||||
{
|
||||
const auto* upper=FindResolvedLayoutStratumByOrder(Lower.order-1);
|
||||
if(!upper||!Owner||Owner->CoreVoxelSizeMicrons==0
|
||||
||!IsResolvedLayoutTransitionActive(*upper))return false;
|
||||
const auto toMicrometres=[&](double value)
|
||||
{return static_cast<std::int64_t>(std::llround(value*Owner->CoreVoxelSizeMicrons));};
|
||||
const auto* tileQueries=ActivePassageTileQueries;
|
||||
const bool skipPassageFade=tileQueries&&tileQueries->matches(*this)
|
||||
&&!tileQueries->mayContainTransitionPassageFade();
|
||||
const auto bands=ResolvedTransitionBandsAtXY(*upper,Lower,toMicrometres(X),
|
||||
toMicrometres(Y),skipPassageFade);
|
||||
BottomZ=static_cast<double>(bands.lowerCeilingBottomMicrometres)
|
||||
/Owner->CoreVoxelSizeMicrons;
|
||||
return std::isfinite(BottomZ);
|
||||
}
|
||||
|
||||
bool IsInsideResolvedStrateBoundaryBand(float Z) const
|
||||
{
|
||||
#if defined(VF_L30_ENABLE_COST_COUNTERS)
|
||||
@@ -471,12 +535,72 @@ public:
|
||||
return density;
|
||||
}
|
||||
}
|
||||
if(!ResolvedLayout||!Owner||!Owner->Manager||!std::isfinite(Z))return density;
|
||||
if(!ResolvedLayout||!Owner||!Owner->Manager||Owner->CoreVoxelSizeMicrons==0
|
||||
||!std::isfinite(Z))return density;
|
||||
const double zMicrometres=static_cast<double>(Z)*Owner->CoreVoxelSizeMicrons;
|
||||
if(ResolvedLayout->strates.size()==2)
|
||||
{
|
||||
const auto* transitionUpper=FindResolvedLayoutStratumByOrder(0);
|
||||
const auto* transitionLower=FindResolvedLayoutStratumByOrder(1);
|
||||
if(transitionUpper&&transitionLower
|
||||
&&IsResolvedLayoutTransitionActive(*transitionUpper))
|
||||
{
|
||||
#if defined(VF_L30_ENABLE_COST_COUNTERS)
|
||||
++ThreadClassificationStats().TransitionWindowChecks;
|
||||
#endif
|
||||
const auto maximumDisplacement=ResolvedTransitionMaximumFaceDisplacementMicrometres(
|
||||
*transitionUpper,*transitionLower);
|
||||
const double minimum=std::min(transitionUpper->bedrock.bottomMicrometres,
|
||||
transitionLower->ceiling.bottomMicrometres)-maximumDisplacement;
|
||||
const double maximum=std::max(transitionUpper->bedrock.topMicrometres,
|
||||
transitionLower->ceiling.topMicrometres)+maximumDisplacement;
|
||||
if(zMicrometres>=minimum&&zMicrometres<maximum)
|
||||
{
|
||||
#if defined(VF_L30_ENABLE_COST_COUNTERS)
|
||||
++ThreadClassificationStats().TransitionWindowHits;
|
||||
++ThreadClassificationStats().TransitionEvaluatorCalls;
|
||||
#endif
|
||||
const auto* tileQueries=ActivePassageTileQueries;
|
||||
const bool skipPassageFade=tileQueries&&tileQueries->matches(*this)
|
||||
&&!tileQueries->mayContainTransitionPassageFade();
|
||||
#if defined(VF_L30_ENABLE_COST_COUNTERS)
|
||||
if(skipPassageFade)
|
||||
++ThreadClassificationStats().TransitionPassageTileSkips;
|
||||
#endif
|
||||
const auto xMicrometres=static_cast<std::int64_t>(std::llround(
|
||||
static_cast<double>(X)*Owner->CoreVoxelSizeMicrons));
|
||||
const auto yMicrometres=static_cast<std::int64_t>(std::llround(
|
||||
static_cast<double>(Y)*Owner->CoreVoxelSizeMicrons));
|
||||
const auto bands=ResolvedTransitionBandsAtXY(*transitionUpper,
|
||||
*transitionLower,xMicrometres,yMicrometres,skipPassageFade);
|
||||
const std::int64_t solidBottom=std::min(
|
||||
bands.upperBedrockBottomMicrometres,bands.lowerCeilingBottomMicrometres);
|
||||
const std::int64_t solidTop=std::max(
|
||||
bands.upperBedrockTopMicrometres,bands.lowerCeilingTopMicrometres);
|
||||
const double signedDistanceMicrometres=zMicrometres<solidBottom
|
||||
?static_cast<double>(solidBottom-zMicrometres)
|
||||
:zMicrometres>solidTop
|
||||
?static_cast<double>(zMicrometres-solidTop)
|
||||
:-static_cast<double>(std::min(zMicrometres-solidBottom,
|
||||
solidTop-zMicrometres));
|
||||
const std::int64_t seam=transitionUpper->bedrock.bottomMicrometres;
|
||||
const bool floatingIslandAirGap=
|
||||
transitionUpper->generatorKind=="FloatingIslands"
|
||||
&&transitionUpper->transitionMode=="Gradient"
|
||||
&&bands.upperBedrockTopMicrometres<seam
|
||||
&&zMicrometres>=bands.upperBedrockTopMicrometres
|
||||
&&zMicrometres<seam;
|
||||
if(!(signedDistanceMicrometres>0.0&&density<0.0f
|
||||
&&!floatingIslandAirGap))
|
||||
return static_cast<float>(signedDistanceMicrometres
|
||||
/Owner->CoreVoxelSizeMicrons);
|
||||
}
|
||||
}
|
||||
}
|
||||
const double worldZCentimetres=static_cast<double>(Z)*GetCoreVoxelSizeCm();
|
||||
const int32 strateIndex=Owner->Manager->GetStrateIndex(static_cast<float>(worldZCentimetres));
|
||||
const auto* stratum=FindResolvedLayoutStratum(strateIndex);
|
||||
if(!stratum)return density;
|
||||
const double zMicrometres=static_cast<double>(Z)*Owner->CoreVoxelSizeMicrons;
|
||||
const auto solidAt=[&](const VoxelForgeCore::ResolvedLayoutBandV2& band)
|
||||
{return band.presentInCurrentField&&zMicrometres>=band.bottomMicrometres
|
||||
&&zMicrometres<band.topMicrometres;};
|
||||
@@ -502,77 +626,46 @@ public:
|
||||
#endif
|
||||
if(upper==stratum)bBedrockHandledByTransition=true;
|
||||
if(lower==stratum)bCeilingHandledByTransition=true;
|
||||
const auto* tileQueries=ActivePassageTileQueries;
|
||||
const std::int64_t xMicrometres=static_cast<std::int64_t>(std::llround(
|
||||
static_cast<double>(X)*Owner->CoreVoxelSizeMicrons));
|
||||
const std::int64_t yMicrometres=static_cast<std::int64_t>(std::llround(
|
||||
static_cast<double>(Y)*Owner->CoreVoxelSizeMicrons));
|
||||
const auto bands=VoxelForgeCore::EvaluateResolvedLayoutTransitionBandsV1(*upper,*lower,
|
||||
Owner->Seed,ResolvedLayout->strateLayoutChunkSizeMicrometres,
|
||||
xMicrometres,yMicrometres);
|
||||
auto resolvedBands=bands;
|
||||
constexpr std::int64_t TransitionQ16=1ll<<16;
|
||||
const auto* tileQueries=ActivePassageTileQueries;
|
||||
const bool skipPassageFade=tileQueries&&tileQueries->matches(*this)
|
||||
&&!tileQueries->mayContainTransitionPassageFade();
|
||||
const std::int64_t transitionWeight=skipPassageFade?TransitionQ16
|
||||
:ResolvedTransitionPassageWeightQ16(*upper,*lower,xMicrometres,yMicrometres);
|
||||
#if defined(VF_L30_ENABLE_COST_COUNTERS)
|
||||
if(skipPassageFade)++ThreadClassificationStats().TransitionPassageTileSkips;
|
||||
#endif
|
||||
if(transitionWeight<TransitionQ16)
|
||||
{
|
||||
const std::int64_t seam=upper->bedrock.bottomMicrometres;
|
||||
const std::int64_t upperThickness=upper->bedrock.topMicrometres-seam;
|
||||
const std::int64_t lowerThickness=lower->ceiling.topMicrometres
|
||||
-lower->ceiling.bottomMicrometres;
|
||||
const auto scaled=[&](std::int64_t value)
|
||||
{return (value*transitionWeight)/TransitionQ16;};
|
||||
resolvedBands.upperBedrockBottomMicrometres=seam+scaled(
|
||||
bands.upperBedrockBottomMicrometres-seam);
|
||||
resolvedBands.upperBedrockTopMicrometres=
|
||||
resolvedBands.upperBedrockBottomMicrometres+upperThickness;
|
||||
resolvedBands.lowerCeilingTopMicrometres=seam+scaled(
|
||||
bands.lowerCeilingTopMicrometres-seam);
|
||||
resolvedBands.lowerCeilingBottomMicrometres=
|
||||
resolvedBands.lowerCeilingTopMicrometres-lowerThickness;
|
||||
resolvedBands.crossingDepthMicrometres=std::max<std::int64_t>(0,
|
||||
resolvedBands.lowerCeilingTopMicrometres
|
||||
-resolvedBands.upperBedrockBottomMicrometres);
|
||||
}
|
||||
const bool upperBedrock=zMicrometres>=resolvedBands.upperBedrockBottomMicrometres
|
||||
&&zMicrometres<resolvedBands.upperBedrockTopMicrometres;
|
||||
const bool lowerCeiling=zMicrometres>=resolvedBands.lowerCeilingBottomMicrometres
|
||||
&&zMicrometres<resolvedBands.lowerCeilingTopMicrometres;
|
||||
if(!upperBedrock&&!lowerCeiling)
|
||||
{
|
||||
// FloatingIslands can retain native solid density above a negatively
|
||||
// displaced Gradient face, creating a second surface instead of the
|
||||
// resolved transition top. Keep that air-side gap open in the owner field.
|
||||
const std::int64_t seam=upper->bedrock.bottomMicrometres;
|
||||
if(upper->generatorKind=="FloatingIslands"
|
||||
&&upper->transitionMode=="Gradient"
|
||||
&&resolvedBands.upperBedrockTopMicrometres<seam
|
||||
&&zMicrometres>=resolvedBands.upperBedrockTopMicrometres
|
||||
&&zMicrometres<seam)
|
||||
{
|
||||
const double distanceVoxels=static_cast<double>(zMicrometres
|
||||
-resolvedBands.upperBedrockTopMicrometres)
|
||||
/std::max<std::uint32_t>(1,Owner->CoreVoxelSizeMicrons);
|
||||
density=std::max(0.05f,static_cast<float>(distanceVoxels));
|
||||
return true;
|
||||
}
|
||||
const auto resolvedBands=ResolvedTransitionBandsAtXY(*upper,*lower,
|
||||
xMicrometres,yMicrometres,skipPassageFade);
|
||||
// Treat the two authored bands as one continuous solid interval. Its
|
||||
// exterior faces remain the lower ceiling bottom and upper bedrock top;
|
||||
// measuring signed distance from those faces also clears native generator
|
||||
// density on both air sides of the displaced interface.
|
||||
const std::int64_t solidBottom=std::min(
|
||||
resolvedBands.upperBedrockBottomMicrometres,
|
||||
resolvedBands.lowerCeilingBottomMicrometres);
|
||||
const std::int64_t solidTop=std::max(
|
||||
resolvedBands.upperBedrockTopMicrometres,
|
||||
resolvedBands.lowerCeilingTopMicrometres);
|
||||
const double signedDistanceMicrometres=zMicrometres<solidBottom
|
||||
?static_cast<double>(solidBottom-zMicrometres)
|
||||
:zMicrometres>solidTop
|
||||
?static_cast<double>(zMicrometres-solidTop)
|
||||
:-static_cast<double>(std::min(zMicrometres-solidBottom,
|
||||
solidTop-zMicrometres));
|
||||
const std::int64_t seam=upper->bedrock.bottomMicrometres;
|
||||
const bool floatingIslandAirGap=upper->generatorKind=="FloatingIslands"
|
||||
&&upper->transitionMode=="Gradient"
|
||||
&&resolvedBands.upperBedrockTopMicrometres<seam
|
||||
&&zMicrometres>=resolvedBands.upperBedrockTopMicrometres
|
||||
&&zMicrometres<seam;
|
||||
// A native solid may continue beyond either authored face. Keep that
|
||||
// connected rock; the FI gap is the explicit exception that must stay open.
|
||||
if(signedDistanceMicrometres>0.0&&density<0.0f&&!floatingIslandAirGap)
|
||||
return false;
|
||||
}
|
||||
const auto& band=upperBedrock?upper->bedrock:lower->ceiling;
|
||||
if(upperBedrock&&upper->generatorKind=="FloatingIslands"
|
||||
&&upper->transitionMode=="Gradient")
|
||||
{
|
||||
const double distanceVoxels=static_cast<double>(
|
||||
resolvedBands.upperBedrockTopMicrometres-zMicrometres)
|
||||
/std::max<std::uint32_t>(1,Owner->CoreVoxelSizeMicrons);
|
||||
density=-std::max(0.05f,static_cast<float>(distanceVoxels));
|
||||
}
|
||||
else density=-std::max(0.05f,static_cast<float>(band.solidDensityQ16)/65536.0f);
|
||||
density=static_cast<float>(signedDistanceMicrometres
|
||||
/std::max<std::uint32_t>(1,Owner->CoreVoxelSizeMicrons));
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user