diff --git a/Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h b/Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h index ade7a4e..97523cd 100644 --- a/Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h +++ b/Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h @@ -138,6 +138,9 @@ namespace VoxelForgeCore bool passageComposerProfiled = false; std::uint64_t passageTileFeatureCount = 0; std::uint64_t passageTileRouteSegmentCandidates = 0; + std::uint64_t nativeCaveFieldSamples = 0; + std::uint64_t cavernRoomCandidates = 0; + std::uint64_t cavernEdgeCandidates = 0; std::uint64_t resolvedBoundaryBandQueries = 0; std::uint64_t passageComposerQueries = 0; std::uint64_t passageIterations = 0; @@ -498,8 +501,16 @@ namespace VoxelForgeCore // voxels for every broadphase and proof bound. Version 227 uses the exact smooth-max // support width/6 when deriving that reach. Version 228 composes deterministic cave // graphs from fixed passage mouths and the owner Tunnel Network settings. Version 229 - // budgets cave floor support; 230 records a shared backbone floor profile and joint cap. - inline constexpr std::uint32_t WorldAssemblyEvaluatorVersion = 230; + // budgets cave floor support; 230 records a shared backbone floor profile and joint cap; + // 231 carries passage tangents and floor slopes through cave portal lead-ins and keeps + // floor backing local to the walk footprint, with depth measured along the floor normal; + // 233 bounds passage ring jitter to the authored placement disk; 234 aligns route mouth + // anchors with capsule foot height and carries support through the SurfaceWorld mouth; + // 235 plans the cave backbone as a sampled curve with both passage mouth tangents and + // preserves open cave lanes through passage shell overlaps; 236 binds passage mouths to + // the planned graph route, preserves its walk corridor through shell overlaps, and keeps + // boundary-blended host rock from widening the authored passage wall. + inline constexpr std::uint32_t WorldAssemblyEvaluatorVersion = 236; enum class FieldChannel : std::uint32_t { diff --git a/Standalone/Core/src/ResolvedLayoutInternal.h b/Standalone/Core/src/ResolvedLayoutInternal.h index 0b1286d..5344ff0 100644 --- a/Standalone/Core/src/ResolvedLayoutInternal.h +++ b/Standalone/Core/src/ResolvedLayoutInternal.h @@ -4,6 +4,7 @@ #include "VoxelForgeCore/VoxelForgeCore.h" #include +#include #include #include #include @@ -904,7 +905,7 @@ bool ResolvedLayoutSnapshotV3::finalize(std::string& error) x.lowerInterfaceCapabilities.preserveUpperBedrockAirGap= x.generatorKind=="FloatingIslands"&&x.transitionMode=="Gradient"; x.lowerInterfaceCapabilities.surfaceToTunnelLandingSupport= - x.generatorKind=="SurfaceWorld"&&lower.generatorKind=="TunnelNetwork"; + x.generatorKind=="SurfaceWorld"&&IsNativeCaveFieldStratum(lower); } } const std::int64_t playerCenterAboveFloorQ=static_cast(std::llround( @@ -1094,15 +1095,57 @@ bool ResolvedLayoutSnapshotV3::validate(ResolvedLayoutValidationModeV3 mode, const std::int64_t PlayerCenterAboveFloorQ=coreVoxelSizeMicrometres>0 ?static_cast(std::llround(1.13*WorldAssemblySubvoxelsPerVoxel *1000000.0/coreVoxelSizeMicrometres)):1157; - const auto floorProfile=[&](const ResolvedLayoutPassagePointV1& p) - {return p.positionSubvoxels[2]-p.radiusSubvoxels+PlayerCenterAboveFloorQ;}; + const std::int64_t SurfacePlayerCenterAboveFloorQ=coreVoxelSizeMicrometres>0 + ?static_cast(std::llround(0.98*WorldAssemblySubvoxelsPerVoxel + *1000000.0/coreVoxelSizeMicrometres)):901; + std::vector centerHeightOffsets(passage.controlPoints.size(), + PlayerCenterAboveFloorQ); + const auto& upperMouthPoint=passage.controlPoints.front(); + const bool hasSurfaceRoute=upper->generatorKind=="SurfaceWorld" + &&upperMouthPoint.positionSubvoxels[0]==passage.upperMouthSubvoxels[0] + &&upperMouthPoint.positionSubvoxels[1]==passage.upperMouthSubvoxels[1] + &&upperMouthPoint.positionSubvoxels[2]-upperMouthPoint.radiusSubvoxels + +SurfacePlayerCenterAboveFloorQ==passage.upperMouthSubvoxels[2]; + if(hasSurfaceRoute) + { + const std::int64_t qPerMetre=static_cast(std::llround( + static_cast(WorldAssemblySubvoxelsPerVoxel)*1000000.0 + /std::max(1,coreVoxelSizeMicrometres))); + const std::int64_t blendLength=std::max(8*qPerMetre, + 2*passage.mouthRadiusSubvoxels); + std::int64_t distance=0; + for(std::size_t i=0;i0) + { + const auto& a=passage.controlPoints[i-1].positionSubvoxels; + const auto& b=passage.controlPoints[i].positionSubvoxels; + const auto dx=static_cast(std::abs(b[0]-a[0])); + const auto dy=static_cast(std::abs(b[1]-a[1])); + const auto square=dx*dx+dy*dy; + auto length=PassageIsqrt(square); + if(length*length(length); + } + const double t=std::clamp(static_cast(distance) + /std::max(1,blendLength),0.0,1.0); + const double smooth=t*t*(3.0-2.0*t); + centerHeightOffsets[i]=SurfacePlayerCenterAboveFloorQ + +static_cast(std::llround( + (PlayerCenterAboveFloorQ-SurfacePlayerCenterAboveFloorQ)*smooth)); + } + } + const auto floorProfile=[&](std::size_t index, + const ResolvedLayoutPassagePointV1& p) + {return p.positionSubvoxels[2]-p.radiusSubvoxels + +centerHeightOffsets[index];}; std::size_t lowerMouthControlIndex=passage.controlPoints.size(); for(std::size_t i=1;i(dx*dx+dy*dy)); - const std::int64_t signedDz=floorProfile(point)-floorProfile(previous); + const std::int64_t signedDz=floorProfile(i,point) + -floorProfile(i-1,previous); const std::int64_t dz=std::abs(signedDz); const bool landingExtension=i>lowerMouthControlIndex; if((landingExtension?dz*65536>static_cast(horizontal)*17560 @@ -1452,8 +1501,14 @@ bool ResolvedLayoutSnapshotV3::validate(ResolvedLayoutValidationModeV3 mode, +" and "+edge->stableId+" has distance gap=" +std::to_string(low-previousHigh)+" um height step=" +std::to_string(endpointZ(*previous,false)-endpointZ(*edge,true)) - +" um slope step=" - +std::to_string(endpointSlope(*previous,false)-slope0)); + +" um slope step="+std::to_string( + endpointSlope(*previous,false)-slope0) + +" ("+std::to_string(endpointSlope(*previous,false))+" vs " + +std::to_string(slope0)+") spans_um=(" + +std::to_string(previous->floorProfileEndDistanceMicrometres + -previous->floorProfileStartDistanceMicrometres)+","+ + std::to_string(edge->floorProfileEndDistanceMicrometres + -edge->floorProfileStartDistanceMicrometres)+")"); } previous=edge; (void)high; @@ -2192,6 +2247,7 @@ bool PlanResolvedCavernNetworkV1(const ResolvedLayoutSnapshotV3& layout, std::vector& edges,std::string& error) { rooms.clear();edges.clear();error.clear(); + const auto plannerStarted=std::chrono::steady_clock::now(); const auto stratum=std::find_if(layout.strates.begin(),layout.strates.end(), [strateIndex](const auto& value){return value.strateIndex==strateIndex;}); if(stratum==layout.strates.end()||!IsNativeCaveFieldStratum(*stratum)) @@ -2236,7 +2292,8 @@ bool PlanResolvedCavernNetworkV1(const ResolvedLayoutSnapshotV3& layout, constexpr std::int64_t MinimumBoreRadius=1750000; constexpr std::int64_t MinimumBoreHeight=2200000; constexpr std::int64_t PlannedBoreHeight=4500000; - constexpr std::int64_t MouthOverlap=3000000; + constexpr std::int64_t MouthOverlap=2000000; + constexpr std::int64_t PortalLeadLength=6000000; constexpr std::int64_t MaximumBoreRadius=10000000; constexpr std::int64_t MaximumWalkGradeDenominator=5; // 1:5 = 11.31 degrees. const auto integerSqrt=[](std::uint64_t value) @@ -2262,8 +2319,126 @@ bool PlanResolvedCavernNetworkV1(const ResolvedLayoutSnapshotV3& layout, const auto qToMicrometres=[&](std::int64_t q) {return q*static_cast(layout.coreVoxelSizeMicrometres) /WorldAssemblySubvoxelsPerVoxel;}; + const std::int64_t mouthBoreRadius=std::clamp( + std::max(MinimumBoreRadius,settings.minimumTunnelRadiusMicrometres), + MinimumBoreRadius,MaximumBoreRadius); + const auto chooseMouthRouteTangent=[&](const ResolvedLayoutPassageV1& passage, + std::size_t mouthIndex,bool lowerSide, + const std::array& authoredRouteTangentQ16, + std::int64_t authoredFloorSlopeQ16,const FPoint& mouthFloorPoint) + { + constexpr std::array,16> DirectionsQ16={{ + {{65536,0}},{{60547,25080}},{{46341,46341}},{{25080,60547}}, + {{0,65536}},{{-25080,60547}},{{-46341,46341}},{{-60547,25080}}, + {{-65536,0}},{{-60547,-25080}},{{-46341,-46341}},{{-25080,-60547}}, + {{0,-65536}},{{25080,-60547}},{{46341,-46341}},{{60547,-25080}}}}; + const auto scoreDirection=[&](const std::array& outwardQ16) + { + const std::size_t segmentCount=passage.controlPoints.size()>1 + ?passage.controlPoints.size()-1:0; + std::int64_t worst=std::numeric_limits::max(); + bool sampled=false; + const std::int64_t projectedSlopeQ16=(authoredFloorSlopeQ16 + *(outwardQ16[0]*authoredRouteTangentQ16[0] + +outwardQ16[1]*authoredRouteTangentQ16[1]) + /(65536ll*65536ll)); + constexpr std::int64_t MouthClearanceProbeExtent=48000000; + for(std::int64_t distance=2000000; + distance<=MouthClearanceProbeExtent;distance+=2000000) + { + const std::int64_t x=mouthFloorPoint[0] + +outwardQ16[0]*distance/65536; + const std::int64_t y=mouthFloorPoint[1] + +outwardQ16[1]*distance/65536; + const std::int64_t caveFloor=mouthFloorPoint[2] + +projectedSlopeQ16*distance/65536; + const std::int64_t caveCeiling=caveFloor+PlannedBoreHeight; + for(std::size_t segmentIndex=0;segmentIndex=mouthIndex)continue; + if(!lowerSide&&segmentIndex<2)continue; + const auto& a=passage.controlPoints[segmentIndex]; + const auto& b=passage.controlPoints[segmentIndex+1]; + const std::int64_t ax=qToMicrometres(a.positionSubvoxels[0]); + const std::int64_t ay=qToMicrometres(a.positionSubvoxels[1]); + const std::int64_t az=qToMicrometres(a.positionSubvoxels[2]); + const std::int64_t bx=qToMicrometres(b.positionSubvoxels[0]); + const std::int64_t by=qToMicrometres(b.positionSubvoxels[1]); + const std::int64_t bz=qToMicrometres(b.positionSubvoxels[2]); + const std::int64_t dx=bx-ax,dy=by-ay; + const double lengthSquared=static_cast(dx)*dx + +static_cast(dy)*dy; + const double projection=lengthSquared>0.0 + ?std::clamp((static_cast(x-ax)*dx + +static_cast(y-ay)*dy)/lengthSquared,0.0,1.0):0.0; + const std::int64_t tQ16=static_cast(std::llround( + projection*65536.0)); + const std::int64_t closestX=ax+dx*tQ16/65536; + const std::int64_t closestY=ay+dy*tQ16/65536; + const std::int64_t horizontalDistance=static_cast( + integerSqrt(static_cast((x-closestX)*(x-closestX) + +(y-closestY)*(y-closestY)))); + const std::int64_t passageCenterZ=qToMicrometres( + a.positionSubvoxels[2]) + +(qToMicrometres(b.positionSubvoxels[2]) + -qToMicrometres(a.positionSubvoxels[2]))*tQ16/65536; + const std::int64_t passageRadius=qToMicrometres(a.radiusSubvoxels) + +(qToMicrometres(b.radiusSubvoxels) + -qToMicrometres(a.radiusSubvoxels))*tQ16/65536; + const std::int64_t horizontalGap=horizontalDistance + -passageRadius-mouthBoreRadius; + const std::int64_t passageFloor=passageCenterZ-passageRadius; + const std::int64_t passageCeiling=passageCenterZ+passageRadius; + const std::int64_t verticalGap=caveFloor>passageCeiling + ?caveFloor-passageCeiling + :(passageFloor>caveCeiling?passageFloor-caveCeiling + :-std::min(caveCeiling-passageFloor, + passageCeiling-caveFloor)); + const std::int64_t separation=horizontalGap>=0&&verticalGap>=0 + ?static_cast(integerSqrt(static_cast( + horizontalGap*horizontalGap+verticalGap*verticalGap))) + :std::max(horizontalGap,verticalGap); + worst=std::min(worst,separation);sampled=true; + } + } + return sampled?worst:std::numeric_limits::max(); + }; + + // Keep the authored tangent whenever it clears the other passage spans. + // If the passage folds back over the mouth, choose the outward direction + // with the greatest 3D clearance while preserving deterministic tie order. + const std::array authoredOutwardQ16={ + lowerSide?authoredRouteTangentQ16[0]:-authoredRouteTangentQ16[0], + lowerSide?authoredRouteTangentQ16[1]:-authoredRouteTangentQ16[1]}; + const std::int64_t authoredScore=scoreDirection(authoredOutwardQ16); + if(authoredScore>=0)return authoredRouteTangentQ16; + std::array bestOutwardQ16=authoredOutwardQ16; + std::int64_t bestScore=authoredScore; + std::int64_t bestAlignment=authoredOutwardQ16[0]*authoredOutwardQ16[0] + +authoredOutwardQ16[1]*authoredOutwardQ16[1]; + for(const auto& candidate:DirectionsQ16) + { + const std::int64_t score=scoreDirection(candidate); + const std::int64_t alignment=candidate[0]*authoredOutwardQ16[0] + +candidate[1]*authoredOutwardQ16[1]; + if(score>bestScore||(score==bestScore&&alignment>bestAlignment)) + {bestScore=score;bestAlignment=alignment;bestOutwardQ16=candidate;} + } + std::printf("[CavernMouthClearancePlan] passage=%s side=%s authored_clearance_m=%.3f selected_clearance_m=%.3f outward_q16=(%lld,%lld)\n", + passage.stableId.c_str(),lowerSide?"lower":"upper", + authoredScore/1.0e6,bestScore/1.0e6, + static_cast(bestOutwardQ16[0]), + static_cast(bestOutwardQ16[1])); + return std::array{ + lowerSide?bestOutwardQ16[0]:-bestOutwardQ16[0], + lowerSide?bestOutwardQ16[1]:-bestOutwardQ16[1]}; + }; const auto mouthInnerPoint=[&](const ResolvedLayoutPassageV1& passage, - bool lowerSide,FPoint& point) + bool lowerSide,FPoint& point,std::array& routeTangentQ16, + std::int64_t& floorSlopeQ16) { for(std::size_t axis=0;axis<3;++axis) point[axis]=qToMicrometres(lowerSide?passage.lowerMouthSubvoxels[axis] @@ -2273,91 +2448,436 @@ bool PlanResolvedCavernNetworkV1(const ResolvedLayoutSnapshotV3& layout, ?std::min(passage.lowerMouthControlIndex,passage.controlPoints.size()-1):0; const std::size_t tangentIndex=lowerSide ?(mouthIndex>0?mouthIndex-1:1):1; - const std::int64_t tx=passage.controlPoints[mouthIndex].positionSubvoxels[0] - -passage.controlPoints[tangentIndex].positionSubvoxels[0]; - const std::int64_t ty=passage.controlPoints[mouthIndex].positionSubvoxels[1] - -passage.controlPoints[tangentIndex].positionSubvoxels[1]; + // The lower arrival continues from the previous passage point through its + // mouth. The upper departure approaches its mouth from the cave and then + // continues toward control point 1, so the two route tangents have opposite + // signs even though both portal offsets lie on the cave side. + const std::int64_t direction=lowerSide?1:-1; + const std::int64_t tx=direction*(passage.controlPoints[mouthIndex].positionSubvoxels[0] + -passage.controlPoints[tangentIndex].positionSubvoxels[0]); + const std::int64_t ty=direction*(passage.controlPoints[mouthIndex].positionSubvoxels[1] + -passage.controlPoints[tangentIndex].positionSubvoxels[1]); const auto tangentLength=static_cast(integerSqrt( static_cast(tx*tx+ty*ty))); if(tangentLength<=0)return false; - point[0]+=tx*MouthOverlap/tangentLength; - point[1]+=ty*MouthOverlap/tangentLength; + routeTangentQ16={tx*65536/tangentLength,ty*65536/tangentLength}; + const auto& mouthControl=passage.controlPoints[mouthIndex]; + const auto& tangentControl=passage.controlPoints[tangentIndex]; + const std::int64_t mouthFloor=mouthControl.positionSubvoxels[2] + -mouthControl.radiusSubvoxels; + const std::int64_t tangentFloor=tangentControl.positionSubvoxels[2] + -tangentControl.radiusSubvoxels; + // Resolved passage mouths already store the player center at floor + 1.13 m. + // Cave edge control points store the floor itself, so start from the passage + // control's floor rather than reusing the player-center mouth height. + point[2]=qToMicrometres(mouthFloor); + const std::int64_t passageFloorDelta=direction*(mouthFloor-tangentFloor); + floorSlopeQ16=passageFloorDelta*65536/tangentLength; + const auto authoredRouteTangentQ16=routeTangentQ16; + routeTangentQ16=chooseMouthRouteTangent(passage,mouthIndex,lowerSide, + authoredRouteTangentQ16,floorSlopeQ16,point); + const std::int64_t tangentAlignment=routeTangentQ16[0] + *authoredRouteTangentQ16[0]+routeTangentQ16[1] + *authoredRouteTangentQ16[1]; + floorSlopeQ16=floorSlopeQ16*tangentAlignment/(65536ll*65536ll); + const std::int64_t offsetDirection=lowerSide?1:-1; + point[0]+=offsetDirection*routeTangentQ16[0]*MouthOverlap/65536; + point[1]+=offsetDirection*routeTangentQ16[1]*MouthOverlap/65536; + point[2]+=offsetDirection*floorSlopeQ16*MouthOverlap/65536; return true; }; FPoint routeStart{},routeEnd{}; - if(!mouthInnerPoint(*arrivalPassage,true,routeStart) - ||!mouthInnerPoint(*departurePassage,false,routeEnd)) + std::array arrivalTangentQ16{},departureTangentQ16{}; + std::int64_t arrivalFloorSlopeQ16=0,departureFloorSlopeQ16=0; + if(!mouthInnerPoint(*arrivalPassage,true,routeStart,arrivalTangentQ16, + arrivalFloorSlopeQ16) + ||!mouthInnerPoint(*departurePassage,false,routeEnd,departureTangentQ16, + departureFloorSlopeQ16)) {error="cavern passage mouth is missing a usable horizontal route tangent";return false;} const std::int64_t dx=routeEnd[0]-routeStart[0]; const std::int64_t dy=routeEnd[1]-routeStart[1]; const std::int64_t dz=routeEnd[2]-routeStart[2]; - const std::int64_t straightLength=distance2d(routeStart,routeEnd); const std::int64_t horizontalBias=std::clamp( settings.tunnelHorizontalBiasQ16,0,65536); const std::int64_t planGradeDenominator=MaximumWalkGradeDenominator +horizontalBias/32768; - const std::int64_t requiredLength=std::abs(dz)*planGradeDenominator+1000; - std::int64_t lateral=std::max(1000000, - std::max(settings.tunnelWarpMicrometres,settings.caveWarpMicrometres)/2); - const std::uint64_t routeHash=LayoutStampSeed(seed,strateIndex, - "tunnel-network/main-route"); - if((routeHash&1u)!=0)lateral=-lateral; + const std::int64_t requiredLength=(std::abs(dz)*planGradeDenominator*130)/100+1000; std::int64_t pathAxisX=dx,pathAxisY=dy; - const auto makeRoute=[&](std::int64_t lateralOffset) + FPoint routeLeadStart=routeStart,routeLeadEnd=routeEnd; + for(std::size_t axis=0;axis<2;++axis) { - std::array points{}; - points[0]=routeStart;points[3]=routeEnd; - std::int64_t axisX=dx,axisY=dy; - std::int64_t axisLength=straightLength; - if(axisLength<=0) + routeLeadStart[axis]+=arrivalTangentQ16[axis]*PortalLeadLength/65536; + routeLeadEnd[axis]-=departureTangentQ16[axis]*PortalLeadLength/65536; + } + routeLeadStart[2]+=arrivalFloorSlopeQ16*PortalLeadLength/65536; + routeLeadEnd[2]-=departureFloorSlopeQ16*PortalLeadLength/65536; + const std::int64_t mouthTangentDot=arrivalTangentQ16[0]*departureTangentQ16[0] + +arrivalTangentQ16[1]*departureTangentQ16[1]; + const auto makeRoute=[&](std::int64_t handleLength, + std::int64_t lateralOffsetMicrometres=0) + { + if(mouthTangentDot<0) { - axisX=1;axisY=0;axisLength=1; + // A cubic with opposed portal tangents folds back over itself when its + // handles are lengthened to satisfy the walking grade. Make the loop + // explicit: leave both mouths on their authored tangents, carry two + // parallel legs on separate lanes, and turn through cavern rooms. + constexpr std::int64_t LaneSeparation=30000000; + const std::array sideQ16={ + -arrivalTangentQ16[1],arrivalTangentQ16[0]}; + FPoint outTip=routeLeadStart,shiftedTip{},returnTip{},terminal=routeLeadEnd; + for(std::size_t axis=0;axis<2;++axis) + { + outTip[axis]+=arrivalTangentQ16[axis]*handleLength/65536; + shiftedTip[axis]=outTip[axis]+sideQ16[axis] + *(LaneSeparation+lateralOffsetMicrometres)/65536; + } + outTip[2]+=arrivalFloorSlopeQ16*handleLength/65536; + shiftedTip[2]=outTip[2]; + terminal=routeLeadEnd; + const std::int64_t departureTangentLengthSquared= + departureTangentQ16[0]*departureTangentQ16[0] + +departureTangentQ16[1]*departureTangentQ16[1]; + const std::int64_t toTerminalX=terminal[0]-shiftedTip[0]; + const std::int64_t toTerminalY=terminal[1]-shiftedTip[1]; + const std::int64_t returnLength=std::max(1000000, + (toTerminalX*departureTangentQ16[0] + +toTerminalY*departureTangentQ16[1])*65536 + /departureTangentLengthSquared); + for(std::size_t axis=0;axis<2;++axis) + returnTip[axis]=shiftedTip[axis] + +departureTangentQ16[axis]*returnLength/65536; + returnTip[2]=routeEnd[2] + -departureFloorSlopeQ16*(returnLength+PortalLeadLength)/65536; + return std::vector{routeStart,routeLeadStart,outTip, + shiftedTip,returnTip,routeLeadEnd,routeEnd}; } - pathAxisX=axisX;pathAxisY=axisY; - const std::int64_t perpX=-axisY*lateralOffset/axisLength; - const std::int64_t perpY= axisX*lateralOffset/axisLength; - for(std::size_t axis=0;axis<3;++axis) + FPoint controlStart=routeLeadStart,controlEnd=routeLeadEnd; + for(std::size_t axis=0;axis<2;++axis) { - const std::int64_t delta=routeEnd[axis]-routeStart[axis]; - points[1][axis]=routeStart[axis]+delta/3; - points[2][axis]=routeStart[axis]+(delta*2)/3; + controlStart[axis]+=arrivalTangentQ16[axis]*handleLength/65536; + controlEnd[axis]-=departureTangentQ16[axis]*handleLength/65536; } - points[1][0]+=perpX;points[1][1]+=perpY; - // Keep both control points on the same side of the mouth axis. Opposite - // offsets create a tight hairpin whose descending bores overlap in XY. - points[2][0]+=perpX;points[2][1]+=perpY; + controlStart[2]+=arrivalFloorSlopeQ16*handleLength/65536; + controlEnd[2]-=departureFloorSlopeQ16*handleLength/65536; + + std::vector points; + points.reserve(35); + points.push_back(routeStart); + points.push_back(routeLeadStart); + constexpr std::int64_t CurveParameterOne=std::int64_t{1}<<20; + constexpr std::int64_t CurveSamples=32; + const auto interpolate=[=](const FPoint& a,const FPoint& b,std::int64_t t) + { + FPoint point{}; + for(std::size_t axis=0;axis<3;++axis) + point[axis]=a[axis]+(b[axis]-a[axis])*t/CurveParameterOne; + return point; + }; + const std::int64_t pathAxisLength=static_cast(integerSqrt( + static_cast(pathAxisX*pathAxisX+pathAxisY*pathAxisY))); + const std::array pathSideQ16=pathAxisLength>0 + ?std::array{{-pathAxisY*65536/pathAxisLength, + pathAxisX*65536/pathAxisLength}} + :std::array{{-arrivalTangentQ16[1],arrivalTangentQ16[0]}}; + for(std::int64_t sample=1;sample<=CurveSamples;++sample) + { + const std::int64_t t=sample*CurveParameterOne/CurveSamples; + const FPoint a=interpolate(routeLeadStart,controlStart,t); + const FPoint b=interpolate(controlStart,controlEnd,t); + const FPoint c=interpolate(controlEnd,routeLeadEnd,t); + const FPoint d=interpolate(a,b,t); + const FPoint e=interpolate(b,c,t); + FPoint point=interpolate(d,e,t); + if(lateralOffsetMicrometres!=0) + { + const std::int64_t one=CurveParameterOne-t; + const std::int64_t t2=t*t/CurveParameterOne; + const std::int64_t one2=one*one/CurveParameterOne; + const std::int64_t envelope=std::min(CurveParameterOne, + 16*t2*one2/CurveParameterOne); + const std::int64_t offset=lateralOffsetMicrometres*envelope + /CurveParameterOne; + for(std::size_t axis=0;axis<2;++axis) + point[axis]+=pathSideQ16[axis]*offset/65536; + } + points.push_back(point); + } + points.push_back(routeEnd); return points; }; - const auto routeLength=[&](const std::array& points) + const auto routeLength=[&](const std::vector& points) { std::int64_t length=0; for(std::size_t i=1;i0)lateral=std::min(lateral*2,1000000000ll); - else lateral=std::max(lateral*2,-1000000000ll); - routePoints=makeRoute(lateral); + std::array a{},b{}; + double radius=0.0; + }; + std::vector routePassageClearanceSpans; + const auto appendPassageClearanceSpans=[&]( + const ResolvedLayoutPassageV1& passage,bool lowerMouth) + { + if(passage.controlPoints.size()<2)return; + std::vector controls; + controls.reserve(passage.controlPoints.size()); + for(const auto& point:passage.controlPoints) + controls.push_back({static_cast(qToMicrometres( + point.positionSubvoxels[0])), + static_cast(qToMicrometres(point.positionSubvoxels[1])), + static_cast(qToMicrometres(point.positionSubvoxels[2])), + static_cast(qToMicrometres(point.radiusSubvoxels))}); + const auto segments=VoxelForgeCore::PassageSpline::BuildSegments(controls, + controls.size()-1,2.0,1000000.0); + const double totalLength=segments.empty()?0.0: + segments.back().alongMetres+segments.back().lengthMetres; + constexpr double PortalExclusionMetres=10.0; + for(const auto& segment:segments) + { + const double midpoint=segment.alongMetres+segment.lengthMetres*0.5; + if((lowerMouth&&totalLength-midpoint& p0, + const std::array& p1,const std::array& q0, + const std::array& q1) + { + constexpr double Epsilon=1.0e-12; + std::array u{},v{},w{}; + double a=0.0,b=0.0,c=0.0,d=0.0,e=0.0; + for(std::size_t axis=0;axis<3;++axis) + { + u[axis]=p1[axis]-p0[axis];v[axis]=q1[axis]-q0[axis]; + w[axis]=p0[axis]-q0[axis]; + a+=u[axis]*u[axis];b+=u[axis]*v[axis];c+=v[axis]*v[axis]; + d+=u[axis]*w[axis];e+=v[axis]*w[axis]; + } + double sNumerator=0.0,sDenominator=a*c-b*b; + double tNumerator=0.0,tDenominator=sDenominator; + if(a<=Epsilon&&c<=Epsilon)return std::sqrt(w[0]*w[0]+w[1]*w[1]+w[2]*w[2]); + if(a<=Epsilon) + {sNumerator=0.0;sDenominator=1.0;tNumerator=e;tDenominator=c;} + else if(c<=Epsilon) + {tNumerator=0.0;tDenominator=1.0;sNumerator=-d;sDenominator=a;} + else + { + if(sDenominatorsDenominator) + {sNumerator=sDenominator;tNumerator=e+b;tDenominator=c;} + } + } + if(tNumerator<0.0) + { + tNumerator=0.0; + if(-d<0.0)sNumerator=0.0; + else if(-d>a)sNumerator=sDenominator; + else {sNumerator=-d;sDenominator=a;} + } + else if(tNumerator>tDenominator) + { + tNumerator=tDenominator; + if(-d+b<0.0)sNumerator=0.0; + else if(-d+b>a)sNumerator=sDenominator; + else {sNumerator=-d+b;sDenominator=a;} + } + const double s=std::abs(sNumerator)& points) + { + if(routePassageClearanceSpans.empty()||points.size()<2) + return std::numeric_limits::infinity(); + const double totalLength=routeLength(points)/1.0e6; + constexpr double PortalExclusionMetres=10.0; + double minimumClearance=std::numeric_limits::infinity(); + double along=0.0; + for(std::size_t index=1;index=PortalExclusionMetres + &&totalLength-midpoint>=PortalExclusionMetres) + { + const std::array a={static_cast(points[index-1][0]), + static_cast(points[index-1][1]), + static_cast(points[index-1][2])}; + const std::array b={static_cast(points[index][0]), + static_cast(points[index][1]), + static_cast(points[index][2])}; + for(const auto& span:routePassageClearanceSpans) + minimumClearance=std::min(minimumClearance, + segmentDistanceMicrometres(a,b,span.a,span.b)/1.0e6 + -span.radius/1.0e6-mouthBoreRadius/1.0e6); + } + along+=segmentLength; + } + return minimumClearance; + }; + std::int64_t handleLength=10000000; + std::int64_t handleLow=handleLength; + std::int64_t handleHigh=std::max(handleLength,requiredLength/2); + auto routePoints=makeRoute(handleHigh); + while(routeLength(routePoints)(handleHigh*2,1000000000ll); + routePoints=makeRoute(handleHigh); } + if(routeLength(makeRoute(handleLow))>=requiredLength) + handleHigh=handleLow; + else + { + for(int attempt=0;attempt<24&&handleHigh-handleLow>1;++attempt) + { + const std::int64_t middle=handleLow+(handleHigh-handleLow)/2; + if(routeLength(makeRoute(middle))>=requiredLength)handleHigh=middle; + else handleLow=middle; + } + } + handleLength=handleHigh; + routePoints=makeRoute(handleLength); + double plannedPassageClearanceMetres=routePassageClearance(routePoints); + std::int64_t selectedLateralOffsetMicrometres=0; + if(plannedPassageClearanceMetres<0.25) + { + constexpr std::array LateralOffsetsMicrometres={ + 20000000,-20000000,40000000,-40000000, + 80000000,-80000000,160000000,-160000000}; + for(const std::int64_t offset:LateralOffsetsMicrometres) + { + auto candidate=makeRoute(handleLength,offset); + if(routeLength(candidate)plannedPassageClearanceMetres+1.0e-6) + { + routePoints=std::move(candidate); + plannedPassageClearanceMetres=clearance; + selectedLateralOffsetMicrometres=offset; + } + } + } + const auto routeTangentAlignment=[&](std::size_t from,std::size_t to, + const std::array& tangent) + { + const double tangentLength=std::hypot(static_cast(tangent[0]), + static_cast(tangent[1])); + const double dx=static_cast(routePoints[to][0]-routePoints[from][0]); + const double dy=static_cast(routePoints[to][1]-routePoints[from][1]); + const double segmentLength=std::hypot(dx,dy); + return tangentLength>0.0&&segmentLength>0.0 + ?(dx*tangent[0]+dy*tangent[1])/(tangentLength*segmentLength):0.0; + }; + const double plannedMouthTangentAlignment=std::min( + routeTangentAlignment(1,2,arrivalTangentQ16), + routeTangentAlignment(routePoints.size()-2,routePoints.size()-1, + departureTangentQ16)); const std::int64_t totalRouteLength=routeLength(routePoints); if(totalRouteLength<=0||totalRouteLength cumulative{}; + std::vector cumulative(routePoints.size(),0); for(std::size_t i=1;i(distance,0,totalRouteLength); - return routeStart[2]+dz*distance/totalRouteLength; + const std::int64_t u=static_cast(static_cast(distance) + *ProfileOne/totalRouteLength); + const std::int64_t u2=u*u/ProfileOne; + const std::int64_t u3=u2*u/ProfileOne; + return std::array{ + 2*u3-3*u2+ProfileOne,u3-2*u2+u,-2*u3+3*u2,u3-u2, + 6*u2-6*u,3*u2-4*u+ProfileOne,-6*u2+6*u,3*u2-2*u}; }; + const auto profileSlopeAt=[&](std::int64_t distance, + std::int64_t startSlope,std::int64_t endSlope) + { + const auto basis=profileBasis(distance); + const std::int64_t endpointDerivative=basis[4]*routeStart[2] + +basis[6]*routeEnd[2]; + const std::int64_t tangentDerivative=basis[5]*startSlope + +basis[7]*endSlope; + return roundedDivide(roundedDivide(endpointDerivative,ProfileOne)*65536, + totalRouteLength) + +roundedDivide(tangentDerivative,ProfileOne); + }; + const std::int64_t averageFloorSlopeQ16=roundedDivide( + (routeEnd[2]-routeStart[2])*65536,totalRouteLength); + const std::int64_t authoredArrivalFloorSlopeQ16=arrivalFloorSlopeQ16; + const std::int64_t authoredDepartureFloorSlopeQ16=departureFloorSlopeQ16; + const std::int64_t maximumProfileSlopeQ16=10813; // 0.165, below the 1:5 walk grade. + const auto profileSlopeBlendSafe=[&](std::int64_t blendQ16) + { + const std::int64_t startSlope=averageFloorSlopeQ16 + +(authoredArrivalFloorSlopeQ16-averageFloorSlopeQ16)*blendQ16/65536; + const std::int64_t endSlope=averageFloorSlopeQ16 + +(authoredDepartureFloorSlopeQ16-averageFloorSlopeQ16)*blendQ16/65536; + for(std::int64_t sample=0;sample<=128;++sample) + { + const std::int64_t distance=totalRouteLength*sample/128; + if(std::abs(profileSlopeAt(distance,startSlope,endSlope)) + >maximumProfileSlopeQ16)return false; + } + return true; + }; + std::int64_t slopeBlendLow=0,slopeBlendHigh=65536; + for(int attempt=0;attempt<17;++attempt) + { + const std::int64_t middle=(slopeBlendLow+slopeBlendHigh+1)/2; + if(profileSlopeBlendSafe(middle))slopeBlendLow=middle; + else slopeBlendHigh=middle-1; + } + arrivalFloorSlopeQ16=averageFloorSlopeQ16 + +(authoredArrivalFloorSlopeQ16-averageFloorSlopeQ16)*slopeBlendLow/65536; + departureFloorSlopeQ16=averageFloorSlopeQ16 + +(authoredDepartureFloorSlopeQ16-averageFloorSlopeQ16)*slopeBlendLow/65536; + const auto routeFloorAt=[&](std::int64_t distance) + { + const auto basis=profileBasis(distance); + const std::int64_t positionTerms=basis[0]*routeStart[2] + +basis[2]*routeEnd[2]; + const std::int64_t startTangentDistance=basis[1]*totalRouteLength/ProfileOne; + const std::int64_t endTangentDistance=basis[3]*totalRouteLength/ProfileOne; + const std::int64_t tangentTerms=(startTangentDistance*arrivalFloorSlopeQ16 + +endTangentDistance*departureFloorSlopeQ16)/65536; + return roundedDivide(positionTerms,ProfileOne)+tangentTerms; + }; + const auto routeFloorSlopeQ16At=[&](std::int64_t distance) + {return profileSlopeAt(distance,arrivalFloorSlopeQ16,departureFloorSlopeQ16);}; const auto routeFloorGradientAt=[&](std::int64_t distance) { distance=std::clamp(distance,0,totalRouteLength); @@ -2381,11 +2901,10 @@ bool PlanResolvedCavernNetworkV1(const ResolvedLayoutSnapshotV3& layout, const double length=std::hypot(tangent[0],tangent[1]); if(length>1.0e-12){tangent[0]/=length;tangent[1]/=length;} } - constexpr double Q16=65536.0; - const double profileSlope=static_cast(dz)/totalRouteLength; + const std::int64_t profileSlope=routeFloorSlopeQ16At(distance); return std::array{ - static_cast(std::llround(tangent[0]*profileSlope*Q16)), - static_cast(std::llround(tangent[1]*profileSlope*Q16))}; + static_cast(std::llround(tangent[0]*profileSlope)), + static_cast(std::llround(tangent[1]*profileSlope))}; }; const auto pointAt=[&](std::int64_t distance) { @@ -2414,23 +2933,45 @@ bool PlanResolvedCavernNetworkV1(const ResolvedLayoutSnapshotV3& layout, if(spacing<=0) {error="cavern planner has no positive route spacing after applying tunnel length limits";return false;} std::vector nodeDistances; - for(std::size_t leg=1;leg(1, - (legLength+spacing-1)/spacing); - if(segments>256) - {error="cavern planner would exceed the 256-node bounded backbone";return false;} - for(std::int64_t part=1;part<=segments;++part) - { - const std::int64_t distance=legStart+legLength*part/segments; - if(distance>0&&distance(beforeX), + static_cast(beforeY)); + const double afterLength=std::hypot(static_cast(afterX), + static_cast(afterY)); + if(beforeLength<=0.0||afterLength<=0.0)continue; + const double alignment=(static_cast(beforeX)*afterX + +static_cast(beforeY)*afterY)/(beforeLength*afterLength); + if(alignment<0.95||std::min(beforeLength,afterLength) profileSpacedNodeDistances; + profileSpacedNodeDistances.reserve(nodeDistances.size()); + for(const std::int64_t distance:nodeDistances) + if(distance>=MinimumProfileSpanMicrometres + &&(profileSpacedNodeDistances.empty() + ||distance-profileSpacedNodeDistances.back() + >=MinimumProfileSpanMicrometres)) + profileSpacedNodeDistances.push_back(distance); + while(!profileSpacedNodeDistances.empty() + &&totalRouteLength-profileSpacedNodeDistances.back() + 256) {error="cavern planner would exceed the 256-node bounded backbone";return false;} @@ -2455,18 +2996,6 @@ bool PlanResolvedCavernNetworkV1(const ResolvedLayoutSnapshotV3& layout, return routeBoreRadius+(span==0?0:static_cast( LayoutStampSeed(seed,strateIndex,stableId)%(span+1))); }; - const auto passageBoreRadius=[&](const ResolvedLayoutPassageV1& passage,bool lowerSide) - { - std::int64_t radius=qToMicrometres(passage.mouthRadiusSubvoxels); - if(!passage.controlPoints.empty()) - { - const auto control=lowerSide?std::min(passage.lowerMouthControlIndex, - passage.controlPoints.size()-1):std::size_t{0}; - radius=std::max(radius,qToMicrometres(passage.controlPoints[control].radiusSubvoxels)); - } - return std::clamp(std::max(routeBoreRadius,radius), - routeBoreRadius,MaximumBoreRadius); - }; const auto fractional=[](std::int64_t value,std::int64_t q16) {return value*std::clamp(q16,0,65536)/65536;}; struct FRoomNode @@ -2613,10 +3142,14 @@ bool PlanResolvedCavernNetworkV1(const ResolvedLayoutSnapshotV3& layout, { const std::int64_t floorStart=routeFloorAt(profileStartDistance); const std::int64_t floorEnd=routeFloorAt(profileEndDistance); - const std::int64_t floorDelta=floorEnd-floorStart; + const std::int64_t profileSpan=profileEndDistance-profileStartDistance; + const std::int64_t startSlope=routeFloorSlopeQ16At(profileStartDistance); + const std::int64_t endSlope=routeFloorSlopeQ16At(profileEndDistance); edge.controlPointsMicrometres[0][2]=floorStart; - edge.controlPointsMicrometres[1][2]=floorStart+floorDelta/3; - edge.controlPointsMicrometres[2][2]=floorStart+(floorDelta*2)/3; + edge.controlPointsMicrometres[1][2]=floorStart + +roundedDivide(startSlope*profileSpan,3*65536); + edge.controlPointsMicrometres[2][2]=floorEnd + -roundedDivide(endSlope*profileSpan,3*65536); edge.controlPointsMicrometres[3][2]=floorEnd; } return edge; @@ -2628,12 +3161,12 @@ bool PlanResolvedCavernNetworkV1(const ResolvedLayoutSnapshotV3& layout, for(std::size_t nodeIndex=0;nodeIndexstableId+"/upper-mouth", - routeEnd,nodes.back(),passageBoreRadius(*departurePassage,false), - totalRouteLength,nodes.back().floorProfileDistanceMicrometres)); + auto edge=makeEdge(departureEdgeId,departurePassage->stableId+"/upper-mouth", + routeEnd,nodes.back(),selectedBoreRadius(departureEdgeId), + totalRouteLength,nodes.back().floorProfileDistanceMicrometres); + edges.push_back(std::move(edge)); } const std::int64_t roomDensity=clampQ16(settings.roomDensityQ16); @@ -2706,16 +3240,27 @@ bool PlanResolvedCavernNetworkV1(const ResolvedLayoutSnapshotV3& layout, if(run<=0&&rise>0) {error="cavern planner produced a vertical route edge with no walking run";return false;} if(run>0&&rise*MaximumWalkGradeDenominator>run) - {error="cavern planner emitted an edge beyond its 1:5 route grade budget";return false;} + { + std::printf("[CavernPlannerGradeReject] edge=%s run_m=%.3f rise_m=%.3f grade_deg=%.3f start=(%.3f,%.3f,%.3f) end=(%.3f,%.3f,%.3f)\n", + edge.stableId.c_str(),run/1.0e6,rise/1.0e6, + std::atan2(static_cast(rise),static_cast(run)) + *180.0/3.14159265358979323846, + a[0]/1.0e6,a[1]/1.0e6,a[2]/1.0e6, + b[0]/1.0e6,b[1]/1.0e6,b[2]/1.0e6); + error="cavern planner emitted an edge beyond its 1:5 route grade budget";return false; + } if(run>0)maximumGradeDegrees=std::max(maximumGradeDegrees, std::atan2(static_cast(rise),static_cast(run)) *180.0/3.14159265358979323846); } - std::printf("[CavernPlannerV1] strate=%d seed=%d mouths=%s->%s route_nodes=%zu room_primitives=%zu edges=%zu branches=%zu route_horizontal_m=%.3f floor_delta_m=%.3f max_grade_deg=%.3f grade_limit_deg=11.310 clearance_radius_m=%.3f clearance_height_m=%.3f destination_radius_m=%.3f status=PASS\n", + const double plannerMilliseconds=std::chrono::duration( + std::chrono::steady_clock::now()-plannerStarted).count(); + std::printf("[CavernPlannerV1] strate=%d seed=%d mouths=%s->%s route_nodes=%zu room_primitives=%zu edges=%zu branches=%zu route_horizontal_m=%.3f floor_delta_m=%.3f max_grade_deg=%.3f mouth_tangent_min_dot=%.3f grade_limit_deg=11.310 clearance_radius_m=%.3f clearance_height_m=%.3f destination_radius_m=%.3f plan_ms=%.3f status=PASS\n", strateIndex,seed,arrivalPassage->stableId.c_str(),departurePassage->stableId.c_str(), nodes.size(),rooms.size(),edges.size(),branchCount,totalRouteLength/1.0e6, - std::abs(dz)/1.0e6,maximumGradeDegrees,routeBoreRadius/1.0e6,PlannedBoreHeight/1.0e6, - settings.destinationRadiusMicrometres/1.0e6); + std::abs(dz)/1.0e6,maximumGradeDegrees,plannedMouthTangentAlignment, + routeBoreRadius/1.0e6,PlannedBoreHeight/1.0e6, + settings.destinationRadiusMicrometres/1.0e6,plannerMilliseconds); return true; } @@ -2936,6 +3481,8 @@ bool PlanResolvedLayoutPassagesV1(ResolvedLayoutSnapshotV3& layout,std::int32_t /layout.coreVoxelSizeMicrometres)); if(qPerMetre<=0){error="passage planner could not resolve fixed-point metres";return false;} const std::int64_t playerCenterAboveFloorQ=static_cast(std::llround(1.13*qPerMetre)); + const std::int64_t surfacePlayerCenterAboveFloorQ=static_cast( + std::llround(0.98*qPerMetre)); const std::array,16> directions{{ {{65536,0}},{{60547,25080}},{{46341,46341}},{{25080,60547}}, {{0,65536}},{{-25080,60547}},{{-46341,46341}},{{-60547,25080}}, @@ -3047,6 +3594,8 @@ bool PlanResolvedLayoutPassagesV1(ResolvedLayoutSnapshotV3& layout,std::int32_t const bool hasUpperRoute=connection==0&&routeAnchor(upper,true,upperMouth); const bool hasLowerRoute=connection==0&&routeAnchor(lower,false,lowerMouth); const bool surfaceGroundDeparture=hasUpperRoute&&upperIsSurfaceWorld; + const std::int64_t upperPlayerCenterAboveFloorQ=surfaceGroundDeparture + ?surfacePlayerCenterAboveFloorQ:playerCenterAboveFloorQ; const ResolvedLayoutAnchorV2* passageAnchor=nullptr; if(connection==0) for(const auto& anchor:upper.anchors) @@ -3099,7 +3648,11 @@ bool PlanResolvedLayoutPassagesV1(ResolvedLayoutSnapshotV3& layout,std::int32_t const bool hasPassageArrival=std::any_of(lower.anchors.begin(),lower.anchors.end(), [](const auto& anchor){return anchor.kind=="passage_arrival" &&anchor.values.size()>=3;}); - const bool hasLowerTangent=hasLowerRoute&&!hasPassageArrival + // The native cavern graph starts at passage_arrival, but its regenerated + // route points still define the receiving tangent and floor grade. Other + // generators keep the prior landing behavior when they carry this marker. + const bool hasLowerTangent=hasLowerRoute + &&(!hasPassageArrival||IsNativeCaveFieldStratum(lower)) &&routeTangent(lower,false,lowerTangent); const bool profiledSurfaceJoin=hasLowerTangent &&std::any_of(lower.anchors.begin(),lower.anchors.end(), @@ -3109,6 +3662,8 @@ bool PlanResolvedLayoutPassagesV1(ResolvedLayoutSnapshotV3& layout,std::int32_t const std::int64_t outerRingRadius=std::max(1, placementRadius-mouthRadius-qPerMetre/2); const std::uint32_t pathStyle=anchoredSpiral?1u:style; + const bool generatedWormEntry=!hasUpperRoute&&!hasUpperTangent + &&!anchoredSpiral&&pathStyle==1u; std::int64_t ringRadius=outerRingRadius; if(pathStyle==1u) ringRadius=std::min(outerRingRadius, @@ -3122,6 +3677,8 @@ bool PlanResolvedLayoutPassagesV1(ResolvedLayoutSnapshotV3& layout,std::int32_t std::max(0,cfg?cfg->cascadeLedgeSubvoxels:0) *static_cast(cfg?cfg->cascadeSteps:4)), mouthRadius+2*qPerMetre,outerRingRadius); + if(generatedWormEntry) + ringRadius=outerRingRadius; if(surfaceGroundDeparture) { const double departureRadius=std::hypot(static_cast(upperMouth[0]), @@ -3148,8 +3705,6 @@ bool PlanResolvedLayoutPassagesV1(ResolvedLayoutSnapshotV3& layout,std::int32_t // Authored tangents and island anchors already determine the phase. // A generated mouth in a native cave uses its radial approach; an // island landing uses the tangent fit below to ease off the crater rim. - const bool generatedIslandWormEntry=!hasUpperRoute&&!hasUpperTangent - &&!anchoredSpiral&&upper.generatorKind=="FloatingIslands"; if(hasUpperRoute||(anchoredIslandPassage&&!anchoredSpiral) ||(!hasUpperRoute&&!anchoredIslandPassage&&!anchoredSpiral &&upper.generatorKind!="FloatingIslands")) @@ -3273,11 +3828,15 @@ bool PlanResolvedLayoutPassagesV1(ResolvedLayoutSnapshotV3& layout,std::int32_t centerX=static_cast(passageAnchor->values[0]); centerY=static_cast(passageAnchor->values[1]); } + const double placementBudget=std::max(0.0, + static_cast(placementRadius) + -std::hypot(centerX,centerY)-2.0); + radius=std::clamp(radius,0.0,placementBudget); return std::array{ static_cast(std::llround(centerX+radius*directions[directionIndex][0]/65536.0)), static_cast(std::llround(centerY+radius*directions[directionIndex][1]/65536.0))}; }; - if(generatedIslandWormEntry) + if(generatedWormEntry) { struct FPhaseScore { @@ -3402,7 +3961,7 @@ bool PlanResolvedLayoutPassagesV1(ResolvedLayoutSnapshotV3& layout,std::int32_t const double tangentAlignment=(entryX*tangentX+entryY*tangentY) /(entryLength*tangentLength); const std::int64_t firstAxisZ=upperMouth[2]+mouthRadius - -playerCenterAboveFloorQ; + -upperPlayerCenterAboveFloorQ; const std::int64_t secondAxisZ=firstAxisZ+PassageRoundRatio( lowerMouth[2]-upperMouth[2],static_cast(entryDistance), static_cast(routeLength)); @@ -3515,20 +4074,33 @@ bool PlanResolvedLayoutPassagesV1(ResolvedLayoutSnapshotV3& layout,std::int32_t double bestClearance=-std::numeric_limits::infinity(); for(std::size_t index=0;indexrouteLength; - const bool stableLengthTie=selected - &&candidate.routeLength==selected->routeLength - &&(candidate.clearance>selected->clearance+1.0e-6 - ||(std::abs(candidate.clearance-selected->clearance)<=1.0e-6 - &&candidate.tangent>selected->tangent)); - if(shorter||stableLengthTie)selected=&candidate; + if(candidate.clearance+ClearanceToleranceMetresselected->tangent+1.0e-6; + const bool tangentTie=selected + &&std::abs(candidate.tangent-selected->tangent)<=1.0e-6; + const bool stableTangentTie=tangentTie&&( + candidate.routeLengthrouteLength + ||(candidate.routeLength==selected->routeLength + &&candidate.clearance>selected->clearance+1.0e-6)); + if(smoother||stableTangentTie)selected=&candidate; } + if(!selected) + for(std::size_t index=0;indexselected->clearance + ||(candidate.clearance==selected->clearance + &&candidate.routeLengthrouteLength)) + selected=&candidate; + } if(selected) { phase=selected->phase; @@ -3644,7 +4216,66 @@ bool PlanResolvedLayoutPassagesV1(ResolvedLayoutSnapshotV3& layout,std::int32_t pushXY(point); } } - pushXY(firstRing); + if(generatedWormEntry) + { + // Join the portal lead to the winding route with a circular arc whose + // radius remains wider than the bore. A short quadratic corner still + // makes the passage sweep swell on the inside of this turn. + const auto nextRing=ringPoint(static_cast( + (static_cast(phase)+direction+16)%16)); + const double incomingX=static_cast(firstRing[0]-upperMouth[0]); + const double incomingY=static_cast(firstRing[1]-upperMouth[1]); + const double outgoingX=static_cast(nextRing[0]-firstRing[0]); + const double outgoingY=static_cast(nextRing[1]-firstRing[1]); + const double incomingLength=std::hypot(incomingX,incomingY); + const double outgoingLength=std::hypot(outgoingX,outgoingY); + const double turnCross=incomingX*outgoingY-incomingY*outgoingX; + const double turnDot=incomingX*outgoingX+incomingY*outgoingY; + const double turnAngle=std::atan2(turnCross,turnDot); + const double halfTangent=std::tan(std::abs(turnAngle)*0.5); + if(incomingLength>1.0e-6&&outgoingLength>1.0e-6 + &&std::abs(halfTangent)>1.0e-6) + { + const double maximumCut=0.80*std::min(incomingLength,outgoingLength); + const double requestedRadius=std::max(mouthRadius,midRadius) + +0.75*qPerMetre; + const double filletRadius=std::min(static_cast(requestedRadius), + maximumCut/std::abs(halfTangent)); + const double cut=filletRadius*std::abs(halfTangent); + const double incomingUnitX=incomingX/incomingLength; + const double incomingUnitY=incomingY/incomingLength; + const double outgoingUnitX=outgoingX/outgoingLength; + const double outgoingUnitY=outgoingY/outgoingLength; + const std::array start={ + firstRing[0]-incomingUnitX*cut, + firstRing[1]-incomingUnitY*cut}; + const std::array end={ + firstRing[0]+outgoingUnitX*cut, + firstRing[1]+outgoingUnitY*cut}; + const double turnSign=turnCross>=0.0?1.0:-1.0; + const std::array center={ + start[0]-turnSign*incomingUnitY*filletRadius, + start[1]+turnSign*incomingUnitX*filletRadius}; + const double startAngle=std::atan2(start[1]-center[1], + start[0]-center[0]); + const int arcSegments=std::max(1,static_cast(std::ceil( + filletRadius*std::abs(turnAngle)/(0.75*qPerMetre)))); + pushXY({static_cast(std::llround(start[0])), + static_cast(std::llround(start[1]))}); + for(int sample=1;sample<=arcSegments;++sample) + { + const double t=static_cast(sample)/arcSegments; + const double angle=startAngle+turnAngle*t; + const std::array point=sample==arcSegments?end: + std::array{center[0]+filletRadius*std::cos(angle), + center[1]+filletRadius*std::sin(angle)}; + pushXY({static_cast(std::llround(point[0])), + static_cast(std::llround(point[1]))}); + } + } + else pushXY(firstRing); + } + else pushXY(firstRing); std::uint64_t totalHorizontal=0;std::uint32_t loops=0; const std::int64_t verticalDistance=hasAnchorHelix ?std::abs(lowerMouth[2]-helixBedrockZ):std::abs(lowerMouth[2]-upperMouth[2]); @@ -3855,7 +4486,7 @@ bool PlanResolvedLayoutPassagesV1(ResolvedLayoutSnapshotV3& layout,std::int32_t std::max(8*qPerMetre,2*mouthRadius)); for(std::size_t i=0;ilowerMouthIndex&&i+1>=xy.size()-1))?mouthRadius:midRadius; if(surfaceGroundDeparture&&i(std::llround( (midRadius-surfacePortalRadius)*smooth)); } + else if(i( + std::max(UpperMouthRadiusBlendLengthMetres*qPerMetre, + 2*mouthRadius)); + if(cumulative[i](cumulative[i])/blendLength; + const double smooth=t*t*(3.0-2.0*t); + radius=mouthRadius+static_cast(std::llround( + (midRadius-mouthRadius)*smooth)); + } + } // Grow the lower bore into its mouth over fixed route distance. Assigning // the full mouth radius to the penultimate point creates a segment-length // flare whose cubic-detrended profile can read as a short-period wall beat. @@ -3900,12 +4545,23 @@ bool PlanResolvedLayoutPassagesV1(ResolvedLayoutSnapshotV3& layout,std::int32_t static_cast(segmentLength)); } ResolvedLayoutPassagePointV1 point; - point.positionSubvoxels={xy[i][0],xy[i][1],playerZ+radius-playerCenterAboveFloorQ}; + std::int64_t pointPlayerCenterAboveFloorQ=playerCenterAboveFloorQ; + if(surfaceGroundDeparture) + { + const double t=std::clamp(static_cast(cumulative[i]) + /surfaceMouthBlendLength,0.0,1.0); + const double smooth=t*t*(3.0-2.0*t); + pointPlayerCenterAboveFloorQ=upperPlayerCenterAboveFloorQ + +static_cast(std::llround( + (playerCenterAboveFloorQ-upperPlayerCenterAboveFloorQ)*smooth)); + } + point.positionSubvoxels={xy[i][0],xy[i][1], + playerZ+radius-pointPlayerCenterAboveFloorQ}; point.radiusSubvoxels=radius;passage.controlPoints.push_back(point); } if(surfaceGroundDeparture) std::printf("[ResolvedSurfaceWorldDepartureMouth] passage=%s ground_floor_z_m=%.3f portal_radius_m=%.3f tunnel_radius_m=%.3f blend_length_m=%.3f departure_xy_m=(%.3f,%.3f) first_tunnel_xy_m=(%.3f,%.3f) first_tunnel_floor_z_m=%.3f first_tunnel_radius_m=%.3f\n", - passage.stableId.c_str(),(upperMouth[2]-static_cast(std::llround(0.88*qPerMetre)))/static_cast(qPerMetre), + passage.stableId.c_str(),(upperMouth[2]-upperPlayerCenterAboveFloorQ)/static_cast(qPerMetre), surfacePortalRadius/static_cast(qPerMetre), midRadius/static_cast(qPerMetre), surfaceMouthBlendLength/static_cast(qPerMetre), @@ -3918,12 +4574,12 @@ bool PlanResolvedLayoutPassagesV1(ResolvedLayoutSnapshotV3& layout,std::int32_t passage.controlPoints.size()>1? (passage.controlPoints[1].positionSubvoxels[2] -passage.controlPoints[1].radiusSubvoxels+playerCenterAboveFloorQ - -static_cast(std::llround(0.88*qPerMetre))) + -static_cast(std::llround(1.13*qPerMetre))) /static_cast(qPerMetre):0.0, passage.controlPoints.size()>1?passage.controlPoints[1].radiusSubvoxels /static_cast(qPerMetre):0.0); passage.controlPoints.front().positionSubvoxels[2]=upperMouth[2] - +passage.controlPoints.front().radiusSubvoxels-playerCenterAboveFloorQ; + +passage.controlPoints.front().radiusSubvoxels-upperPlayerCenterAboveFloorQ; passage.controlPoints[lowerMouthIndex].positionSubvoxels[2]=lowerMouth[2] +passage.controlPoints[lowerMouthIndex].radiusSubvoxels -playerCenterAboveFloorQ; diff --git a/Standalone/Core/src/ShapeWorldInternal.h b/Standalone/Core/src/ShapeWorldInternal.h index 49ee4c2..1d361e5 100644 --- a/Standalone/Core/src/ShapeWorldInternal.h +++ b/Standalone/Core/src/ShapeWorldInternal.h @@ -255,6 +255,7 @@ public: { double signedDistance=std::numeric_limits::infinity(); double shellSignedDistance=std::numeric_limits::infinity(); + double floorShellSignedDistance=std::numeric_limits::infinity(); double roundSignedDistance=std::numeric_limits::infinity(); double centerZSubvoxels=0.0,floorCenterZSubvoxels=0.0; double radiusSubvoxels=0.0,floorDepthSubvoxels=0.0; @@ -308,6 +309,14 @@ public: stampIndices_=shape.BuildStampTileList(minimumSubvoxels_,maximumSubvoxels_); cavernFeatureIndices_=shape.BuildCavernFeatureTileList( minimumSubvoxels_,maximumSubvoxels_); + if(profileStats_) + for(const std::uint32_t index:cavernFeatureIndices_) + { + if(index>=shape.CavernRuntime.size())continue; + const auto& feature=shape.CavernRuntime[index]; + if(feature.room)++profileStats_->cavernRoomCandidates; + else if(feature.edge)++profileStats_->cavernEdgeCandidates; + } passageRoutes_.assign(shape.ResolvedLayout ?shape.ResolvedLayout->passages.size():0,0); passageRouteSegments_.resize(passageRoutes_.size()); @@ -1206,11 +1215,15 @@ public: auto* passageProfile=passageTileScopeMatches ?passageTileQueries->profileStats():nullptr; if(passageProfile)++passageProfile->passageComposerQueries; + if(passageProfile&&nativeTunnelFieldDensity + !=std::numeric_limits::lowest()) + ++passageProfile->nativeCaveFieldSamples; struct FNearestTubeSample { double signedDistance=std::numeric_limits::infinity(); double shellSignedDistance=std::numeric_limits::infinity(); + double floorShellSignedDistance=std::numeric_limits::infinity(); double roundSignedDistance=std::numeric_limits::infinity(); double centerZ=0.0,floorCenterZ=0.0,radius=0.0,floorDepth=0.0; double t=0.0,floorT=0.0; @@ -1220,13 +1233,32 @@ public: }; double unionDistance=std::numeric_limits::infinity(); double unionShellDistance=std::numeric_limits::infinity(); + double unionFloorShellDistance=std::numeric_limits::infinity(); + double plannedTunnelPassageShellDistance=std::numeric_limits::infinity(); const std::uint32_t reliefSeed=static_cast(Owner?Owner->Seed:0); const auto* queryStratum=FindBoundStratumForManagerZ(Z); + const bool plannedTunnelStratum=queryStratum&&queryStratum->Resolved + &&queryStratum->Resolved->generatorKind=="TunnelNetwork" + &&queryStratum->Resolved->recipeId=="Core.Caves.PrimitiveField" + &&queryStratum->Resolved->recipeVersion==1; for(std::size_t passageIndex=0;passageIndexpassages.size(); ++passageIndex) { const auto& passage=ResolvedLayout->passages[passageIndex]; if(passage.controlPoints.size()<2)continue; + const bool surfaceDepartureLanding=queryStratum&&queryStratum->Resolved + &&queryStratum->Resolved->lowerInterfaceCapabilities.surfaceToTunnelLandingSupport + &&passage.upperStrateIndex==queryStratum->StrateIndex; + const auto floorThicknessWithLanding=[&](double floorThickness, + double smoothFloorBlend,double alongMetres) + { + if(!surfaceDepartureLanding)return floorThickness; + const double fade=std::clamp(1.0-alongMetres/8.0,0.0,1.0); + const double smoothFade=fade*fade*(3.0-2.0*fade); + const double landingThickness=0.50*qPerMetre + *smoothFloorBlend*smoothFade; + return std::max(floorThickness,landingThickness); + }; const bool passageTargetsNativeCaveField=queryStratum&&queryStratum->Resolved &&queryStratum->Resolved->passageCapabilities.hasNativeCaveField &&passage.lowerStrateIndex==queryStratum->StrateIndex; @@ -1235,6 +1267,8 @@ public: &&queryStratum->Resolved->generatorKind=="TunnelNetwork" &&queryStratum->Resolved->recipeId=="Core.Caves.PrimitiveField" &&queryStratum->Resolved->recipeVersion==1; + const double mouthShellBlendMetres=passageTargetsPlannedTunnelNetwork + ?2.0:MouthShellBlendMetres; // Legacy native cave fields own the floor and mouth carve on their // lower-strate boundary. The planner's TunnelNetwork field instead // needs the shared passage composer to carry its incoming mouth open. @@ -1390,7 +1424,7 @@ public: const double mouthDistanceMetres=std::max(0.0,std::min(alongMetres, constants.totalPathLengthMetres-alongMetres)); const double shellFade=std::clamp(mouthDistanceMetres/ - MouthShellBlendMetres,0.0,1.0); + mouthShellBlendMetres,0.0,1.0); const double smoothShellFade=shellFade*shellFade *(3.0-2.0*shellFade); const double floorDistanceMetres=(floorCenterZ-floorDepth-qz) @@ -1408,12 +1442,16 @@ public: const double pastMouthMetres=std::max(0.0,alongMetres -constants.totalPathLengthMetres); const double extensionFade=std::clamp(1.0 - -pastMouthMetres/MouthShellBlendMetres,0.0,1.0); + -pastMouthMetres/mouthShellBlendMetres,0.0,1.0); floorExtensionFade=extensionFade*extensionFade *(3.0-2.0*extensionFade); } - const double floorShellThickness=smoothFloorBlend*shellThickness - *floorExtensionFade*smoothShellFade; + const double floorShellThickness=floorThicknessWithLanding( + smoothFloorBlend*(shellThickness + +(passageTargetsPlannedTunnelNetwork?0.25*qPerMetre:0.0)) + *floorExtensionFade + *smoothShellFade, + smoothFloorBlend,alongMetres); const double wallShellDistance=PassageRoundedWallShellDistance( flatDistance,wallElevationMetres*qPerMetre, OpenAirSideWallHeightMetres*qPerMetre,mouthWallThickness); @@ -1421,8 +1459,14 @@ public: static_cast(flatDistance)-floorShellThickness); const double localShellDistance=std::min(wallShellDistance, floorShellDistance); + if(passageTargetsPlannedTunnelNetwork + &&mouthDistanceMetres>=mouthShellBlendMetres) + plannedTunnelPassageShellDistance=std::min( + plannedTunnelPassageShellDistance,localShellDistance); nearest.shellSignedDistance=std::min(nearest.shellSignedDistance, localShellDistance); + nearest.floorShellSignedDistance=std::min( + nearest.floorShellSignedDistance,floorShellDistance); }; double shellFloorSearchBoundSq=-1.0; VisitNearestPassageSegmentRange(passageIndex,0,lastIndex-1, @@ -1439,6 +1483,14 @@ public: const double crestFade=std::clamp((OpenAirSideWallHeightMetres -wallElevationMetres)/0.25,0.0,1.0); const double smoothCrestFade=crestFade*crestFade*(3.0-2.0*crestFade); + const auto& segment=PassageSegmentData[passageIndex][nearest.segmentIndex]; + const double alongMetres=segment.authoredAlongMetres + +nearest.t*segment.authoredLengthMetres; + const double mouthDistanceMetres=std::max(0.0,std::min(alongMetres, + constants.totalPathLengthMetres-alongMetres)); + const double shellFade=std::clamp(mouthDistanceMetres/ + mouthShellBlendMetres,0.0,1.0); + const double smoothShellFade=shellFade*shellFade*(3.0-2.0*shellFade); const double wallReliefWeight=(0.05+0.95*(1.0-verticalWallAlignment)) *smoothCrestFade; float relief=0.0f; @@ -1457,15 +1509,7 @@ public: const double shapedDistance=PassageFlatBottomDistance( static_cast(roughRoundDistance),nearest.floorCenterZ,qz, nearest.floorDepth,qPerMetre); - const auto& segment=PassageSegmentData[passageIndex][nearest.segmentIndex]; const auto& floorSegment=PassageSegmentData[passageIndex][nearest.floorSegmentIndex]; - const double alongMetres=segment.authoredAlongMetres - +nearest.t*segment.authoredLengthMetres; - const double mouthDistanceMetres=std::max(0.0,std::min(alongMetres, - constants.totalPathLengthMetres-alongMetres)); - const double shellFade=std::clamp(mouthDistanceMetres/ - MouthShellBlendMetres,0.0,1.0); - const double smoothShellFade=shellFade*shellFade*(3.0-2.0*shellFade); // Ease both wall and floor shell into the host ground over five metres. // The floor shell remains below the floor plane throughout that blend. const double floorDistanceMetres=(nearest.floorCenterZ @@ -1483,18 +1527,26 @@ public: const double pastMouthMetres=std::max(0.0,floorAlongMetres -constants.totalPathLengthMetres); const double extensionFade=std::clamp(1.0 - -pastMouthMetres/MouthShellBlendMetres,0.0,1.0); + -pastMouthMetres/mouthShellBlendMetres,0.0,1.0); floorExtensionFade=extensionFade*extensionFade *(3.0-2.0*extensionFade); } - const double floorShellThickness=smoothFloorBlend*shellThickness - *floorExtensionFade*smoothShellFade; + const double floorShellThickness=floorThicknessWithLanding( + smoothFloorBlend*(shellThickness + +(passageTargetsPlannedTunnelNetwork?0.25*qPerMetre:0.0)) + *floorExtensionFade + *smoothShellFade, + smoothFloorBlend,alongMetres); const double wallShellDistance=PassageRoundedWallShellDistance( shapedDistance,wallElevationMetres*qPerMetre, OpenAirSideWallHeightMetres*qPerMetre,mouthWallThickness); const double floorShellDistance=std::max(-static_cast(shapedDistance), static_cast(shapedDistance)-floorShellThickness); + nearest.floorShellSignedDistance=std::min( + nearest.floorShellSignedDistance,floorShellDistance); unionDistance=std::min(unionDistance,static_cast(shapedDistance)); + unionFloorShellDistance=std::min(unionFloorShellDistance, + nearest.floorShellSignedDistance); const double localShellDistance=std::min(wallShellDistance, floorShellDistance); unionShellDistance=std::min(unionShellDistance, @@ -1503,12 +1555,131 @@ public: if(!std::isfinite(unionDistance))return density; const float passageAirDistance=static_cast(-unionDistance/Q); + const auto plannedPortalWalkCorridorContains=[&]() + { + if(!queryStratum||!queryStratum->Resolved + ||queryStratum->Resolved->generatorKind!="TunnelNetwork" + ||queryStratum->Resolved->recipeId!="Core.Caves.PrimitiveField" + ||queryStratum->Resolved->recipeVersion!=1)return false; + const VoxelForgeCore::ResolvedLayoutAnchorV2* first=nullptr; + const VoxelForgeCore::ResolvedLayoutAnchorV2* second=nullptr; + const VoxelForgeCore::ResolvedLayoutAnchorV2* third=nullptr; + const VoxelForgeCore::ResolvedLayoutAnchorV2* lastThree=nullptr; + const VoxelForgeCore::ResolvedLayoutAnchorV2* lastTwo=nullptr; + const VoxelForgeCore::ResolvedLayoutAnchorV2* last=nullptr; + std::size_t routeCount=0; + for(const auto& anchor:queryStratum->Resolved->anchors) + { + if(anchor.kind!="route_point"||anchor.values.size()<3)continue; + if(routeCount==0)first=&anchor; + else if(routeCount==1)second=&anchor; + else if(routeCount==2)third=&anchor; + lastThree=lastTwo;lastTwo=last;last=&anchor; + ++routeCount; + } + if(routeCount<6||!first||!second||!third||!lastThree||!lastTwo||!last + ||!first->stableId.ends_with("000-arrival") + ||!second->stableId.ends_with("000-edge-start") + ||!lastTwo->stableId.ends_with("998-departure-inner") + ||!last->stableId.ends_with("999-departure"))return false; + const double metresPerSubvoxel=1.0/qPerMetre; + const auto position=[&](const VoxelForgeCore::ResolvedLayoutAnchorV2& anchor) + { + return std::array{anchor.values[0]*metresPerSubvoxel, + anchor.values[1]*metresPerSubvoxel, + anchor.values[2]*metresPerSubvoxel}; + }; + const double queryX=X*Q/qPerMetre; + const double queryY=Y*Q/qPerMetre; + const double queryZ=Z*Q/qPerMetre; + const auto nearRouteSegment=[&](const VoxelForgeCore::ResolvedLayoutAnchorV2& start, + const VoxelForgeCore::ResolvedLayoutAnchorV2& end,double limit,bool fromDeparture, + double distanceToDepartureAfterEnd) + { + const auto a=position(start),b=position(end); + const double dx=b[0]-a[0],dy=b[1]-a[1]; + const double horizontalLength=std::hypot(dx,dy); + if(horizontalLength<=1.0e-9)return false; + const double t=std::clamp(((queryX-a[0])*dx + +(queryY-a[1])*dy)/(horizontalLength*horizontalLength),0.0,1.0); + const double along=fromDeparture + ?distanceToDepartureAfterEnd+(1.0-t)*horizontalLength + :distanceToDepartureAfterEnd+t*horizontalLength; + if(along>limit)return false; + const double routeX=a[0]+t*dx,routeY=a[1]+t*dy; + if(std::hypot(queryX-routeX,queryY-routeY)>0.80)return false; + const double routeFloor=std::lerp(a[2],b[2],t)-1.13; + return queryZ>=routeFloor-0.30&&queryZ<=routeFloor+2.25; + }; + constexpr double PortalWalkLeadMetres=8.0; + const auto arrival=position(*first),arrivalInner=position(*second); + const double arrivalInnerDistance=std::hypot( + arrivalInner[0]-arrival[0],arrivalInner[1]-arrival[1]); + if(nearRouteSegment(*first,*second,PortalWalkLeadMetres,false,0.0) + ||nearRouteSegment(*second,*third,PortalWalkLeadMetres,false, + arrivalInnerDistance))return true; + const auto departureInner=position(*lastTwo),departure=position(*last); + const double departureTailDistance=std::hypot( + departure[0]-departureInner[0],departure[1]-departureInner[1]); + return nearRouteSegment(*lastThree,*lastTwo,PortalWalkLeadMetres,true, + departureTailDistance) + ||nearRouteSegment(*lastTwo,*last,PortalWalkLeadMetres,true,0.0); + }; + if(plannedTunnelStratum + &&nativeTunnelFieldDensity!=std::numeric_limits::lowest() + &&nativeTunnelFieldDensity<0.0f) + { + const double metresPerVoxel=Q/qPerMetre; + const double queryZMetres=Z*metresPerVoxel; + const double contentBottomMetres=static_cast( + queryStratum->Resolved->content.bottomMicrometres)/1000000.0; + const double contentTopMetres=static_cast( + queryStratum->Resolved->content.topMicrometres)/1000000.0; + const double boundaryDistance=std::min( + std::abs(queryZMetres-contentBottomMetres), + std::abs(queryZMetres-contentTopMetres)); + const double boundaryJoinBand=std::max(2.0, + static_cast(queryStratum->Resolved->transitionWidthMicrometres) + /2000000.0); + if(boundaryDistance<=boundaryJoinBand + &&std::abs(static_cast(passageAirDistance)*metresPerVoxel) + <=FloorShellThicknessMetres) + // A neighboring stratum can be open at a boundary sample even + // though the native graph owns solid host rock there. Keep the + // authored passage signed distance through that transition band; + // otherwise the neighboring field can widen its wall by a whole + // shell thickness. Native graph air still wins above via the + // positive nativeTunnelFieldDensity path. + return passageAirDistance; + } // Air is the union of the native graph and passage voids. Keeping the // signed passage distance outside the void as well gives marching cubes // a real negative distance through the floor instead of the terrain's // saturated deep-rock value. + if(nativeTunnelFieldDensity!=std::numeric_limits::lowest() + &&plannedTunnelPassageShellDistance<0.0&&unionDistance>0.0) + { + if(nativeTunnelFieldDensity>=0.0f&&plannedPortalWalkCorridorContains()) + return std::max(nativeTunnelFieldDensity,passageAirDistance); + return std::min(density, + static_cast(plannedTunnelPassageShellDistance/Q)); + } if(nativeTunnelFieldDensity>=0.0f) + { + // The planner's passage shell remains authoritative through native + // cave voids after the explicit mouth fade has ended. Keep this scoped + // to the planner-crossing passage shell so nearby portal joints stay open. + if(plannedTunnelPassageShellDistance<0.0&&unionDistance>0.0) + return std::min(nativeTunnelFieldDensity, + static_cast(plannedTunnelPassageShellDistance/Q)); + constexpr float NativeCaveAirMarginVoxels=0.75f; + if(unionDistance>0.0 + &&nativeTunnelFieldDensity(unionFloorShellDistance/Q)); return std::max(nativeTunnelFieldDensity,passageAirDistance); + } if(nativeFloorSupport!=std::numeric_limits::lowest() &&nativeFloorSupport<0.0f) return std::min(nativeFloorSupport,unionDistance<=0.0 @@ -2274,6 +2445,10 @@ public: SurfaceDistanceQ=std::make_unique_for_overwrite(static_cast(NodeCount)); Substage=Clock::now(); BuildPackedRockIntervals(Stats.RockIntervalLines); + const auto criticalAirInitialStart=Clock::now(); + const uint64 criticalAirVoxelsBeforeDetail=ApplyCriticalAirPackedSigns(); + const double criticalAirInitialSeconds= + std::chrono::duration(Clock::now()-criticalAirInitialStart).count(); Stats.PackedSignBuildSeconds=std::chrono::duration(Clock::now()-Substage).count(); // Native density queries for non-heightfield strates use the exact interval // evaluator. Build those native intervals before preparing the tunnel sweeps. @@ -2296,8 +2471,16 @@ public: ApplyDetailBand(Stats.DetailBandLines); Stats.DetailApplySeconds=std::chrono::duration(Clock::now()-Substage).count(); Substage=Clock::now(); + const uint64 criticalAirVoxelsAfterDetail=ApplyCriticalAirPackedSigns(); + const double criticalAirRepairSeconds= + std::chrono::duration(Clock::now()-Substage).count(); + Substage=Clock::now(); Stats.SurfaceProxyNodes+=CompleteSignEdgeDistances(); Stats.SignEdgeDistanceSeconds=std::chrono::duration(Clock::now()-Substage).count(); + Substage=Clock::now(); + const uint64 criticalAirVoxelsAfterSignRepair=ApplyCriticalAirPackedSigns(); + const double criticalAirSignEdgeRepairSeconds= + std::chrono::duration(Clock::now()-Substage).count(); Stats.SurfaceSeconds=std::chrono::duration(Clock::now()-Stage).count(); // Route validation is world-wide. Detail regions reuse the same authored route data and // do not need to repeat the 5,642-point world audit for every entering tile group. @@ -2329,8 +2512,13 @@ public: Stats.CandidateMergeLines.WallSeconds*1000.0,Stats.CandidateMergeLines.WorkerSeconds*1000.0, Stats.CandidateMergeSeconds*1000.0,Stats.DetailBandLines.WallSeconds*1000.0, Stats.DetailBandLines.WorkerSeconds*1000.0); - std::printf("[ShapeSubstage] detail_apply=%.3fms sign_edge_distances=%.3fms passage_validation=%.3fms shared_field_primitives=%.3fms far_parents=%.3fms\n", - Stats.DetailApplySeconds*1000.0,Stats.SignEdgeDistanceSeconds*1000.0, + std::printf("[ShapeSubstage] detail_apply=%.3fms critical_air_sign_repair=%.3fms critical_air_voxels=%llu/%llu/%llu sign_edge_distances=%.3fms passage_validation=%.3fms shared_field_primitives=%.3fms far_parents=%.3fms\n", + Stats.DetailApplySeconds*1000.0,criticalAirInitialSeconds*1000.0 + +criticalAirRepairSeconds*1000.0+criticalAirSignEdgeRepairSeconds*1000.0, + static_cast(criticalAirVoxelsBeforeDetail), + static_cast(criticalAirVoxelsAfterDetail), + static_cast(criticalAirVoxelsAfterSignRepair), + Stats.SignEdgeDistanceSeconds*1000.0, Stats.PassageValidationSeconds*1000.0,Stats.SharedFieldSeconds*1000.0, Stats.FarShapeSeconds*1000.0); std::printf("[ShapeBreakdown] owner_shapes=%.3fms owner_shape_worker=%.3fms owner_bins=%.3fms packed_signs=%.3fms passage_prep=%.3fms candidate_sweeps=%.3fms visual_detail_prep=%.3fms detail_band=%.3fms sign_edge_repair=%.3fms route_validation=%.3fms far_parent_shapes=%.3fms far_shared_primitives=%.3fms far_bins=%.3fms\n", @@ -3402,6 +3590,7 @@ private: { double floorDepthMetres=0.0,totalPathLengthMetres=0.0; double authoredTotalPathLengthMetres=0.0; + double upperMouthPlayerCenterHeightMetres=1.13; std::size_t routeProfileSegmentCount=0; double upperMouthX=0.0,upperMouthY=0.0; double upperMouthPlannedFloor=0.0; @@ -3475,7 +3664,8 @@ private: plannedFloor,constants.upperMouthX,constants.upperMouthY, constants.upperMouthPlannedFloor, constants.upperMouthTangentX,constants.upperMouthTangentY, - VoxelForgeCore::PassageSpline::UpperMouthFloorBlendRadiusMetres,1.13, + VoxelForgeCore::PassageSpline::UpperMouthFloorBlendRadiusMetres, + constants.upperMouthPlayerCenterHeightMetres, qPerMetre); const double lowerBlendFloor=VoxelForgeCore::PassageSpline::BlendUpperMouthFloorAtXY( constants.lowerMouthReceivingRoute,queryXSubvoxels,queryYSubvoxels, @@ -3594,7 +3784,7 @@ private: }; struct FSegmentNode { - std::array minimum{},maximum{}; + std::array minimum{},maximum{},minimumNormalized{},maximumNormalized{}; std::size_t first=0,end=0; int left=-1,right=-1; }; @@ -4174,6 +4364,16 @@ private: return nodeIndex; }; buildSegmentNode(buildSegmentNode,0,runtime.segmentOrder.size()); + const double radius=edge.boreRadiusMicrometres/1000000.0; + const double height=edge.boreHeightMicrometres/1000000.0; + if(radius>0.0&&height>0.0) + for(auto& node:runtime.segmentNodes) + for(std::size_t axis=0;axis<3;++axis) + { + const double scale=axis==2?height:radius; + node.minimumNormalized[axis]=node.minimum[axis]/scale; + node.maximumNormalized[axis]=node.maximum[axis]/scale; + } } CavernRuntime.push_back(runtime); } @@ -4623,6 +4823,12 @@ private: passage.lowerMouthControlIndex,1,passage.controlPoints.size()-1); auto& constants=PassageComposerConstants[passageIndex]; constants.routeEndControlIndex=routeEnd; + const auto upperStratum=std::find_if(ResolvedLayout->strates.begin(), + ResolvedLayout->strates.end(),[&](const auto& stratum) + {return stratum.strateIndex==passage.upperStrateIndex;}); + if(upperStratum!=ResolvedLayout->strates.end() + &&upperStratum->generatorKind=="SurfaceWorld") + constants.upperMouthPlayerCenterHeightMetres=0.98; using FRawPassageSegment=VoxelForgeCore::PassageSpline::FSegment; std::vector controls; @@ -4640,11 +4846,9 @@ private: } const double authoredUpperMouthFloorDepth=std::max(0.0, static_cast(passage.controlPoints.front().positionSubvoxels[2] - -passage.upperMouthSubvoxels[2])/qPerMetre+1.13); + -passage.upperMouthSubvoxels[2])/qPerMetre + +constants.upperMouthPlayerCenterHeightMetres); double upperMouthFloorDepth=authoredUpperMouthFloorDepth; - const auto upperStratum=std::find_if(ResolvedLayout->strates.begin(), - ResolvedLayout->strates.end(),[&](const auto& stratum) - {return stratum.strateIndex==passage.upperStrateIndex;}); if(upperStratum!=ResolvedLayout->strates.end() &&(upperStratum->generatorKind=="FloatingIslands" ||upperStratum->generatorKind=="TunnelNetwork")) @@ -4664,7 +4868,8 @@ private: &&distanceSquared<=std::pow(controls.front()[3]+2.0*qPerMetre,2.0)) { upperMouthFloorDepth=std::max(upperMouthFloorDepth, - std::max(0.0,(controls.front()[2]-routeHeight)/qPerMetre+1.13)); + std::max(0.0,(controls.front()[2]-routeHeight)/qPerMetre + +constants.upperMouthPlayerCenterHeightMetres)); if(upperStratum->generatorKind=="TunnelNetwork") VoxelForgeCore::PassageSpline::BlendEndpointFloorToRoute( controls,plannedFloorProfile,0,route,qPerMetre); @@ -4761,6 +4966,26 @@ private: constants.upperMouthTangentY=sampledSegments.front().b[1] -sampledSegments.front().a[1]; } + const std::string lowerMouthAnchor=passage.stableId+"/lower-mouth"; + const std::string upperMouthAnchor=passage.stableId+"/upper-mouth"; + for(const auto& edge:ResolvedLayout->cavernEdges) + { + if(edge.controlPointsMicrometres.size()<2)continue; + if(edge.startAnchorStableId==lowerMouthAnchor) + { + constants.lowerMouthTangentX=edge.controlPointsMicrometres[1][0] + -edge.controlPointsMicrometres[0][0]; + constants.lowerMouthTangentY=edge.controlPointsMicrometres[1][1] + -edge.controlPointsMicrometres[0][1]; + } + if(edge.startAnchorStableId==upperMouthAnchor) + { + constants.upperMouthTangentX=edge.controlPointsMicrometres[0][0] + -edge.controlPointsMicrometres[1][0]; + constants.upperMouthTangentY=edge.controlPointsMicrometres[0][1] + -edge.controlPointsMicrometres[1][1]; + } + } std::vector rawSegments=sampledSegments; std::vector> routeRadiusSamples; routeRadiusSamples.push_back({0.0,controls.front()[3]/qPerMetre}); @@ -7563,6 +7788,8 @@ private: double floorSupportDepth=0.0,floorSupportFloor=0.0,floorSupportGrade=0.0; const std::string* floorSupportEdgeId=nullptr; const std::string* floorSupportProfileId=nullptr; + double lowestOpenEdgeFloor=std::numeric_limits::infinity(); + const std::string* lowestOpenEdgeProfileId=nullptr; for(const std::uint32_t index:*candidates) { if(index>=CavernRuntime.size())continue; @@ -7570,7 +7797,26 @@ private: const std::int32_t strateIndex=feature.room?feature.room->strateIndex :feature.edge?feature.edge->strateIndex:-1; if(strateIndex!=bound.StrateIndex)continue; + // Tile culling keeps the candidate list small, but a tile can still + // contain millions of sample points outside a feature's authored + // bounds. Reject those points before evaluating room SDFs or walking + // an edge's segment BVH. Keep a 1 mm guard for float voxel-coordinate + // rounding; the serialized bounds already include all SDF, union, + // irregularity, bore, and floor-support reach. + constexpr double FeatureBoundsGuardMetres=0.001; + bool insideFeatureBounds=true; + for(std::size_t axis=0;axis<3;++axis) + { + const double minimum=feature.boundsMinimumMicrometres[axis]/1000000.0 + -FeatureBoundsGuardMetres; + const double maximum=feature.boundsMaximumMicrometres[axis]/1000000.0 + +FeatureBoundsGuardMetres; + if(point[axis]maximum) + {insideFeatureBounds=false;break;} + } + if(!insideFeatureBounds)continue; double featureVoid=std::numeric_limits::infinity(); + double edgeFloor=0.0; if(feature.room) { double normalizedSquared=0.0,meanRadius=0.0; @@ -7655,15 +7901,16 @@ private: ?(segment.end[2]-segment.start[2])/horizontal:0.0; } }; + const std::array normalizedPoint={ + point[0]/radius,point[1]/radius,point[2]/height}; const auto nodeLowerBound=[&](const FCavernRuntimeFeature::FSegmentNode& node) { double gapSquared=0.0; for(std::size_t axis=0;axis<3;++axis) { - const double scale=axis==2?height:radius; - const double q=point[axis]/scale; - const double low=node.minimum[axis]/scale; - const double high=node.maximum[axis]/scale; + const double q=normalizedPoint[axis]; + const double low=node.minimumNormalized[axis]; + const double high=node.maximumNormalized[axis]; const double gap=qhigh?q-high:0.0; gapSquared+=gap*gap; } @@ -7696,52 +7943,67 @@ private: evaluateSegment(segment); const double candidateSupportDepth= feature.edge->floorSupportDepthMicrometres/1000000.0; - if(candidateSupportDepth>0.0&&nearestRadialstartAnchorStableId.ends_with( + "-mouth"); + const double floorSupportRadius=portalMouthEdge?radius: + std::min(radius,FloorSupportFootprintRadiusMetres); + const double candidateNormalDepth=(nearestFloor-point[2]) + /std::sqrt(1.0+nearestGrade*nearestGrade); + if(candidateSupportDepth>=MinimumBackingDepthMetres + &&candidateNormalDepth>=MinimumBackingDepthMetres + &&candidateNormalDepth<=candidateSupportDepth + &&nearestRadialfloorProfileStableId.empty()) + const double candidateScore=nearestRadial*nearestRadial; + const bool hasProfile=!feature.edge->floorProfileStableId.empty(); + const bool sameProfile=floorSupportProfileId&&hasProfile + &&*floorSupportProfileId==feature.edge->floorProfileStableId; + if(!floorSupportProfileId||sameProfile) { - const bool sameProfile=floorSupportProfileId - &&*floorSupportProfileId==feature.edge->floorProfileStableId; - if(!floorSupportProfileId||sameProfile) - { - // Backbone edges share h(s). At a geometric joint their - // cave volumes overlap, so the backing top is the lower - // of the local floors, never the closer higher segment. - if(!floorSupportProfileId||nearestFloorstableId<*floorSupportEdgeId)) - { - floorSupportScore=candidateScore; - floorSupportDepth=candidateSupportDepth; - floorSupportFloor=nearestFloor; - floorSupportGrade=nearestGrade; - floorSupportEdgeId=&feature.edge->stableId; - } - floorSupportProfileId=&feature.edge->floorProfileStableId; - } - } - else if(!floorSupportProfileId - &&(candidateScorestableId<*floorSupportEdgeId)))) - { - floorSupportScore=candidateScore; - floorSupportDepth=candidateSupportDepth; - floorSupportFloor=nearestFloor; - floorSupportGrade=nearestGrade; - floorSupportEdgeId=&feature.edge->stableId; + &&(nearestFloorstableId<*floorSupportEdgeId)))) + { + floorSupportScore=candidateScore; + floorSupportDepth=candidateSupportDepth; + floorSupportFloor=nearestFloor; + floorSupportGrade=nearestGrade; + floorSupportEdgeId=&feature.edge->stableId; + } + if(hasProfile) + floorSupportProfileId=&feature.edge->floorProfileStableId; } } + edgeFloor=nearestFloor; const double floor=(nearestFloor-point[2]) /std::sqrt(1.0+nearestGrade*nearestGrade); const double width=feature.edge->smoothWidthMicrometres/1000000.0; featureVoid=SmoothMaxMetres(tube,floor,width); } if(!std::isfinite(featureVoid))continue; + if(feature.edge&&feature.edge->floorSupportDepthMicrometres>=650000 + &&featureVoid<0.0) + { + if(edgeFloorfloorProfileStableId + <(lowestOpenEdgeProfileId?*lowestOpenEdgeProfileId:std::string{}))) + { + lowestOpenEdgeFloor=edgeFloor; + lowestOpenEdgeProfileId=&feature.edge->floorProfileStableId; + } + } if(!std::isfinite(caveVoid))caveVoid=featureVoid; else caveVoid=SmoothMinMetres(caveVoid,featureVoid, feature.room @@ -7752,8 +8014,10 @@ private: ?feature.edge->unionWidthMicrometres :feature.edge->smoothWidthMicrometres)/1000000.0); } + const double floorSupportNormalDepth=(floorSupportFloor-point[2]) + /std::sqrt(1.0+floorSupportGrade*floorSupportGrade); if(std::isfinite(floorSupportScore)&&point[2]empty() + &&*floorSupportProfileId==*lowestOpenEdgeProfileId; + const bool sealsOpenLowerEdge=std::isfinite(lowestOpenEdgeFloor) + &&lowestOpenEdgeFloorlowestOpenEdgeFloor&&!sameContinuousFloorProfile; + if(std::isfinite(nativeFloorSupportMinimum)&&!sealsOpenLowerEdge) composed=std::min(composed,nativeFloorSupportMinimum); if(nativeFloorSupport&&std::isfinite(nativeFloorSupportMinimum)) *nativeFloorSupport=static_cast(nativeFloorSupportMinimum/metresPerVoxel); @@ -9748,6 +10020,32 @@ private: },&Timing,nullptr,16); } + uint64 ApplyCriticalAirPackedSigns() + { + const int32 NX=Size[0],NY=Size[1]; + std::array workerVoxels{}; + ParallelLines(static_cast(NX)*NY,[&](size_t Line,int32 Worker) + { + const int32 X=Min[0]+static_cast(Line%NX); + const int32 Y=Min[1]+static_cast(Line/NX); + const size_t Base=Line*WordsPerZ; + for(const uint32 Id:GetLineCandidates(2,X,Y)) + { + const FPrimitive& primitive=Primitives[Id]; + if(!primitive.bCarve||!primitive.bCritical)continue; + const int32 lo=std::max(primitive.Min[2],Min[2]); + const int32 hi=std::min(primitive.Max[2],Max[2]); + for(int32 Z=lo;Z<=hi;++Z) + { + if(!ShapeContains(primitive,X,Y,Z))continue; + FillBits(RockWords,Base,Z-Min[2],Z-Min[2],false); + ++workerVoxels[static_cast(Worker)]; + } + } + },nullptr,nullptr,16); + return std::accumulate(workerVoxels.begin(),workerVoxels.end(),uint64{0}); + } + void BuildCandidateBands(FParallelTiming& AxisTiming,FParallelTiming& MergeTiming, double& MergeSeconds) { @@ -11491,7 +11789,8 @@ private: const int32 VZ=static_cast(std::lround(Z)); const bool bCritical=IsInsideCriticalAir(VX,VY,VZ); if(bCritical){++RouteCritical;++SegmentCritical;} - if(IsRock(VX,VY,VZ)) + const bool bRock=IsRock(VX,VY,VZ); + if(bRock) { ++Stats.WalkFailures;++RouteFailures;++SegmentFailures; if(RouteFailures==1) @@ -11705,7 +12004,7 @@ inline void FStandaloneShapeWorld::FScopedPassageTileQueries::prepareSweptTubeBa ?shape_->ResolvedLayout->coreVoxelSizeMicrometres:250000); const double shellThickness=VF_PassageShellThicknessMetres*qPerMetre; const double stampedOutsideDistance=sweep->influenceSubvoxels+1.0; - constexpr double MouthShellBlendMetres=5.0; + constexpr double DefaultMouthShellBlendMetres=5.0; const double wallReliefShell=0.41*qPerMetre; const double floorDetailShell=1.0*qPerMetre; if(profile) @@ -11730,6 +12029,19 @@ inline void FStandaloneShapeWorld::FScopedPassageTileQueries::prepareSweptTubeBa { const auto& candidates=passageRouteSegments_[passageIndex]; if(candidates.empty())continue; + double mouthShellBlendMetres=DefaultMouthShellBlendMetres; + if(shape_->ResolvedLayout&&passageIndexResolvedLayout->passages.size()) + { + const auto& passage=shape_->ResolvedLayout->passages[passageIndex]; + const auto lower=std::find_if(shape_->ResolvedLayout->strates.begin(), + shape_->ResolvedLayout->strates.end(),[&](const auto& stratum) + {return stratum.strateIndex==passage.lowerStrateIndex;}); + if(lower!=shape_->ResolvedLayout->strates.end() + &&lower->generatorKind=="TunnelNetwork" + &&lower->recipeId=="Core.Caves.PrimitiveField" + &&lower->recipeVersion==1) + mouthShellBlendMetres=2.0; + } const std::int32_t sweepIndex=static_cast(sweep->passages.size()); sweep->passageToSweep[passageIndex]=sweepIndex; auto& passageSamples=sweep->passages.emplace_back(); @@ -11883,7 +12195,7 @@ inline void FStandaloneShapeWorld::FScopedPassageTileQueries::prepareSweptTubeBa const double mouthDistanceMetres=std::max(0.0,std::min(alongMetres, constants.totalPathLengthMetres-alongMetres)); const double shellFade=std::clamp(mouthDistanceMetres/ - MouthShellBlendMetres,0.0,1.0); + mouthShellBlendMetres,0.0,1.0); const double smoothShellFade=shellFade*shellFade *(3.0-2.0*shellFade); const double floorDistanceMetres=(floorCenterZ-floorDepth-qz)/qPerMetre; @@ -11900,7 +12212,7 @@ inline void FStandaloneShapeWorld::FScopedPassageTileQueries::prepareSweptTubeBa const double pastMouthMetres=std::max(0.0,alongMetres -constants.totalPathLengthMetres); const double extensionFade=std::clamp(1.0 - -pastMouthMetres/MouthShellBlendMetres,0.0,1.0); + -pastMouthMetres/mouthShellBlendMetres,0.0,1.0); floorExtensionFade=extensionFade*extensionFade *(3.0-2.0*extensionFade); } diff --git a/Standalone/Research/WorldAssemblerM3/Linux135/REPORT.md b/Standalone/Research/WorldAssemblerM3/Linux135/REPORT.md new file mode 100644 index 0000000..3a09abd --- /dev/null +++ b/Standalone/Research/WorldAssemblerM3/Linux135/REPORT.md @@ -0,0 +1,32 @@ +# Linux135 — Tunnel Network planner + +Source v236 (`8dc4294ce0250a591ee4515471c3c547e796ffa4346ab44f0ae1740bbc3797f5`) on `wip/l134-cave-planner`. + +## Route audits + +Seeds 0–15 pass the composed passage, seam, shell, naturalness, mouth-profile, ripple, and blocked-route controls (16/16). The negative control still detects all three blocked routes. No audit threshold changed. + +The mouth joint now takes its floor/profile from the resolved mouth geometry and carries that shared profile into the planner's first and last edges. This fixes seed 1's blocked arrival and seed 2's unsupported arrival. For seeds 0 and 3, the first 648 regular route candidates exceeded the grade bound; the bounded deterministic fallback finds a route. “No sweepable route candidates” was a planner search miss, not an empty cave. + +## Cost + +L80 medians are near-ready / whole-view percent deltas. The serialized 144-run matrix started at load 0.81 / 98.4% CPU idle. Each cell gives `assembler/world` first, then exact L134 (`a3c8e36`). + +| Layer | W8 | W12 | +|---|---:|---:| +| 0 | +3.88 / +0.27%; +1.79 / -0.21% | +8.22 / +4.09%; +3.89 / +2.49% | +| 1 | +8.82 / +4.96%; +4.14 / +1.71% | +11.83 / +5.35%; +4.10 / +2.09% | +| 2 | +13.33 / +3.77%; +2.34 / -0.09% | +12.56 / +5.20%; +1.40 / +0.61% | +| 3 | +13.65 / +3.99%; -0.25 / +0.44% | +12.71 / +5.14%; +2.97 / +2.12% | + +The two speed changes were bounded cave AABBs with a 1 mm float guard, and normalized edge-BVH bounds with one normalization per sample. This improves on L134, but misses the `assembler/world` target: near-ready remains 3.88–13.65% slower. Layers 1–3 overlap the native cave volume and spend time on roughly 4.95–5.76 million near-LOD cave samples per view; layer 0 has none at LOD0–3 and is still up 3.88% at W8, showing some generic composer/classification cost as well. + +Planning is about 3.75–4.19 ms per view (cavern planning 0.46–0.49 ms). At LOD0–3, room/edge candidates average 0.39/0.23 per composer tile on layer 1, 0.47/0.27 on layer 2, and 0.06/0.04 on layer 3. Tile proofs skip work at LOD0–3; measured cave views have no tile skips at LOD4–8. The reported 180–320 ns density-call estimate includes all shape fields, not cave-only time. Profile: `Saved/Linux/l135/profile-summary-speed2.json`. + +## Gates and pictures + +Fast tier passes in 301.7 s. Clang and GCC each match 13 hash lines at W1/W8/W12. Route seams and the negative controls pass; canonical baselines are v236. The post-timing VFHost Linux Development build succeeds: 27/27 actions, 77.70 s, using `-NoUBA -MaxParallelActions=4`. + +The seven-view gallery has four planner views at the planned floor plus 1.6 m and three legacy views at legacy coordinates. Open `captures/COMPARE.html`; camera coordinates and renderer settings are in `captures/capture-index.json`. + +Sweep: `Saved/Linux/l135/optimized-seed-sweep.json`. Timing: `Saved/Linux/l135/timing/summary.json`. Determinism: `Saved/Linux/l135/determinism-summary.json`. Host build log: `Saved/Linux/l135/ubt-l135-console.log`. diff --git a/Standalone/Research/WorldAssemblerM3/Linux135/RunL135Captures.py b/Standalone/Research/WorldAssemblerM3/Linux135/RunL135Captures.py new file mode 100644 index 0000000..2d67a31 --- /dev/null +++ b/Standalone/Research/WorldAssemblerM3/Linux135/RunL135Captures.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""Capture eye-height planner interiors and legacy interiors at legacy coordinates.""" +from __future__ import annotations + +import json +import math +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[4] +OUT = ROOT / "Standalone/Research/WorldAssemblerM3/Linux135/captures" +SAVED = ROOT / "Saved/Linux/l135" +DATA = ROOT / "Standalone/data/owner_world.json" +LAYOUT = SAVED / "capture-default-layout.json" +HEADLESS = SAVED / "bin/VoxelForgeCoreHeadless-clang-l135" +RENDERER = SAVED / "bin/VoxelForgeOffscreenCapture-clang-l135" +STAMP = "8dc4294ce0250a591ee4515471c3c547e796ffa4346ab44f0ae1740bbc3797f5" +EYE_METRES = 1.6 + + +def run(command: list[str], log_path: Path) -> None: + wrapped = ["taskset", "-c", "0-11", "prlimit", "--as=6442450944", "--", + "timeout", "1740s", *command] + with log_path.open("w", encoding="utf-8") as log: + result = subprocess.run(wrapped, cwd=ROOT, stdout=log, + stderr=subprocess.STDOUT, timeout=1740, + check=False) + if result.returncode: + raise SystemExit(f"command failed ({result.returncode}): {log_path}") + + +def xyz(value: list[int] | list[float]) -> tuple[float, float, float]: + return tuple(component / 1_000_000.0 for component in value) # type: ignore[return-value] + + +def yaw_to(dx: float, dy: float) -> float: + # The renderer's yaw zero points along +Y (Forward = sin(yaw), cos(yaw), 0). + return math.degrees(math.atan2(dx, dy)) + + +def camera_arg(camera: tuple[float, float, float], yaw: float) -> str: + return ",".join(f"{part:.9f}" for part in (*camera, yaw, 0.0)) + + +def make_view(name: str, recipe: str, camera: tuple[float, float, float], + yaw: float) -> dict[str, object]: + OUT.mkdir(parents=True, exist_ok=True) + mesh = SAVED / f"capture-{name}.vfmesh" + image = OUT / f"{name}.png" + mesh_log = SAVED / f"capture-{name}-mesh.log" + render_log = SAVED / f"capture-{name}-render.log" + command = [str(HEADLESS), str(DATA), "--world-assembler-prototype", + "--stream-benchmark", "--stream-pipeline", "--workers=8", + "--clip-radius=1", "--max-clip-level=0", "--max-level=0", + f"--camera={camera_arg(camera, yaw)}", f"--mesh-export={mesh}", + "--capture-skip-walkability", "--force-generate", + "--render-distance-chunks=0"] + # The checked-in fixture is already seed 0. Keeping its Unreal-resolved owner + # plan avoids rebuilding the broad SurfaceWorld spawn search for each camera. + if recipe == "legacy-tunnel-network": + command.append("--legacy-tunnel-network") + else: + command.append("--recipe=surface-world") + run(command, mesh_log) + render_command = [str(RENDERER), str(mesh), str(image), "--size=1600x900", + "--shade=readable", "--height-tint=on", "--headlamp=off"] + run(render_command, render_log) + mesh_output = mesh_log.read_text(encoding="utf-8", errors="replace") + render_output = render_log.read_text(encoding="utf-8", errors="replace") + if STAMP not in mesh_output or STAMP not in render_output: + raise SystemExit(f"source stamp mismatch for {name}") + if "status=PASS" not in render_output: + raise SystemExit(f"render failed for {name}") + return {"name": name, "recipe": recipe, "camera_m": list(camera), + "yaw_deg": yaw, + "camera_height_basis": "floor + player eye offset" if recipe == "surface-world" + else ("legacy room floor + player eye offset" if name.endswith("room") + else "near the legacy tunnel centerline"), + "eye_offset_m": EYE_METRES + if recipe == "surface-world" or name.endswith("room") else None, + "mesh": str(mesh.relative_to(ROOT)), "image": str(image.relative_to(ROOT)), + "mesh_log": str(mesh_log.relative_to(ROOT)), + "render_log": str(render_log.relative_to(ROOT))} + + +def main() -> int: + layout = json.loads(LAYOUT.read_text(encoding="utf-8")) + rooms = {room["stable_id"]: room for room in layout["cavern_rooms"]} + edges = {edge["stable_id"]: edge for edge in layout["cavern_edges"]} + views: list[dict[str, object]] = [] + + arrival = rooms["tunnel-network/strate-2/room-0/primitive-0"] + room1 = rooms["tunnel-network/strate-2/room-1/primitive-0"] + arrival_center = xyz(arrival["center_um"]) + arrival_floor = arrival["floor_um"] / 1_000_000.0 + views.append(make_view("planner-arrival-room", "surface-world", + (arrival_center[0], arrival_center[1], arrival_floor + EYE_METRES), + yaw_to(room1["center_um"][0] - arrival["center_um"][0], + room1["center_um"][1] - arrival["center_um"][1]))) + + tunnel = edges["tunnel-network/strate-2/route-edge-1"]["polyline_um"] + tunnel_start, tunnel_end = xyz(tunnel[0]), xyz(tunnel[-1]) + tunnel_floor = tunnel[1][2] / 1_000_000.0 + views.append(make_view("planner-tunnel", "surface-world", + ((tunnel_start[0] + tunnel_end[0]) * 0.5, + (tunnel_start[1] + tunnel_end[1]) * 0.5, + tunnel_floor + EYE_METRES), + yaw_to(tunnel_end[0] - tunnel_start[0], tunnel_end[1] - tunnel_start[1]))) + + compound = rooms["tunnel-network/strate-2/room-19/primitive-0"] + compound_center = xyz(compound["center_um"]) + compound_next = rooms["tunnel-network/strate-2/room-20/primitive-0"] + views.append(make_view("planner-compound-room", "surface-world", + (compound_center[0], compound_center[1], + compound["floor_um"] / 1_000_000.0 + EYE_METRES), + yaw_to(compound_next["center_um"][0] - compound["center_um"][0], + compound_next["center_um"][1] - compound["center_um"][1]))) + + destination = rooms["tunnel-network/strate-2/room-33/primitive-0"] + destination_center = xyz(destination["center_um"]) + upstream = rooms["tunnel-network/strate-2/room-32/primitive-0"] + views.append(make_view("planner-destination-chamber", "surface-world", + (destination_center[0], destination_center[1], + destination["floor_um"] / 1_000_000.0 + EYE_METRES), + yaw_to(upstream["center_um"][0] - destination["center_um"][0], + upstream["center_um"][1] - destination["center_um"][1]))) + + # The compiled legacy network has a deterministic origin chamber at (0, 0). + # Its room floor is hash-rolled from the owner fixture; these are legacy world + # coordinates, independent of the native planner's passage route. + legacy_origin_floor = -819.9169921875 + legacy_origin_eye = legacy_origin_floor + EYE_METRES + legacy_neighbor = (39.327854, 5.115777, -797.724731) + views.append(make_view("legacy-origin-room", "legacy-tunnel-network", + (0.0, 0.0, legacy_origin_eye), yaw_to(legacy_neighbor[0], legacy_neighbor[1]))) + + # Hash-selected (cell 1,0) is the origin graph's nearest connected room. Its + # cut-plane floor estimate is -801.0 m before the authored local relief. + legacy_neighbor_floor = -800.9986572265625 + legacy_neighbor_eye = legacy_neighbor_floor + EYE_METRES + views.append(make_view("legacy-connected-room", "legacy-tunnel-network", + (legacy_neighbor[0], legacy_neighbor[1], legacy_neighbor_eye), + yaw_to(-legacy_neighbor[0], -legacy_neighbor[1]))) + + legacy_tunnel_mid = (legacy_neighbor[0] * 0.5, legacy_neighbor[1] * 0.5, + (-812.0 + legacy_neighbor[2]) * 0.5) + views.append(make_view("legacy-origin-tunnel", "legacy-tunnel-network", + legacy_tunnel_mid, yaw_to(legacy_neighbor[0], legacy_neighbor[1]))) + + manifest = {"source_stamp": STAMP, "seed": 0, + "renderer": "readable, height tint on, headlamp off; 1600x900", + "views": views} + (OUT / "capture-index.json").write_text( + json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + cards = "\n".join( + f'
' + f'{view[' + f'
{view["name"]}
' for view in views) + (OUT / "COMPARE.html").write_text( + "L135 interior views\n" + "\n" + "

Planner and legacy interiors, seed 0

\n" + + cards + "\n
\n", encoding="utf-8") + print(f"[L135CaptureSummary] views={len(views)} status=PASS") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Standalone/Research/WorldAssemblerM3/Linux135/RunL135Determinism.py b/Standalone/Research/WorldAssemblerM3/Linux135/RunL135Determinism.py new file mode 100644 index 0000000..5277c68 --- /dev/null +++ b/Standalone/Research/WorldAssemblerM3/Linux135/RunL135Determinism.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Check Clang/GCC cave-path pipeline hashes at 1, 8, and 12 workers.""" +from __future__ import annotations + +import json +import re +import subprocess +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[4] +OUT = ROOT / "Saved/Linux/l135" +DATA = ROOT / "Standalone/data/regression/owner_world_2strate_l61.json" +BINARIES = { + "clang": OUT / "bin/VoxelForgeCoreHeadless-clang-l135", + "gcc": OUT / "bin/VoxelForgeCoreHeadless-gcc-l135", +} +CAMERA = "-4.688214,38.662384,-505.781342,-28.446,0" +HASH_RE = re.compile( + r"^\[Pipeline(?:FullDetailHash|WholeViewHash|MaterialHash|LODHash|ExactQueryHash)\].*$", + re.M) +STAMP_RE = re.compile(r"^\[VoxelForgeSourceStamp\] sha256=([0-9a-f]+)$", re.M) + + +def main() -> int: + if any(not path.is_file() for path in (DATA, *BINARIES.values())): + raise SystemExit("missing L135 determinism binary or owner fixture") + OUT.mkdir(parents=True, exist_ok=True) + rows = [] + baseline: tuple[str, ...] | None = None + stamp: str | None = None + for compiler, binary in BINARIES.items(): + for workers in (1, 8, 12): + label = f"determinism-{compiler}-w{workers}" + log = OUT / f"{label}.log" + command = ["taskset", "-c", "0-11", "prlimit", "--as=6442450944", "--", + "timeout", "1740s", str(binary), str(DATA), + "--world-assembler-prototype", "--stream-benchmark", "--stream-pipeline", + f"--workers={workers}", "--clip-radius=3", "--max-clip-level=0", + "--max-level=0", f"--camera={CAMERA}", "--stream-hash-tiles", + "--force-generate", "--recipe=surface-world", "--render-distance-chunks=0", + "--landmark-chamber-demo", "--ore-veins-demo"] + started = time.monotonic() + with log.open("w", encoding="utf-8") as output: + result = subprocess.run(command, cwd=ROOT, stdout=output, + stderr=subprocess.STDOUT, check=False, timeout=1740) + content = log.read_text(encoding="utf-8", errors="replace") + found_stamp = STAMP_RE.search(content) + hashes = tuple(HASH_RE.findall(content)) + passed = result.returncode == 0 and found_stamp is not None and len(hashes) >= 4 + if found_stamp is not None: + if stamp is None: + stamp = found_stamp.group(1) + passed &= stamp == found_stamp.group(1) + if baseline is None and passed: + baseline = hashes + elif baseline is not None: + passed &= hashes == baseline + row = {"compiler": compiler, "workers": workers, + "exit": result.returncode, "stamp": found_stamp.group(1) + if found_stamp else None, + "hash_lines": len(hashes), "hashes_match_first": hashes == baseline, + "passed": bool(passed), "elapsed_s": round(time.monotonic() - started, 3), + "log": str(log.relative_to(ROOT))} + rows.append(row) + print("[L135DeterminismRun] " + json.dumps(row, sort_keys=True), flush=True) + if not passed: + (OUT / "determinism-summary.json").write_text(json.dumps( + {"status": "FAIL", "runs": rows}, indent=2) + "\n") + raise SystemExit(f"determinism mismatch; see {log}") + summary = {"status": "PASS", "stamp": stamp, "runs": rows} + (OUT / "determinism-summary.json").write_text( + json.dumps(summary, indent=2) + "\n", encoding="utf-8") + print(f"[L135DeterminismSummary] runs={len(rows)} stamp={stamp} status=PASS") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Standalone/Research/WorldAssemblerM3/Linux135/RunL135Profiles.py b/Standalone/Research/WorldAssemblerM3/Linux135/RunL135Profiles.py new file mode 100644 index 0000000..53401b9 --- /dev/null +++ b/Standalone/Research/WorldAssemblerM3/Linux135/RunL135Profiles.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Collect final-stamp cave, candidate, proof, and startup counters by stratum.""" +from __future__ import annotations + +import json +import re +import subprocess +import time +from collections import defaultdict +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[4] +OUT = ROOT / "Saved/Linux/l135" +BIN = OUT / "bin/VoxelForgeCoreHeadless-clang-l135" +DATA = ROOT / "Standalone/data/owner_world.json" +STAMP = "8dc4294ce0250a591ee4515471c3c547e796ffa4346ab44f0ae1740bbc3797f5" +CAMERAS = { + 0: (8.0, 16.0, -372.2626953125), + 1: (25.8369140625, 3.19921875, -737.0), + 2: (19.166015625, -14.2001953125, -753.3740234375), + 3: (-20.0625, -5.068359375, -900.353515625), +} +STAMP_RE = re.compile(r"^\[VoxelForgeSourceStamp\] sha256=([0-9a-f]+)$", re.M) +PLAN_RE = re.compile(r"^\[WorldAssemblerPlanTiming\] total_ms=([0-9.]+) status=([^ ]+)$", re.M) +CAVE_RE = re.compile(r"^\[CavernPlannerV1\].* plan_ms=([0-9.]+) status=([^ ]+)$", re.M) +TILE_RE = re.compile(r"^\[WorldAssemblerTilePipeline\] (.*)$", re.M) +CLASS_RE = re.compile(r"^\[WorldAssemblerLODClassification\] (.*)$", re.M) +COMPOSER_RE = re.compile(r"^\[PassageComposerTile\] (.*)$", re.M) +FIELDS_RE = re.compile(r"([a-zA-Z0-9_]+)=([^ ]+)") + + +def fields(line: str) -> dict[str, str]: + return dict(FIELDS_RE.findall(line)) + + +def main() -> int: + if not BIN.is_file() or not DATA.is_file(): + raise SystemExit("missing L135 profile binary or owner fixture") + rows = [] + for strate, xyz in CAMERAS.items(): + camera = ",".join(f"{value:.12f}" for value in xyz) + ",0,0" + log = OUT / f"profile-final-speed2-s{strate}.log" + command = ["taskset", "-c", "0-11", "prlimit", "--as=6442450944", "--", + "timeout", "1740s", str(BIN), str(DATA), "--world-assembler-prototype", + "--stream-benchmark", "--stream-pipeline", "--workers=8", + "--clip-radius=3", "--max-clip-level=8", "--max-level=8", + f"--camera={camera}", "--profile-lod-work", "--profile-passage-composer", + "--stream-scheduler-policy=3"] + started = time.monotonic() + with log.open("w", encoding="utf-8") as output: + result = subprocess.run(command, cwd=ROOT, stdout=output, + stderr=subprocess.STDOUT, check=False, timeout=1740) + content = log.read_text(encoding="utf-8", errors="replace") + found_stamp = STAMP_RE.search(content) + if result.returncode or found_stamp is None or found_stamp.group(1) != STAMP: + raise SystemExit(f"final-stamp profile failed: {log}, exit={result.returncode}") + plan, cave = PLAN_RE.search(content), CAVE_RE.search(content) + tile = TILE_RE.search(content) + if plan is None or cave is None or tile is None: + raise SystemExit(f"profile counters missing: {log}") + aggregate = defaultdict(lambda: defaultdict(int)) + for match in CLASS_RE.finditer(content): + v = fields(match.group(1)); lod = int(v["lod"]) + for key in ("tiles", "tile_skips", "density_point_calls", "field_eval_sample_points", + "proof_air_cells", "proof_solid_cells"): + aggregate[lod][key] = int(v.get(key, "0")) + aggregate[lod]["field_eval_ms_est"] = float(v.get("field_eval_ms_est", "0")) + for match in COMPOSER_RE.finditer(content): + v = fields(match.group(1)); lod = int(v["key"].split("lod", 1)[1].rstrip(")")) + aggregate[lod]["composer_tiles"] += 1 + for key in ("samples", "native_cave_samples", "room_candidates", "edge_candidates", + "features", "route_candidates", "passage_iterations"): + aggregate[lod][key] += int(v.get(key, "0")) + aggregate[lod]["composer_worker_ms"] += float(v.get("worker_ms", "0")) + for lod, values in aggregate.items(): + calls = values.get("density_point_calls", 0) + values["field_eval_ns_per_density_call_est"] = round( + values.get("field_eval_ms_est", 0.0) * 1.0e6 / calls, 1) if calls else None + composer_tiles = values.get("composer_tiles", 0) + values["room_candidates_per_composer_tile"] = round( + values.get("room_candidates", 0) / composer_tiles, 3) if composer_tiles else None + values["edge_candidates_per_composer_tile"] = round( + values.get("edge_candidates", 0) / composer_tiles, 3) if composer_tiles else None + tile_fields = fields(tile.group(1)) + tile_summary = {} + for key, value in tile_fields.items(): + try: + tile_summary[key] = int(value) + except ValueError: + try: + tile_summary[key] = float(value) + except ValueError: + continue + row = {"strate": strate, "source_stamp": found_stamp.group(1), + "world_assembler_plan_ms": float(plan.group(1)), + "cavern_planner_ms": float(cave.group(1)), + "tile_pipeline": tile_summary, + "lod": {str(k): dict(v) for k, v in sorted(aggregate.items())}, + "wall_s": round(time.monotonic() - started, 3), + "log": str(log.relative_to(ROOT))} + rows.append(row) + print("[L135ProfileRun] " + json.dumps(row, sort_keys=True), flush=True) + summary = {"status": "PASS", "source_stamp": STAMP, "strata": rows, + "field_eval_ns_per_density_call_note": + "Estimate includes all shape-field density calls at this view, not native-cave-exclusive time.", + "tile_skip_note": + "tile_pipeline.whole_tile_skips and lod.tile_skips are aggregate classifiers; they are not cave-only attribution."} + (OUT / "profile-summary-speed2.json").write_text( + json.dumps(summary, indent=2) + "\n", encoding="utf-8") + print("[L135ProfileSummary] strata=4 status=PASS") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Standalone/Research/WorldAssemblerM3/Linux135/RunL135SeedSweep.py b/Standalone/Research/WorldAssemblerM3/Linux135/RunL135SeedSweep.py new file mode 100644 index 0000000..3e1bb35 --- /dev/null +++ b/Standalone/Research/WorldAssemblerM3/Linux135/RunL135SeedSweep.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Run the L135 composed cave-passage and tile-seam audits for fixed seeds.""" +from __future__ import annotations + +import json +import re +import subprocess +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[4] +OUT = ROOT / "Saved/Linux/l135" +BIN = OUT / "bin/VoxelForgeCoreHeadless-clang-l135" +DATA = ROOT / "Standalone/data/owner_world.json" +REQUIRED = { + "passage": re.compile(r"^\[ResolvedPassageAuditSummary\].*status=PASS$", re.M), + "seams": re.compile(r"^\[PassageTileSeamAudit\].*status=PASS$", re.M), + "shell": re.compile(r"^\[PassageShellAuditSummary\].*status=PASS$", re.M), + "naturalness": re.compile(r"^\[PassageNaturalnessSummary\].*status=PASS$", re.M), + "mouth_profile": re.compile(r"^\[PassageMouthProfileSummary\].*status=PASS$", re.M), + "ripple": re.compile(r"^\[PassageRippleAuditSummary\].*status=PASS$", re.M), + "route_negative_control": re.compile( + r"^\[TunnelNetworkBlockedRouteControl\].*expected=FAIL status=PASS$", re.M), +} + + +def main() -> int: + seeds = [int(value) for value in sys.argv[1:]] or list(range(16)) + if not BIN.is_file() or not DATA.is_file(): + raise SystemExit("missing L135 headless binary or owner fixture") + results = [] + for seed in seeds: + log = OUT / f"optimized-seed{seed}.log" + layout = OUT / f"optimized-seed{seed}.json" + command = ["taskset", "-c", "0-11", "prlimit", "--as=6442450944", "--", + "timeout", "1740s", str(BIN), str(DATA), + "--world-assembler-prototype", "--stream-benchmark", "--stream-pipeline", + "--workers=8", "--clip-radius=3", "--max-clip-level=8", "--max-level=0", + "--force-generate", f"--seed={seed}", "--recipe=surface-world", + "--audit-resolved-layout-passages", "--audit-passage-tile-seams", + f"--resolved-layout-output={layout}"] + started = time.monotonic() + with log.open("w", encoding="utf-8") as output: + result = subprocess.run(command, cwd=ROOT, stdout=output, + stderr=subprocess.STDOUT, check=False, timeout=1740) + content = log.read_text(encoding="utf-8", errors="replace") + checks = {name: bool(pattern.search(content)) for name, pattern in REQUIRED.items()} + blocked_controls = re.findall( + r"^\[ResolvedPassageBlockedControl\].*detected=true$", content, re.M) + checks["three_blocked_controls"] = len(blocked_controls) == 3 + passed = result.returncode == 0 and all(checks.values()) + row = {"seed": seed, "exit": result.returncode, "passed": passed, + "checks": checks, "blocked_controls": len(blocked_controls), + "elapsed_s": round(time.monotonic() - started, 3), + "log": str(log.relative_to(ROOT)), "layout": str(layout.relative_to(ROOT))} + results.append(row) + print("[L135SeedSweep] " + json.dumps(row, sort_keys=True), flush=True) + if not passed: + raise SystemExit(f"seed {seed} failed a composed audit; see {log}") + summary = {"seeds": seeds, "passed": len(results), "failed": 0, + "results": results} + (OUT / "optimized-seed-sweep.json").write_text( + json.dumps(summary, indent=2) + "\n", encoding="utf-8") + print(f"[L135SeedSweepSummary] passed={len(results)}/{len(seeds)} status=PASS") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Standalone/Research/WorldAssemblerM3/Linux135/RunL135Timing.py b/Standalone/Research/WorldAssemblerM3/Linux135/RunL135Timing.py new file mode 100644 index 0000000..e9cffa6 --- /dev/null +++ b/Standalone/Research/WorldAssemblerM3/Linux135/RunL135Timing.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Five interleaved L80 timings against assembler/world and the L134 revision.""" +from __future__ import annotations + +import json +import os +import re +import statistics +import subprocess +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[4] +OUT = ROOT / "Saved/Linux/l135/timing" +DATA = ROOT / "Standalone/data/owner_world.json" +BINARIES = { + "assembler-world": ROOT / "Saved/Linux/l130/bin/VoxelForgeCoreHeadless-l130-clang", + "a3c8e36-v230": ROOT / "Saved/Linux/l134/bin/VoxelForgeCoreHeadless-l134-clang", + "l135-v236": ROOT / "Saved/Linux/l135/bin/VoxelForgeCoreHeadless-clang-l135", +} +CAMERAS = { + 0: (8.0, 16.0, -372.2626953125), + 1: (25.8369140625, 3.19921875, -737.0), + 2: (19.166015625, -14.2001953125, -753.3740234375), + 3: (-20.0625, -5.068359375, -900.353515625), +} +PIPELINE_RE = re.compile(r"^\[StreamPipeline\] (.*)$", re.M) +FIELDS_RE = re.compile(r"([a-zA-Z0-9_]+)=([^ ]+)") +NEAR_RE = re.compile( + r"^\[WorldAssemblerNearLODReady\] lods=0-2 tiles=(\d+)/(\d+) cold_ready_s=([0-9.]+)$", + re.M) +PLAN_RE = re.compile(r"^\[WorldAssemblerPlanTiming\] total_ms=([0-9.]+) status=([^ ]+)$", re.M) +CAVE_RE = re.compile(r"^\[CavernPlannerV1\].* plan_ms=([0-9.]+) status=([^ ]+)$", re.M) + + +def run(strate: int, workers: int, version: str, phase: str, + replicate: int) -> dict: + camera = ",".join(f"{value:.12f}" for value in CAMERAS[strate]) + ",0,0" + label = f"s{strate}-w{workers}-{version}-{phase}{replicate}-l135" + log = OUT / f"{label}.log" + command = ["taskset", "-c", "0-11", "prlimit", "--as=6442450944", "--", + "timeout", "1740s", str(BINARIES[version]), str(DATA), + "--world-assembler-prototype", "--stream-benchmark", "--stream-pipeline", + f"--workers={workers}", "--clip-radius=3", "--max-clip-level=8", + "--max-level=8", f"--camera={camera}", "--profile-lod-work", + "--stream-scheduler-policy=3"] + started = time.monotonic() + with log.open("w", encoding="utf-8") as output: + result = subprocess.run(command, cwd=ROOT, stdout=output, + stderr=subprocess.STDOUT, check=False, timeout=1740) + content = log.read_text(encoding="utf-8", errors="replace") + pipeline, near = PIPELINE_RE.search(content), NEAR_RE.search(content) + if result.returncode or pipeline is None or near is None: + raise RuntimeError(f"timing run failed: {log}; exit={result.returncode}") + fields = dict(FIELDS_RE.findall(pipeline.group(1))) + plan, cave = PLAN_RE.search(content), CAVE_RE.search(content) + row = {"strate": strate, "workers": workers, "version": version, + "phase": phase, "replicate": replicate, + "lod02_ready_s": float(near.group(3)), + "whole_view_ready_s": float(fields["whole_view_ready"].removesuffix("s")), + "plan_ms": float(plan.group(1)) if plan else None, + "cave_plan_ms": float(cave.group(1)) if cave else None, + "wall_s": round(time.monotonic() - started, 3), + "log": str(log.relative_to(ROOT))} + print("[L135TimingRun] " + json.dumps(row, sort_keys=True), flush=True) + return row + + +def quiet_machine() -> tuple[float, float]: + load_1m = os.getloadavg()[0] + def totals() -> tuple[int, int]: + fields = Path("/proc/stat").read_text().splitlines()[0].split() + values = [int(value) for value in fields[1:]] + return sum(values), values[3] + values[4] + before = totals() + time.sleep(2.0) + after = totals() + idle = 100.0 * (after[1] - before[1]) / max(1, after[0] - before[0]) + return load_1m, idle + + +def main() -> int: + if any(not path.is_file() for path in (DATA, *BINARIES.values())): + raise SystemExit("missing L135 timing input") + OUT.mkdir(parents=True, exist_ok=True) + load_1m, idle = quiet_machine() + (OUT / "machine-load-before.txt").write_text( + f"1-minute load: {load_1m:.2f}\n2-second CPU idle: {idle:.1f}%\n") + print(f"[L135TimingGate] load_1m={load_1m:.2f} cpu_idle_2s={idle:.1f}%", flush=True) + if load_1m >= 0.9 or idle < 97.0: + raise SystemExit("timing refused: machine is above the L80 quiet-load gate") + + rows = [] + for strate in range(4): + for workers in (8, 12): + for version in BINARIES: + rows.append(run(strate, workers, version, "warmup", 0)) + for replicate in range(1, 6): + order = tuple(BINARIES) if replicate % 2 else tuple(reversed(BINARIES)) + for version in order: + rows.append(run(strate, workers, version, "measured", replicate)) + (OUT / "runs-partial.json").write_text( + json.dumps(rows, indent=2) + "\n", encoding="utf-8") + + measured = [row for row in rows if row["phase"] == "measured"] + summary = [] + for strate in range(4): + for workers in (8, 12): + item = {"strate": strate, "workers": workers, + "replicates_per_version": 5} + medians = {} + for version in BINARIES: + group = [row for row in measured if row["strate"] == strate + and row["workers"] == workers and row["version"] == version] + medians[version] = {} + for key in ("lod02_ready_s", "whole_view_ready_s"): + values = [row[key] for row in group] + medians[version][key] = statistics.median(values) + item[f"{version}_{key}_values"] = values + item[f"{version}_{key}_median"] = medians[version][key] + for baseline in ("assembler-world", "a3c8e36-v230"): + for key in ("lod02_ready_s", "whole_view_ready_s"): + item[f"vs_{baseline}_{key}_delta_pct"] = round( + 100.0 * (medians["l135-v236"][key] + / medians[baseline][key] - 1.0), 2) + summary.append(item) + (OUT / "summary.json").write_text(json.dumps( + {"runs": rows, "summary": summary}, indent=2) + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Standalone/Research/WorldAssemblerM3/Linux135/captures/COMPARE.html b/Standalone/Research/WorldAssemblerM3/Linux135/captures/COMPARE.html new file mode 100644 index 0000000..da1f60a --- /dev/null +++ b/Standalone/Research/WorldAssemblerM3/Linux135/captures/COMPARE.html @@ -0,0 +1,11 @@ +L135 interior views + +

Planner and legacy interiors, seed 0

+
planner-arrival-room
planner-arrival-room
+
planner-tunnel
planner-tunnel
+
planner-compound-room
planner-compound-room
+
planner-destination-chamber
planner-destination-chamber
+
legacy-origin-room
legacy-origin-room
+
legacy-connected-room
legacy-connected-room
+
legacy-origin-tunnel
legacy-origin-tunnel
+
diff --git a/Standalone/Research/WorldAssemblerM3/Linux135/captures/capture-index.json b/Standalone/Research/WorldAssemblerM3/Linux135/captures/capture-index.json new file mode 100644 index 0000000..e43e0d1 --- /dev/null +++ b/Standalone/Research/WorldAssemblerM3/Linux135/captures/capture-index.json @@ -0,0 +1,119 @@ +{ + "source_stamp": "8dc4294ce0250a591ee4515471c3c547e796ffa4346ab44f0ae1740bbc3797f5", + "seed": 0, + "renderer": "readable, height tint on, headlamp off; 1600x900", + "views": [ + { + "name": "planner-arrival-room", + "recipe": "surface-world", + "camera_m": [ + 35.593114, + -21.55801, + -755.864011 + ], + "yaw_deg": 114.47628370565772, + "camera_height_basis": "floor + player eye offset", + "eye_offset_m": 1.6, + "mesh": "Saved/Linux/l135/capture-planner-arrival-room.vfmesh", + "image": "Standalone/Research/WorldAssemblerM3/Linux135/captures/planner-arrival-room.png", + "mesh_log": "Saved/Linux/l135/capture-planner-arrival-room-mesh.log", + "render_log": "Saved/Linux/l135/capture-planner-arrival-room-render.log" + }, + { + "name": "planner-tunnel", + "recipe": "surface-world", + "camera_m": [ + 42.874177, + -24.8725425, + -757.112565 + ], + "yaw_deg": 114.47628370565774, + "camera_height_basis": "floor + player eye offset", + "eye_offset_m": 1.6, + "mesh": "Saved/Linux/l135/capture-planner-tunnel.vfmesh", + "image": "Standalone/Research/WorldAssemblerM3/Linux135/captures/planner-tunnel.png", + "mesh_log": "Saved/Linux/l135/capture-planner-tunnel-mesh.log", + "render_log": "Saved/Linux/l135/capture-planner-tunnel-render.log" + }, + { + "name": "planner-compound-room", + "recipe": "surface-world", + "camera_m": [ + -39.519627, + -52.685596, + -785.85419 + ], + "yaw_deg": -74.782578137959, + "camera_height_basis": "floor + player eye offset", + "eye_offset_m": 1.6, + "mesh": "Saved/Linux/l135/capture-planner-compound-room.vfmesh", + "image": "Standalone/Research/WorldAssemblerM3/Linux135/captures/planner-compound-room.png", + "mesh_log": "Saved/Linux/l135/capture-planner-compound-room-mesh.log", + "render_log": "Saved/Linux/l135/capture-planner-compound-room-render.log" + }, + { + "name": "planner-destination-chamber", + "recipe": "surface-world", + "camera_m": [ + -28.634259, + -4.85816, + -810.5142579999999 + ], + "yaw_deg": -89.33032962272898, + "camera_height_basis": "floor + player eye offset", + "eye_offset_m": 1.6, + "mesh": "Saved/Linux/l135/capture-planner-destination-chamber.vfmesh", + "image": "Standalone/Research/WorldAssemblerM3/Linux135/captures/planner-destination-chamber.png", + "mesh_log": "Saved/Linux/l135/capture-planner-destination-chamber-mesh.log", + "render_log": "Saved/Linux/l135/capture-planner-destination-chamber-render.log" + }, + { + "name": "legacy-origin-room", + "recipe": "legacy-tunnel-network", + "camera_m": [ + 0.0, + 0.0, + -818.3169921875 + ], + "yaw_deg": 82.58856646117444, + "camera_height_basis": "legacy room floor + player eye offset", + "eye_offset_m": 1.6, + "mesh": "Saved/Linux/l135/capture-legacy-origin-room.vfmesh", + "image": "Standalone/Research/WorldAssemblerM3/Linux135/captures/legacy-origin-room.png", + "mesh_log": "Saved/Linux/l135/capture-legacy-origin-room-mesh.log", + "render_log": "Saved/Linux/l135/capture-legacy-origin-room-render.log" + }, + { + "name": "legacy-connected-room", + "recipe": "legacy-tunnel-network", + "camera_m": [ + 39.327854, + 5.115777, + -799.3986572265625 + ], + "yaw_deg": -97.41143353882556, + "camera_height_basis": "legacy room floor + player eye offset", + "eye_offset_m": 1.6, + "mesh": "Saved/Linux/l135/capture-legacy-connected-room.vfmesh", + "image": "Standalone/Research/WorldAssemblerM3/Linux135/captures/legacy-connected-room.png", + "mesh_log": "Saved/Linux/l135/capture-legacy-connected-room-mesh.log", + "render_log": "Saved/Linux/l135/capture-legacy-connected-room-render.log" + }, + { + "name": "legacy-origin-tunnel", + "recipe": "legacy-tunnel-network", + "camera_m": [ + 19.663927, + 2.5578885, + -804.8623655 + ], + "yaw_deg": 82.58856646117444, + "camera_height_basis": "near the legacy tunnel centerline", + "eye_offset_m": null, + "mesh": "Saved/Linux/l135/capture-legacy-origin-tunnel.vfmesh", + "image": "Standalone/Research/WorldAssemblerM3/Linux135/captures/legacy-origin-tunnel.png", + "mesh_log": "Saved/Linux/l135/capture-legacy-origin-tunnel-mesh.log", + "render_log": "Saved/Linux/l135/capture-legacy-origin-tunnel-render.log" + } + ] +} diff --git a/Standalone/Research/WorldAssemblerM3/Linux135/captures/legacy-connected-room.png b/Standalone/Research/WorldAssemblerM3/Linux135/captures/legacy-connected-room.png new file mode 100644 index 0000000..a144bf3 Binary files /dev/null and b/Standalone/Research/WorldAssemblerM3/Linux135/captures/legacy-connected-room.png differ diff --git a/Standalone/Research/WorldAssemblerM3/Linux135/captures/legacy-neighbor-room.png b/Standalone/Research/WorldAssemblerM3/Linux135/captures/legacy-neighbor-room.png new file mode 100644 index 0000000..b51cf1f Binary files /dev/null and b/Standalone/Research/WorldAssemblerM3/Linux135/captures/legacy-neighbor-room.png differ diff --git a/Standalone/Research/WorldAssemblerM3/Linux135/captures/legacy-origin-room.png b/Standalone/Research/WorldAssemblerM3/Linux135/captures/legacy-origin-room.png new file mode 100644 index 0000000..bf3a89b Binary files /dev/null and b/Standalone/Research/WorldAssemblerM3/Linux135/captures/legacy-origin-room.png differ diff --git a/Standalone/Research/WorldAssemblerM3/Linux135/captures/legacy-origin-tunnel.png b/Standalone/Research/WorldAssemblerM3/Linux135/captures/legacy-origin-tunnel.png new file mode 100644 index 0000000..a3331d0 Binary files /dev/null and b/Standalone/Research/WorldAssemblerM3/Linux135/captures/legacy-origin-tunnel.png differ diff --git a/Standalone/Research/WorldAssemblerM3/Linux135/captures/planner-arrival-room.png b/Standalone/Research/WorldAssemblerM3/Linux135/captures/planner-arrival-room.png new file mode 100644 index 0000000..1c17da4 Binary files /dev/null and b/Standalone/Research/WorldAssemblerM3/Linux135/captures/planner-arrival-room.png differ diff --git a/Standalone/Research/WorldAssemblerM3/Linux135/captures/planner-compound-room.png b/Standalone/Research/WorldAssemblerM3/Linux135/captures/planner-compound-room.png new file mode 100644 index 0000000..9f1cc4d Binary files /dev/null and b/Standalone/Research/WorldAssemblerM3/Linux135/captures/planner-compound-room.png differ diff --git a/Standalone/Research/WorldAssemblerM3/Linux135/captures/planner-destination-chamber.png b/Standalone/Research/WorldAssemblerM3/Linux135/captures/planner-destination-chamber.png new file mode 100644 index 0000000..bc872c8 Binary files /dev/null and b/Standalone/Research/WorldAssemblerM3/Linux135/captures/planner-destination-chamber.png differ diff --git a/Standalone/Research/WorldAssemblerM3/Linux135/captures/planner-tunnel.png b/Standalone/Research/WorldAssemblerM3/Linux135/captures/planner-tunnel.png new file mode 100644 index 0000000..a5cee29 Binary files /dev/null and b/Standalone/Research/WorldAssemblerM3/Linux135/captures/planner-tunnel.png differ diff --git a/Standalone/Source/StreamBenchmarkPipeline.h b/Standalone/Source/StreamBenchmarkPipeline.h index 23711e8..0a337d9 100644 --- a/Standalone/Source/StreamBenchmarkPipeline.h +++ b/Standalone/Source/StreamBenchmarkPipeline.h @@ -3176,13 +3176,15 @@ namespace StandaloneStreamBenchmark const auto passageUpperMouthFloorDepth=[&]( const VoxelForgeCore::ResolvedLayoutPassageV1& passage) { - const double controlZ=qToMetres( - passage.controlPoints.front().positionSubvoxels[2]); - const double authoredMouthDepth=std::max(0.0,controlZ - -qToMetres(passage.upperMouthSubvoxels[2])+1.13); const auto upperStratum=std::find_if(ResolvedLayout->strates.begin(), ResolvedLayout->strates.end(),[&](const auto& stratum) {return stratum.strateIndex==passage.upperStrateIndex;}); + const double mouthCenterHeight=upperStratum!=ResolvedLayout->strates.end() + &&upperStratum->generatorKind=="SurfaceWorld"?0.98:1.13; + const double controlZ=qToMetres( + passage.controlPoints.front().positionSubvoxels[2]); + const double authoredMouthDepth=std::max(0.0,controlZ + -qToMetres(passage.upperMouthSubvoxels[2])+mouthCenterHeight); if(upperStratum==ResolvedLayout->strates.end() ||(upperStratum->generatorKind!="FloatingIslands" &&upperStratum->generatorKind!="TunnelNetwork")) @@ -3202,7 +3204,8 @@ namespace StandaloneStreamBenchmark ||distanceSquared>std::pow( qToMetres(passage.controlPoints.front().radiusSubvoxels)+2.0,2.0)) return authoredMouthDepth; - const double routeAlignedDepth=std::max(0.0,controlZ-routeHeight+1.13); + const double routeAlignedDepth=std::max(0.0, + controlZ-routeHeight+mouthCenterHeight); return std::max(authoredMouthDepth,routeAlignedDepth); }; const auto passageSplineControls=[&]( @@ -3349,61 +3352,124 @@ namespace StandaloneStreamBenchmark }; std::vector allPassageBodyFloorDepths; std::vector> allPassageSmoothRoutes; - struct FUpperMouthFloorBlend + struct FMouthFloorBlend { std::vector> receivingRoute; double mouthX=0.0,mouthY=0.0,mouthFloor=0.0; double tangentX=0.0,tangentY=0.0; }; - std::vector allUpperMouthFloorBlends; + struct FPassageFloorBlends + { + FMouthFloorBlend upper,lower; + }; + std::vector allPassageFloorBlends; allPassageBodyFloorDepths.reserve(ResolvedLayout->passages.size()); allPassageSmoothRoutes.reserve(ResolvedLayout->passages.size()); - allUpperMouthFloorBlends.reserve(ResolvedLayout->passages.size()); + allPassageFloorBlends.reserve(ResolvedLayout->passages.size()); for(const auto& passage:ResolvedLayout->passages) { const double bodyDepth=passageBodyFloorDepth(passage); allPassageBodyFloorDepths.push_back(bodyDepth); allPassageSmoothRoutes.push_back(passageSmoothRoute(passage,bodyDepth)); - FUpperMouthFloorBlend blend; + FPassageFloorBlends blends; + const auto controls=passageSplineControls(passage); + const auto floorProfile=passageSplineFloorProfile(passage); + const std::size_t routeEnd=std::clamp( + passage.lowerMouthControlIndex,1,passage.controlPoints.size()-1); + const auto segments=VoxelForgeCore::PassageSpline::BuildSegments( + controls,routeEnd,8.0,1.0,floorProfile,0.100); const auto upperStratum=std::find_if(ResolvedLayout->strates.begin(), ResolvedLayout->strates.end(),[&](const auto& stratum) {return stratum.strateIndex==passage.upperStrateIndex;}); if(upperStratum!=ResolvedLayout->strates.end() - &&upperStratum->generatorKind=="FloatingIslands") + &&(upperStratum->generatorKind=="FloatingIslands" + ||upperStratum->generatorKind=="TunnelNetwork")) { for(const auto& anchor:upperStratum->anchors) if(anchor.kind=="route_point"&&anchor.values.size()>=3) - blend.receivingRoute.push_back({ + blends.upper.receivingRoute.push_back({ qToMetres(anchor.values[0]),qToMetres(anchor.values[1]), qToMetres(anchor.values[2])}); - const auto controls=passageSplineControls(passage); - const auto floorProfile=passageSplineFloorProfile(passage); - const std::size_t routeEnd=std::clamp( - passage.lowerMouthControlIndex,1,passage.controlPoints.size()-1); - const auto segments=VoxelForgeCore::PassageSpline::BuildSegments( - controls,routeEnd,8.0,1.0,floorProfile,0.100); + if(upperStratum->generatorKind=="TunnelNetwork" + &&blends.upper.receivingRoute.size()>2) + blends.upper.receivingRoute.erase( + blends.upper.receivingRoute.begin(), + blends.upper.receivingRoute.end()-2); if(!segments.empty()) { - blend.mouthX=segments.front().a[0]; - blend.mouthY=segments.front().a[1]; - blend.mouthFloor=floorProfile.front(); - blend.tangentX=segments.front().b[0]-segments.front().a[0]; - blend.tangentY=segments.front().b[1]-segments.front().a[1]; + blends.upper.mouthX=controls.front()[0]; + blends.upper.mouthY=controls.front()[1]; + blends.upper.mouthFloor=floorProfile.front(); + blends.upper.tangentX=segments.front().b[0]-segments.front().a[0]; + blends.upper.tangentY=segments.front().b[1]-segments.front().a[1]; } + if(upperStratum->generatorKind=="TunnelNetwork") + for(const auto& edge:ResolvedLayout->cavernEdges) + if(edge.startAnchorStableId==passage.stableId+"/upper-mouth" + &&edge.controlPointsMicrometres.size()>=2) + { + blends.upper.tangentX= + (edge.controlPointsMicrometres[0][0] + -edge.controlPointsMicrometres[1][0])/1000000.0; + blends.upper.tangentY= + (edge.controlPointsMicrometres[0][1] + -edge.controlPointsMicrometres[1][1])/1000000.0; + } } - allUpperMouthFloorBlends.push_back(std::move(blend)); + const auto lowerStratum=std::find_if(ResolvedLayout->strates.begin(), + ResolvedLayout->strates.end(),[&](const auto& stratum) + {return stratum.strateIndex==passage.lowerStrateIndex;}); + if(lowerStratum!=ResolvedLayout->strates.end() + &&lowerStratum->generatorKind=="TunnelNetwork" + &&!controls.empty()&&routeEndanchors) + if(anchor.kind=="route_point"&&anchor.values.size()>=3) + blends.lower.receivingRoute.push_back({ + qToMetres(anchor.values[0]),qToMetres(anchor.values[1]), + qToMetres(anchor.values[2])}); + if(blends.lower.receivingRoute.size()>2) + blends.lower.receivingRoute.resize(2); + blends.lower.mouthX=controls[routeEnd][0]; + blends.lower.mouthY=controls[routeEnd][1]; + blends.lower.mouthFloor=floorProfile[routeEnd]; + if(!segments.empty()) + { + blends.lower.tangentX=controls[routeEnd][0] + -controls[routeEnd-1][0]; + blends.lower.tangentY=controls[routeEnd][1] + -controls[routeEnd-1][1]; + } + for(const auto& edge:ResolvedLayout->cavernEdges) + if(edge.startAnchorStableId==passage.stableId+"/lower-mouth" + &&edge.controlPointsMicrometres.size()>=2) + { + blends.lower.tangentX= + (edge.controlPointsMicrometres[1][0] + -edge.controlPointsMicrometres[0][0])/1000000.0; + blends.lower.tangentY= + (edge.controlPointsMicrometres[1][1] + -edge.controlPointsMicrometres[0][1])/1000000.0; + } + } + allPassageFloorBlends.push_back(std::move(blends)); } const auto passageFloorAtXY=[&](std::size_t passageIndex,double x,double y, double plannedFloor) { - if(passageIndex>=allUpperMouthFloorBlends.size())return plannedFloor; - const auto& blend=allUpperMouthFloorBlends[passageIndex]; + if(passageIndex>=allPassageFloorBlends.size())return plannedFloor; + const auto& blends=allPassageFloorBlends[passageIndex]; + double floor=plannedFloor; + floor=VoxelForgeCore::PassageSpline::BlendUpperMouthFloorAtXY( + blends.upper.receivingRoute,x,y,floor,blends.upper.mouthX, + blends.upper.mouthY,blends.upper.mouthFloor, + blends.upper.tangentX,blends.upper.tangentY, + VoxelForgeCore::PassageSpline::UpperMouthFloorBlendRadiusMetres,1.13); return VoxelForgeCore::PassageSpline::BlendUpperMouthFloorAtXY( - blend.receivingRoute,x,y,plannedFloor,blend.mouthX,blend.mouthY, - blend.mouthFloor, - blend.tangentX,blend.tangentY, - VoxelForgeCore::PassageSpline::UpperMouthFloorBlendRadiusMetres, - 1.13); + blends.lower.receivingRoute,x,y,floor,blends.lower.mouthX, + blends.lower.mouthY,blends.lower.mouthFloor, + -blends.lower.tangentX,-blends.lower.tangentY, + VoxelForgeCore::PassageSpline::UpperMouthFloorBlendRadiusMetres,1.13); }; const auto interpolatePassageRoute=[]( const std::vector& route,double distance) @@ -4361,7 +4427,8 @@ namespace StandaloneStreamBenchmark // The probes sit 0.18 m inside the authored radius. Add that // radius back to the signed wall distance so radius-carried ribs // remain visible; the cubic detrend removes the smooth mouth flare. - radii.push_back(sampleRadius+0.5*(left+right)); + const double measuredRadius=sampleRadius+0.5*(left+right); + radii.push_back(measuredRadius); ++audit.samples; } if(radii.size()(WindowLengthMetres/ @@ -4494,6 +4561,18 @@ namespace StandaloneStreamBenchmark constexpr double PassageShellAuditSpacingMetres=0.25; constexpr std::array PassageShellAuditDepthsMetres{{0.125,0.375}}; constexpr float PassageShellDensityTolerance=1.0e-6f; + std::vector surfaceLandingPassages(ResolvedLayout->passages.size(),false); + for(std::size_t passageIndex=0;passageIndexpassages.size(); + ++passageIndex) + { + const auto& passage=ResolvedLayout->passages[passageIndex]; + const auto upper=std::find_if(ResolvedLayout->strates.begin(), + ResolvedLayout->strates.end(),[&](const auto& stratum) + {return stratum.strateIndex==passage.upperStrateIndex;}); + surfaceLandingPassages[passageIndex]=upper!=ResolvedLayout->strates.end() + &&upper->generatorKind=="SurfaceWorld" + &&upper->lowerInterfaceCapabilities.surfaceToTunnelLandingSupport; + } const auto expectedPassageShellAt=[&](double xMetres,double yMetres, double zMetres) { @@ -4639,7 +4718,17 @@ namespace StandaloneStreamBenchmark const double floorBlend=std::clamp(floorDistance/0.25,0.0,1.0); const double smoothFloorBlend=floorBlend*floorBlend *(3.0-2.0*floorBlend); - const double thickness=0.75*smoothFade*smoothFloorBlend; + double thickness=0.75*smoothFade*smoothFloorBlend; + if(candidatePassage(std::llround( - 1130000.0*VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel - /World.CoreVoxelSizeMicrons)); - return equalPosition(authored,expected); + // Passage mouths already encode the player center 1.13 m above + // their floor, matching the route-point convention directly. + return equalPosition(authored,mouth); }; for(const auto& passage:ResolvedLayout->passages) { diff --git a/Standalone/Source/StreamingWorld.h b/Standalone/Source/StreamingWorld.h index a97541d..95db6a6 100644 --- a/Standalone/Source/StreamingWorld.h +++ b/Standalone/Source/StreamingWorld.h @@ -1580,10 +1580,13 @@ private: static_cast(D.islandSharedCacheConflictReplacements), static_cast(D.islandSharedCacheLockWaitNanoseconds)/1000.0); if(D.passageComposerProfiled) - std::printf("[PassageComposerTile] key=(%d,%d,%d,lod%d) worker_ms=%.3f samples=%llu native_field_calls=%llu features=%llu route_candidates=%llu boundary_band_queries=%llu queries=%llu passage_iterations=%llu carve_segment_visits=%llu relief_field_calls=%llu sweep_segments=%llu sweep_pairs=%llu sweep_prepare_ms=%.3f sweep_outside_pairs=%llu sweep_shell_support_skips=%llu sweep_min_updates=%llu sweep_base_samples=%llu sweep_detail_samples=%llu sweep_grid_samples=%llu sweep_no_influence_samples=%llu sweep_lookups=%llu sweep_cache_hits=%llu sweep_base_hits=%llu sweep_outside_hits=%llu sweep_missing=%llu\n", + std::printf("[PassageComposerTile] key=(%d,%d,%d,lod%d) worker_ms=%.3f samples=%llu field_calls=%llu native_cave_samples=%llu room_candidates=%llu edge_candidates=%llu features=%llu route_candidates=%llu boundary_band_queries=%llu queries=%llu passage_iterations=%llu carve_segment_visits=%llu relief_field_calls=%llu sweep_segments=%llu sweep_pairs=%llu sweep_prepare_ms=%.3f sweep_outside_pairs=%llu sweep_shell_support_skips=%llu sweep_min_updates=%llu sweep_base_samples=%llu sweep_detail_samples=%llu sweep_grid_samples=%llu sweep_no_influence_samples=%llu sweep_lookups=%llu sweep_cache_hits=%llu sweep_base_hits=%llu sweep_outside_hits=%llu sweep_missing=%llu\n", Event.Key.Coord.X,Event.Key.Coord.Y,Event.Key.Coord.Z,Event.Key.Level, Event.WorkerMilliseconds,static_cast(Event.DensitySamples), static_cast(D.fieldCalls), + static_cast(D.nativeCaveFieldSamples), + static_cast(D.cavernRoomCandidates), + static_cast(D.cavernEdgeCandidates), static_cast(D.passageTileFeatureCount), static_cast(D.passageTileRouteSegmentCandidates), static_cast(D.resolvedBoundaryBandQueries), diff --git a/Standalone/Source/WorldAssemblyPrototype.h b/Standalone/Source/WorldAssemblyPrototype.h index 08e08aa..d9ae1a8 100644 --- a/Standalone/Source/WorldAssemblyPrototype.h +++ b/Standalone/Source/WorldAssemblyPrototype.h @@ -11,9 +11,11 @@ #include #include #include +#include #include #include #include +#include #include #include @@ -283,6 +285,330 @@ namespace StandaloneWorldAssemblyPrototype } } } + std::uint64_t terrainPathCount=0,terrainGridSamples=0; + std::int32_t terrainGridStepQ=0; + const auto appendTerrainFallback=[&](std::int32_t stepQ) + { + if(!fallbackSearch + ||!((arrivalRadiusSubvoxels>0)!=(departureRadiusSubvoxels>0))) + return; + { + // A bounded one-mouth world can have no walkable straight chord even + // when a contour route exists. Link real ground columns only when their + // grade meets the same limit, then use the ordinary capsule and support + // sweep to accept or reject each resulting path. + struct FGroundNode + { + std::int64_t ground=0; + bool supportReachable=false; + }; + struct FQueueEntry + { + std::uint64_t cost=0; + std::size_t index=0; + bool operator>(const FQueueEntry& other) const + {return cost>other.cost||(cost==other.cost&&index>other.index);} + }; + constexpr std::array,5> supportOffsets{{ + {{0,0}},{{ProbeRadiusQ,0}},{{-ProbeRadiusQ,0}}, + {{0,ProbeRadiusQ}},{{0,-ProbeRadiusQ}}}}; + constexpr std::int64_t SupportStepQ=WorldAssemblySubvoxelsPerVoxel/4; + const std::int64_t routeBaseLiftQ=constraints.minimumClearanceSubvoxels/4; + const auto hasRouteSupportAt=[&](std::int64_t centerX,std::int64_t centerY, + std::int64_t footZ) + { + std::uint32_t reachable=0; + for(const auto& offset:supportOffsets) + { + for(std::int64_t depth=SupportStepQ;depth<=SupportDepthQ; + depth+=SupportStepQ) + { + if(finalField.point(centerX+offset[0],centerY+offset[1], + footZ-depth).densitySubvoxels<0) + {++reachable;break;} + } + } + return reachable>=constraints.minimumSupportProbes; + }; + const auto tryTerrainPaths=[&](std::int32_t terrainStepQ) + { + terrainPathCount=0;terrainGridSamples=0;terrainGridStepQ=0; + const std::int64_t boundedRadius=std::max(arrivalRadiusSubvoxels, + departureRadiusSubvoxels); + const std::int32_t boundRadiusVoxels=static_cast( + (boundedRadius+WorldAssemblySubvoxelsPerVoxel-1) + /WorldAssemblySubvoxelsPerVoxel); + const std::int32_t searchRadiusVoxels=std::clamp( + boundRadiusVoxels+64,64,256); + const std::int64_t searchRadiusQ=static_cast( + searchRadiusVoxels)*WorldAssemblySubvoxelsPerVoxel; + const std::int32_t radiusSteps=static_cast( + searchRadiusQ/terrainStepQ); + const std::int32_t dimension=radiusSteps*2+1; + const std::size_t nodeCount=static_cast(dimension)*dimension; + const auto indexOf=[dimension](std::int32_t x,std::int32_t y) + {return static_cast(y)*dimension+x;}; + std::vector grid(nodeCount); + const auto safeGroundAt=[&](std::int64_t centerX,std::int64_t centerY) + { + constexpr std::int64_t FootprintStepQ=WorldAssemblySubvoxelsPerVoxel/2; + std::int64_t highestGround=std::numeric_limits::min(); + for(std::int64_t oy=-PlayerRadiusQ;oy<=PlayerRadiusQ; + oy+=FootprintStepQ) + for(std::int64_t ox=-PlayerRadiusQ;ox<=PlayerRadiusQ; + ox+=FootprintStepQ) + { + if(ox*ox+oy*oy>PlayerRadiusQ*PlayerRadiusQ)continue; + highestGround=std::max(highestGround,finalField.point( + centerX+ox,centerY+oy,0).groundHeightSubvoxels); + } + return FGroundNode{highestGround, + hasRouteSupportAt(centerX,centerY, + highestGround+routeBaseLiftQ)}; + }; + for(std::int32_t gy=0;gy(gx-radiusSteps) + *terrainStepQ; + const std::int64_t yQ=static_cast(gy-radiusSteps) + *terrainStepQ; + grid[indexOf(gx,gy)]=safeGroundAt(xQ,yQ); + ++terrainGridSamples; + } + for(std::size_t directionIndex=0;directionIndex<1;++directionIndex) + { + std::vector costs(nodeCount, + std::numeric_limits::max()); + std::vector parent(nodeCount,nodeCount); + std::priority_queue, + std::greater> open; + std::vector edgeSupportCache(nodeCount*4,-1); + for(std::int32_t gy=0;gy::max(); + std::uint64_t goalDistanceSquared=std::numeric_limits::max(); + while(!open.empty()) + { + const FQueueEntry current=open.top();open.pop(); + if(current.cost!=costs[current.index])continue; + if(goalCost!=std::numeric_limits::max() + &¤t.cost>goalCost)break; + const std::int32_t gx=static_cast(current.index%dimension); + const std::int32_t gy=static_cast(current.index/dimension); + if(parent[current.index]!=nodeCount) + { + const std::int64_t xQ=static_cast(gx-radiusSteps) + *terrainStepQ; + const std::int64_t yQ=static_cast(gy-radiusSteps) + *terrainStepQ; + const std::uint64_t distanceSquared=static_cast( + xQ*xQ+yQ*yQ); + if(distanceSquared<=static_cast( + boundedRadius*boundedRadius) + &&(current.cost=dimension||ny>=dimension)continue; + const std::size_t next=indexOf(nx,ny); + if(!grid[next].supportReachable)continue; + const std::int64_t horizontalQ=static_cast( + std::llround(terrainStepQ*std::hypot(static_cast(ox), + static_cast(oy)))); + const std::int64_t riseQ=std::abs(grid[next].ground + -grid[current.index].ground); + if(horizontalQ<=0||riseQ*65536>horizontalQ*GradeQ16)continue; + const std::int32_t edgeDx=nx-gx,edgeDy=ny-gy; + std::int32_t baseX=gx,baseY=gy,edgeDirection=0; + if(edgeDx==0) + {baseY=std::min(gy,ny);edgeDirection=1;} + else if(edgeDy==0) + {baseX=std::min(gx,nx);edgeDirection=0;} + else if(edgeDx==edgeDy) + {baseX=std::min(gx,nx);baseY=std::min(gy,ny);edgeDirection=2;} + else + {baseX=std::max(gx,nx);baseY=std::min(gy,ny);edgeDirection=3;} + const std::size_t edgeCacheIndex=indexOf(baseX,baseY)*4 + +static_cast(edgeDirection); + if(edgeSupportCache[edgeCacheIndex]<0) + { + const std::uint64_t intervals=std::max(2, + (static_cast(horizontalQ) + +WorldAssemblySubvoxelsPerVoxel/8-1) + /(WorldAssemblySubvoxelsPerVoxel/8)); + bool supported=true; + const std::int64_t fromX=static_cast( + gx-radiusSteps)*terrainStepQ; + const std::int64_t fromY=static_cast( + gy-radiusSteps)*terrainStepQ; + const std::int64_t toX=static_cast( + nx-radiusSteps)*terrainStepQ; + const std::int64_t toY=static_cast( + ny-radiusSteps)*terrainStepQ; + for(std::uint64_t sample=1;sample(sample) + /static_cast(intervals); + const std::int64_t y=fromY+(toY-fromY) + *static_cast(sample) + /static_cast(intervals); + const std::int64_t footZ=grid[current.index].ground + +(grid[next].ground-grid[current.index].ground) + *static_cast(sample) + /static_cast(intervals) + +routeBaseLiftQ; + if(!hasRouteSupportAt(x,y,footZ)) + {supported=false;break;} + } + edgeSupportCache[edgeCacheIndex]=supported?1:0; + } + if(edgeSupportCache[edgeCacheIndex]==0)continue; + const std::uint64_t edgeCost=static_cast( + horizontalQ+riseQ*4); + if(current.cost>std::numeric_limits::max()-edgeCost) + continue; + const std::uint64_t nextCost=current.cost+edgeCost; + if(nextCost>=costs[next])continue; + costs[next]=nextCost;parent[next]=current.index; + open.push({nextCost,next}); + } + } + if(goal==nodeCount)continue; + std::vector path; + for(std::size_t cursor=goal;cursor!=nodeCount;cursor=parent[cursor]) + { + path.push_back(cursor); + if(parent[cursor]==nodeCount)break; + if(path.size()>nodeCount)break; + } + if(path.size()<2||parent[path.back()]!=nodeCount)continue; + std::reverse(path.begin(),path.end()); + if(arrivalRadiusSubvoxels>0) + std::reverse(path.begin(),path.end()); + Candidate candidate; + candidate.centerX=static_cast( + (static_cast(path.front()%dimension)-radiusSteps) + *terrainStepQ); + candidate.centerY=static_cast( + (static_cast(path.front()/dimension)-radiusSteps) + *terrainStepQ); + candidate.direction=static_cast(directionIndex); + candidate.score=static_cast(goalCost/path.size()); + candidate.endpointProximityCost=goalDistanceSquared + *(arrivalRadiusSubvoxels>0?16ull:1ull); + candidate.points.reserve(path.size()); + bool gradePass=true; + for(std::size_t pointIndex=0;pointIndex(index%dimension); + const std::int32_t gy=static_cast(index/dimension); + const std::int64_t xQ=static_cast(gx-radiusSteps) + *terrainStepQ; + const std::int64_t yQ=static_cast(gy-radiusSteps) + *terrainStepQ; + WorldAssemblyRoutePointV1 point; + point.positionSubvoxels={xQ,yQ, + grid[index].ground+routeBaseLiftQ+HalfHeightQ}; + point.radiusSubvoxels=RouteRadiusQ; + point.stableFeatureId=WorldAssemblyFeatureId(recipe.seed, + xQ,yQ, + 0x52550000u+static_cast(pointIndex)); + if(pointIndex>0) + { + const auto& previous=candidate.points.back().positionSubvoxels; + const std::int64_t dx=point.positionSubvoxels[0]-previous[0]; + const std::int64_t dy=point.positionSubvoxels[1]-previous[1]; + const std::int64_t dz=std::abs(point.positionSubvoxels[2]-previous[2]); + const std::int64_t run=static_cast(std::llround( + std::hypot(static_cast(dx),static_cast(dy)))); + if(run<=0||dz*65536>run*GradeQ16)gradePass=false; + } + candidate.points.push_back(point); + } + if(!gradePass)continue; + ++terrainPathCount; + const std::array,8> lateralNudges{{ + {{-ProbeRadiusQ,-ProbeRadiusQ}},{{-ProbeRadiusQ,0}}, + {{0,-ProbeRadiusQ}},{{ProbeRadiusQ,-ProbeRadiusQ}}, + {{-ProbeRadiusQ,ProbeRadiusQ}},{{0,ProbeRadiusQ}}, + {{ProbeRadiusQ,0}},{{ProbeRadiusQ,ProbeRadiusQ}}}}; + for(std::size_t nudgeIndex=0;nudgeIndex(nudgeIndex+1); + shifted.score+=static_cast(nudgeIndex+1); + bool shiftedGradePass=true; + for(std::size_t pointIndex=0;pointIndex1 + ?static_cast(pointIndex)*65536 + /static_cast(shifted.points.size()-1):0; + const std::int64_t t2Q16=tQ16*tQ16/65536; + const std::int64_t oneMinusQ16=65536-tQ16; + const std::int64_t oneMinus2Q16= + oneMinusQ16*oneMinusQ16/65536; + const std::int64_t weightQ16=std::min(65536, + 16*t2Q16*oneMinus2Q16/65536); + auto& point=shifted.points[pointIndex]; + point.positionSubvoxels[0]+=lateralNudges[nudgeIndex][0] + *weightQ16/65536; + point.positionSubvoxels[1]+=lateralNudges[nudgeIndex][1] + *weightQ16/65536; + const auto ground=safeGroundAt(point.positionSubvoxels[0], + point.positionSubvoxels[1]); + if(!ground.supportReachable)shiftedGradePass=false; + point.positionSubvoxels[2]=ground.ground+routeBaseLiftQ + +HalfHeightQ; + point.stableFeatureId=WorldAssemblyFeatureId(recipe.seed, + point.positionSubvoxels[0],point.positionSubvoxels[1], + 0x52550000u+static_cast( + (nudgeIndex+1)*0x10000u+pointIndex)); + if(pointIndex==0)continue; + const auto& previous=shifted.points[pointIndex-1] + .positionSubvoxels; + const std::int64_t dx=point.positionSubvoxels[0]-previous[0]; + const std::int64_t dy=point.positionSubvoxels[1]-previous[1]; + const std::int64_t dz=std::abs(point.positionSubvoxels[2]-previous[2]); + const std::int64_t run=static_cast(std::llround( + std::hypot(static_cast(dx),static_cast(dy)))); + if(run<=0||dz*65536>run*GradeQ16)shiftedGradePass=false; + } + if(!shiftedGradePass)continue; + candidates.push_back(std::move(shifted)); + ++terrainPathCount; + } + candidates.push_back(std::move(candidate)); + } + if(terrainPathCount>0)terrainGridStepQ=terrainStepQ; + }; + tryTerrainPaths(stepQ); + tested+=terrainPathCount; + std::printf("[WorldAssemblerRouteTerrainSearch] grid_step_m=%.3f grid_samples=%llu candidate_paths=%llu grade_limit_deg=15.000 status=%s\n", + terrainGridStepQ*coreVoxelMetres/WorldAssemblySubvoxelsPerVoxel, + static_cast(terrainGridSamples), + static_cast(terrainPathCount), + terrainPathCount>0?"FOUND":"EMPTY"); + } + }; if(gradeRejected>0||placementRejected>0) { const double metresPerSubvoxel=static_cast(coreVoxelSizeMicrons) @@ -318,8 +644,33 @@ namespace StandaloneWorldAssemblyPrototype }); std::uint64_t sweepAttempt=0,repairAttempt=0; VoxelForgeCore::WorldAssemblyRouteAuditResult lastAudit{}; - for(auto& candidate:candidates) + std::size_t candidateCursor=0; + constexpr std::array TerrainFallbackSteps{{1024,512,256,128}}; + std::size_t terrainFallbackStep=0; + for(;;) { + if(candidateCursor>=candidates.size()) + { + if(terrainFallbackStep0)!=(departureRadiusSubvoxels>0))) + { + const std::size_t firstTerrainCandidate=candidates.size(); + appendTerrainFallback(TerrainFallbackSteps[terrainFallbackStep++]); + std::stable_sort(candidates.begin()+firstTerrainCandidate, + candidates.end(),[](const Candidate& a,const Candidate& b) + { + if(a.endpointProximityCost!=b.endpointProximityCost) + return a.endpointProximityCost(candidate.score)); return true; } + if(liftQ==0&&audit.blockedCapsuleSamples==0 + &&audit.unsupportedCenterSamples>0) + { + constexpr std::int64_t SupportAlignmentStepQ=16; + const std::int64_t lowerLimit=std::max(0, + audit.minimumClearanceSubvoxels + -constraints.minimumClearanceSubvoxels); + for(std::int64_t lowerQ=SupportAlignmentStepQ; + lowerQ<=lowerLimit;lowerQ+=SupportAlignmentStepQ) + { + for(std::size_t pointIndex=0;pointIndex(lowerQ), + static_cast(lowerLimit)); + const double metresPerSubvoxel=coreVoxelMetres + /WorldAssemblySubvoxelsPerVoxel; + std::printf("[WorldAssemblerRouteAccepted] arrival_xy_m=(%.3f,%.3f) departure_xy_m=(%.3f,%.3f) endpoint_proximity_cost=%llu average_grade_q16=%lld\n", + candidate.points.front().positionSubvoxels[0]*metresPerSubvoxel, + candidate.points.front().positionSubvoxels[1]*metresPerSubvoxel, + candidate.points.back().positionSubvoxels[0]*metresPerSubvoxel, + candidate.points.back().positionSubvoxels[1]*metresPerSubvoxel, + static_cast(candidate.endpointProximityCost), + static_cast(candidate.score)); + return true; + } + if(loweredAudit.blockedCapsuleSamples>0 + ||loweredAudit.minimumClearanceSubvoxels + =MaximumRepairLiftQ)break; const std::int64_t needed=std::max(1, constraints.minimumClearanceSubvoxels-audit.minimumClearanceSubvoxels); @@ -358,6 +745,62 @@ namespace StandaloneWorldAssemblyPrototype liftQ=nextLift; ++repairAttempt; } + if(lastAudit.unsupportedCenterSamples>0&&sweepAttempt<5) + { + const Heightfield2D diagnosticField(recipe,stratum); + const std::int64_t sampleSpacing=std::min( + constraints.sweepSpacingSubvoxels, + WorldAssemblySubvoxelsPerVoxel/2); + const std::int64_t supportSpacing=std::min(sampleSpacing, + WorldAssemblySubvoxelsPerVoxel/4); + constexpr std::array,5> supportOffsets{{ + {{0,0}},{{ProbeRadiusQ,0}},{{-ProbeRadiusQ,0}}, + {{0,ProbeRadiusQ}},{{0,-ProbeRadiusQ}}}}; + for(std::size_t segment=1;segment(1, + static_cast(std::llround(std::sqrt( + static_cast(dx*dx+dy*dy+dz*dz))))); + const std::uint64_t intervals=std::max(1, + (length+sampleSpacing-1)/sampleSpacing); + for(std::uint64_t step=segment==1?0:1;step<=intervals;++step) + { + const auto interpolate=[&](std::size_t axis) + {return from[axis]+(to[axis]-from[axis]) + *static_cast(step)/static_cast(intervals);}; + const std::int64_t x=interpolate(0),y=interpolate(1),z=interpolate(2); + const std::int64_t footZ=z-PlayerHeightQ/2; + std::array reachedDepth{}; + for(std::size_t probe=0;probe(std::count_if( + reachedDepth.begin(),reachedDepth.end(),[](auto depth){return depth>0;})); + if(supported>=constraints.minimumSupportProbes)continue; + const double metresPerSubvoxel=coreVoxelMetres + /WorldAssemblySubvoxelsPerVoxel; + std::printf("[WorldAssemblerRouteSupportSample] ordinal=%llu x_m=%.3f y_m=%.3f center_z_m=%.3f supported=%u/5 depths_q=(%lld,%lld,%lld,%lld,%lld)\n", + static_cast(sweepAttempt),x*metresPerSubvoxel, + y*metresPerSubvoxel,z*metresPerSubvoxel,supported, + static_cast(reachedDepth[0]), + static_cast(reachedDepth[1]), + static_cast(reachedDepth[2]), + static_cast(reachedDepth[3]), + static_cast(reachedDepth[4])); + segment=stratum.route.size();break; + } + } + } if(sweepAttempt<3) std::printf("[WorldAssemblerRouteCandidateRejected] ordinal=%llu blocked=%llu unsupported=%llu grade_failures=%llu minimum_clearance_q=%lld\n", static_cast(sweepAttempt), @@ -1183,6 +1626,7 @@ namespace StandaloneWorldAssemblyPrototype static_cast(micrometres)*WorldAssemblySubvoxelsPerVoxel /layout.coreVoxelSizeMicrometres)); }; + constexpr std::int64_t PlayerCenterAboveFloorMicrometres=1130000; for(const FStrateSlot& slot:owner.Manager->GetLayout()) { if(!slot.Definition @@ -1192,10 +1636,24 @@ namespace StandaloneWorldAssemblyPrototype [&](const auto& item){return item.strateIndex==slot.StrateIndex;}); if(stratum==layout.strates.end() ||!IsNativeCaveFieldStratum(*stratum))continue; - const bool hasRoutePoints=std::count_if(stratum->anchors.begin(), + const std::string routePrefix="tunnel-network/strate-" + +std::to_string(slot.StrateIndex)+"/"; + const bool hasPlannedArrival=std::any_of(layout.cavernEdges.begin(), + layout.cavernEdges.end(),[&](const auto& edge) + {return edge.strateIndex==slot.StrateIndex + &&edge.stableId==routePrefix+"route-edge-0";}); + const bool hasPlannedDeparture=std::any_of(layout.cavernEdges.begin(), + layout.cavernEdges.end(),[&](const auto& edge) + {return edge.strateIndex==slot.StrateIndex + &&edge.stableId==routePrefix+"departure-edge";}); + if(!hasPlannedArrival||!hasPlannedDeparture)continue; + // Route-point anchors also drive the passage-mouth floor blend. The + // native cave planner replaces the legacy finite-strate route, so + // retaining its old anchors would join the passage floor to a route + // that is no longer present in the composed cave field. + stratum->anchors.erase(std::remove_if(stratum->anchors.begin(), stratum->anchors.end(),[](const auto& anchor) - {return anchor.kind=="route_point"&&anchor.values.size()>=3;})>=2; - if(hasRoutePoints)continue; + {return anchor.kind=="route_point";}),stratum->anchors.end()); const ResolvedLayoutPassageV1* arrival=nullptr; const ResolvedLayoutPassageV1* departure=nullptr; for(const auto& passage:layout.passages) @@ -1222,9 +1680,7 @@ namespace StandaloneWorldAssemblyPrototype if(std::abs(a[axis]-b[axis])>2)return false; return true; }; - constexpr std::int64_t PlayerCenterAboveFloorMicrometres=1130000; auto start=arrival->lowerMouthSubvoxels; - start[2]+=toCoreSubvoxels(PlayerCenterAboveFloorMicrometres); appendRoutePoint("000-arrival",start); for(std::size_t edgeIndex=0;edgeIndex<256;++edgeIndex) { @@ -1239,6 +1695,10 @@ namespace StandaloneWorldAssemblyPrototype if(edgeIndex==0) { auto inner=edge->controlPointsMicrometres.front(); + // Keep the exact authored mouth anchor and use the planner + // edge's floor height for the first cave-side walking point. + // Passage composition uses these same two points to blend the + // floor profile into the native cave route. inner[2]+=PlayerCenterAboveFloorMicrometres; const std::array point={ toCoreSubvoxels(inner[0]),toCoreSubvoxels(inner[1]), @@ -1266,12 +1726,10 @@ namespace StandaloneWorldAssemblyPrototype toCoreSubvoxels(inner[0]),toCoreSubvoxels(inner[1]), toCoreSubvoxels(inner[2])}; auto end=departure->upperMouthSubvoxels; - end[2]+=toCoreSubvoxels(PlayerCenterAboveFloorMicrometres); if(!sameRoutePoint(innerPoint,end)) appendRoutePoint("998-departure-inner",innerPoint); } auto end=departure->upperMouthSubvoxels; - end[2]+=toCoreSubvoxels(PlayerCenterAboveFloorMicrometres); appendRoutePoint("999-departure",end); } return layout.finalize(error); diff --git a/Standalone/Tools/verify/baselines/owner-hash/canonical/owner-hash-floating-islands-style2-v236.baseline b/Standalone/Tools/verify/baselines/owner-hash/canonical/owner-hash-floating-islands-style2-v236.baseline new file mode 100644 index 0000000..98d4cc8 --- /dev/null +++ b/Standalone/Tools/verify/baselines/owner-hash/canonical/owner-hash-floating-islands-style2-v236.baseline @@ -0,0 +1,12 @@ +[PipelineFullDetailHash] tiles=960 density=0x91931a57a067fd92 mesh=0xa818654be89790ac payload=0x370ff80451105a39 topology=0x06e507557aa70173 +[PipelineWholeViewHash] tiles=2370 density=0x7111b4b743e03c95 mesh=0x8c7240939db21b88 payload=0x3981ff5ca7076219 topology=0x07947d2e691b2e55 +[PipelineLODHash] lod=0 tiles=512 density=0x2fd4c78685a43cfe mesh=0x50478cff1c1f8ca0 payload=0x54e48e57e0af26de topology=0x860c011cfdb9833a +[PipelineLODHash] lod=1 tiles=448 density=0x4b8d9a6dcd2942ad mesh=0x17e89b7087bfe711 payload=0x2389c109eb780db6 topology=0x7eccd4cf560d0630 +[PipelineLODHash] lod=2 tiles=320 density=0x48d4c21a852e6e21 mesh=0x5516a17618d142d5 payload=0x59cda38ab9ca6b7a topology=0x15ae353bd835e6a1 +[PipelineLODHash] lod=3 tiles=208 density=0x09b4811098a48c8d mesh=0xbac3fb6d54bca8e9 payload=0xca779555d55be411 topology=0xc63e8ee46890a03a +[PipelineLODHash] lod=4 tiles=240 density=0xfb11591772a3d18e mesh=0xebf2baee8764591b payload=0xe491d7ac9c0061ad topology=0xd618ac33b95ce494 +[PipelineLODHash] lod=5 tiles=144 density=0x2f8b7b7fb1e67258 mesh=0x4cd508cbe97344fd payload=0x2b6e96a6050458c9 topology=0x4ff6d0eda8988573 +[PipelineLODHash] lod=6 tiles=96 density=0x0114a34c8b8d6113 mesh=0xb1fec4d01ae1fa44 payload=0x3e50cfd429b8e950 topology=0x08090c96d15c1618 +[PipelineLODHash] lod=7 tiles=96 density=0x3836ed8cfce73418 mesh=0x40bc6589c5fc1aac payload=0x677349bcae16790f topology=0x36e3aa53192e6a6b +[PipelineLODHash] lod=8 tiles=306 density=0x144f3c1fd722708b mesh=0xef77b28bf7fd9453 payload=0xe3f47b37fcd61489 topology=0x5a3c73e295494e04 +[PipelineExactQueryHash] probes=9 density=0x7b7c50ac24ccadcc unclamped=0x7b7c50ac24ccadcc detail=0xefcba20c0c70c415 collision=0xe604823a249029bf path=direct-Core-queries diff --git a/Standalone/Tools/verify/baselines/owner-hash/canonical/owner-hash-surface-world-style1-v236.baseline b/Standalone/Tools/verify/baselines/owner-hash/canonical/owner-hash-surface-world-style1-v236.baseline new file mode 100644 index 0000000..e43aaf0 --- /dev/null +++ b/Standalone/Tools/verify/baselines/owner-hash/canonical/owner-hash-surface-world-style1-v236.baseline @@ -0,0 +1,12 @@ +[PipelineFullDetailHash] tiles=960 density=0xace56c8f205238ad mesh=0xa9c603a1248de6b8 payload=0xea55f1f4fcc6a894 topology=0x5ea7bc78a1b678ae +[PipelineWholeViewHash] tiles=2370 density=0x826704417f556bb8 mesh=0x3dc31324d910ad2b payload=0x32cd1847a8e05d0a topology=0xb800610aa85336d7 +[PipelineLODHash] lod=0 tiles=512 density=0xcd6ab28cfa504aca mesh=0xf03dc80a669fca31 payload=0x397aded1c3d2d297 topology=0xb4a1dc84fabc1fe3 +[PipelineLODHash] lod=1 tiles=448 density=0xce6e713828b2a20a mesh=0x1cf408c25d67d18c payload=0x8a2b5d20571c997e topology=0x51cd985fe8e7d100 +[PipelineLODHash] lod=2 tiles=320 density=0xd7f003d75d73e5b1 mesh=0x4e9c56918da506ae payload=0x3867c5915b409d6d topology=0x02a2a7c0ee9ac809 +[PipelineLODHash] lod=3 tiles=208 density=0xce02d09814028b15 mesh=0xe4c2addc4c54c439 payload=0x7e25d9eee6469c5a topology=0xe603e61c9b9bb733 +[PipelineLODHash] lod=4 tiles=240 density=0x0be8798407724eaf mesh=0x131c303cbbe51434 payload=0x2a90ecf0a2411ca0 topology=0xe3e054a371152e08 +[PipelineLODHash] lod=5 tiles=144 density=0xf20897ad272103a9 mesh=0x13d00b6253ae1f72 payload=0x0db3880693a962fa topology=0x462bd87a80f15e89 +[PipelineLODHash] lod=6 tiles=96 density=0xc040fcac457d6313 mesh=0x1ed132eeb718fb62 payload=0xcd0214e35577c6e3 topology=0x2c56c69e6fdcfd64 +[PipelineLODHash] lod=7 tiles=96 density=0xc2d419446355fdb8 mesh=0x4ea44452574fc84b payload=0xfd9779bedfc9ae0f topology=0x8948546e770c9b72 +[PipelineLODHash] lod=8 tiles=306 density=0x62db31696f2da831 mesh=0xd373f9e9481532fe payload=0x540a60526e0e3e46 topology=0xc725cb901e6dc335 +[PipelineExactQueryHash] probes=9 density=0xc949c4df2ce6f9d9 unclamped=0xc949c4df2ce6f9d9 detail=0x114c501e2d830d94 collision=0xe6011d3a248d4849 path=direct-Core-queries diff --git a/Standalone/Tools/verify/verify.py b/Standalone/Tools/verify/verify.py index f9d4f2d..228ec8c 100644 --- a/Standalone/Tools/verify/verify.py +++ b/Standalone/Tools/verify/verify.py @@ -125,11 +125,11 @@ RECIPE_RE = re.compile( def owner_hash_baseline(recipe: str, style: int) -> tuple[Path, str]: if recipe == "surface-world" and style == 1: - return (BASELINES / "owner-hash/canonical/owner-hash-surface-world-style1-v230.baseline", - "L134-v230-continuous-backbone") + return (BASELINES / "owner-hash/canonical/owner-hash-surface-world-style1-v236.baseline", + "L135-v236-cave-mouth-joints") if recipe == "floating-islands" and style == 2: - return (BASELINES / "owner-hash/canonical/owner-hash-floating-islands-style2-v230.baseline", - "L134-v230-continuous-backbone") + return (BASELINES / "owner-hash/canonical/owner-hash-floating-islands-style2-v236.baseline", + "L135-v236-cave-mouth-joints") raise VerificationFailure(f"no pinned owner hash baseline for {recipe} style {style}")