diff --git a/Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h b/Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h index 6dd7823..24bb014 100644 --- a/Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h +++ b/Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h @@ -402,7 +402,8 @@ namespace VoxelForgeCore // Version 208 keeps the crater tread scoped to style-zero FI mouths. // Version 210 keeps bulk native-cave sampling on the scalar field path at strata edges. // Version 211 makes coordinate biasing in the shared cell hash unsigned, removing signed-overflow UB. - inline constexpr std::uint32_t WorldAssemblyEvaluatorVersion = 211; + // Version 212 balances generated entry routes against existing walk lanes and preserves their shared treads. + inline constexpr std::uint32_t WorldAssemblyEvaluatorVersion = 212; enum class FieldChannel : std::uint32_t { diff --git a/Standalone/Core/src/ResolvedLayoutInternal.h b/Standalone/Core/src/ResolvedLayoutInternal.h index 39dda10..535877a 100644 --- a/Standalone/Core/src/ResolvedLayoutInternal.h +++ b/Standalone/Core/src/ResolvedLayoutInternal.h @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -2459,18 +2460,29 @@ bool PlanResolvedLayoutPassagesV1(ResolvedLayoutSnapshotV3& layout,std::int32_t } std::uint32_t phase=static_cast(PassageSeed(seed, upper.strateIndex,lower.strateIndex,connection,0x50484153u)%16); - if(hasUpperRoute) + // 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")) { std::int64_t bestAlignment=std::numeric_limits::min(); const std::uint32_t hashedPhase=phase; const std::int64_t phaseX=style==0&&anchoredIslandPassage &&!anchoredSpiral&&passageAnchor ?upperMouth[0]-passageAnchor->values[0] - :(hasUpperTangent?upperTangent[0]:upperMouth[0]); + :(hasUpperTangent?upperTangent[0]:upperMouth[0] + -(anchoredIslandPassage&&passageAnchor + ?passageAnchor->values[0]:0)); const std::int64_t phaseY=style==0&&anchoredIslandPassage &&!anchoredSpiral&&passageAnchor ?upperMouth[1]-passageAnchor->values[1] - :(hasUpperTangent?upperTangent[1]:upperMouth[1]); + :(hasUpperTangent?upperTangent[1]:upperMouth[1] + -(anchoredIslandPassage&&passageAnchor + ?passageAnchor->values[1]:0)); for(std::uint32_t offset=0;offset<16;++offset) { const std::uint32_t candidate=static_cast( @@ -2528,7 +2540,7 @@ bool PlanResolvedLayoutPassagesV1(ResolvedLayoutSnapshotV3& layout,std::int32_t clockwise=clockwiseTangentX*upperTangent[0] +clockwiseTangentY*upperTangent[1]>=0.0; } - const int direction=clockwise?1:-1; + int direction=clockwise?1:-1; constexpr std::int64_t TargetGradeQ16=12500; // leave margin below the 15-degree helix contract. const std::int64_t wormRadialWobble=std::min( std::max(0,wander/3),2*qPerMetre); @@ -2597,6 +2609,267 @@ bool PlanResolvedLayoutPassagesV1(ResolvedLayoutSnapshotV3& layout,std::int32_t static_cast(std::llround(centerX+radius*directions[directionIndex][0]/65536.0)), static_cast(std::llround(centerY+radius*directions[directionIndex][1]/65536.0))}; }; + if(generatedIslandWormEntry) + { + struct FPhaseScore + { + std::uint32_t phase=0; + int direction=1; + std::uint64_t routeLength=0; + double clearance=0.0; + double tangent=0.0; + }; + std::array phaseScores{}; + std::size_t phaseScoreCount=0; + const std::uint32_t hashedPhase=phase; + const int hashedDirection=direction; + const std::int64_t entryVerticalDistance=std::abs( + lowerMouth[2]-upperMouth[2]); + const std::int64_t entryRouteGradeQ16=profiledSurfaceJoin?8000 + :style==0&&anchoredIslandPassage&&!anchoredSpiral?15500 + :TargetGradeQ16; + const auto candidateRouteLength=[&](std::uint32_t candidate,int candidateDirection) + { + std::vector> candidateXY; + const auto append=[&](std::array point) + {if(candidateXY.empty()||candidateXY.back()!=point) + candidateXY.push_back(point);}; + append({upperMouth[0],upperMouth[1]}); + append(ringPoint(candidate)); + const auto measure=[&]() + { + std::uint64_t length=0; + for(std::size_t i=1;i(entryRouteGradeQ16) + >=static_cast(entryVerticalDistance)*65536ull)break; + for(int step=1;step<=16;++step) + { + int directionIndex=static_cast(candidate) + +candidateDirection*step; + directionIndex=(directionIndex%16+16)%16; + append(ringPoint(static_cast(directionIndex), + loops+(step==16?1u:0u))); + } + ++loops; + } + if(loops>=256)return std::uint64_t{0}; + const std::int64_t mouthPlanSeparation=distanceQ( + lowerMouth[0]-upperMouth[0],lowerMouth[1]-upperMouth[1]); + if(!hasLowerTangent&&!anchoredSpiral&&pathStyle==1u&&loops<=1 + &&mouthPlanSeparation<=2*mouthRadius + &&entryVerticalDistance<=2*mouthRadius+qPerMetre/2) + { + double bestExitScore=-std::numeric_limits::infinity(); + std::uint32_t lowerPhase=candidate; + const auto firstRing=ringPoint(candidate); + for(std::uint32_t exitCandidate=0;exitCandidate<16;++exitCandidate) + { + if(exitCandidate==candidate)continue; + const std::uint32_t previous=static_cast( + (static_cast(exitCandidate)-candidateDirection+16)%16); + const auto terminal=ringPoint(exitCandidate,loops); + const auto beforeTerminal=ringPoint(previous,loops); + const double incomingX=static_cast(terminal[0]-beforeTerminal[0]); + const double incomingY=static_cast(terminal[1]-beforeTerminal[1]); + const double outgoingX=static_cast(lowerMouth[0]-terminal[0]); + const double outgoingY=static_cast(lowerMouth[1]-terminal[1]); + const double incomingLength=std::hypot(incomingX,incomingY); + const double outgoingLength=std::hypot(outgoingX,outgoingY); + if(incomingLength<=1.0e-6||outgoingLength<=1.0e-6)continue; + const double alignment=(incomingX*outgoingX+incomingY*outgoingY) + /(incomingLength*outgoingLength); + const double separation=std::hypot( + static_cast(terminal[0]-firstRing[0]), + static_cast(terminal[1]-firstRing[1])); + const double score=alignment+0.20*std::clamp( + separation/(2.0*qPerMetre),0.0,2.0); + if(score>bestExitScore) + {bestExitScore=score;lowerPhase=exitCandidate;} + } + for(int step=1;step<=16;++step) + { + int directionIndex=static_cast(candidate) + +candidateDirection*step; + directionIndex=(directionIndex%16+16)%16; + append(ringPoint(static_cast(directionIndex),loops)); + if(static_cast(directionIndex)==lowerPhase)break; + } + } + return measure(); + }; + for(std::uint32_t offset=0;offset<16;++offset) + { + const std::uint32_t candidate=static_cast( + (hashedPhase+16-offset)%16); + const auto point=ringPoint(candidate); + const double entryX=static_cast(point[0]-upperMouth[0]); + const double entryY=static_cast(point[1]-upperMouth[1]); + const double entryLength=std::hypot(entryX,entryY); + if(entryLength<=1.0e-6)continue; + const auto entryDistance=distanceQ(point[0]-upperMouth[0], + point[1]-upperMouth[1]); + for(const int candidateDirection:{hashedDirection,-hashedDirection}) + { + const std::uint64_t routeLength=candidateRouteLength(candidate, + candidateDirection); + if(routeLength==0)continue; + const std::uint32_t next=static_cast( + (static_cast(candidate)+candidateDirection+16)%16); + const auto nextPoint=ringPoint(next); + const double tangentX=static_cast(nextPoint[0]-point[0]); + const double tangentY=static_cast(nextPoint[1]-point[1]); + const double tangentLength=std::hypot(tangentX,tangentY); + if(tangentLength<=1.0e-6)continue; + const double tangentAlignment=(entryX*tangentX+entryY*tangentY) + /(entryLength*tangentLength); + const std::int64_t firstAxisZ=upperMouth[2]+mouthRadius + -playerCenterAboveFloorQ; + const std::int64_t secondAxisZ=firstAxisZ+PassageRoundRatio( + lowerMouth[2]-upperMouth[2],static_cast(entryDistance), + static_cast(routeLength)); + double candidateTreadClearance=std::numeric_limits::infinity(); + const int entrySamples=std::max(1,static_cast(std::ceil( + entryLength/(0.25*qPerMetre)))); + for(int sample=0;sample<=entrySamples;++sample) + { + const double t=static_cast(sample)/entrySamples; + const double qx=upperMouth[0]+t*entryX; + const double qy=upperMouth[1]+t*entryY; + const double qz=firstAxisZ+t*(secondAxisZ-firstAxisZ) + -mouthRadius+0.20*qPerMetre; + for(const auto& previousPassage:layout.passages) + { + if(previousPassage.stableId==passage.stableId + ||previousPassage.controlPoints.size()<2)continue; + for(std::size_t previousSegment=0; + previousSegment+1(previousA.positionSubvoxels[0]); + const double ay=static_cast(previousA.positionSubvoxels[1]); + const double az=static_cast(previousA.positionSubvoxels[2]); + const double dx=static_cast(previousB.positionSubvoxels[0])-ax; + const double dy=static_cast(previousB.positionSubvoxels[1])-ay; + const double dz=static_cast(previousB.positionSubvoxels[2])-az; + const double horizontalLengthSquared=dx*dx+dy*dy; + const double projection=horizontalLengthSquared>0.0 + ?std::clamp(((qx-ax)*dx+(qy-ay)*dy) + /horizontalLengthSquared,0.0,1.0):0.0; + const double centerX=ax+projection*dx; + const double centerY=ay+projection*dy; + const double centerZ=az+projection*dz + -(previousA.radiusSubvoxels+projection*( + static_cast(previousB.radiusSubvoxels) + -previousA.radiusSubvoxels)) + +1.13*qPerMetre; + const double vertical=std::max(0.0, + std::abs(qz-centerZ)-0.54*qPerMetre); + const double clearance=std::sqrt( + (qx-centerX)*(qx-centerX)+(qy-centerY)*(qy-centerY) + +vertical*vertical)-0.34*qPerMetre; + candidateTreadClearance=std::min(candidateTreadClearance, + clearance); + } + } + } + double previousTreadClearance=std::numeric_limits::infinity(); + const double entryXQ=entryX,entryYQ=entryY; + const double entryZQ=static_cast(secondAxisZ-firstAxisZ); + const double entryLengthSquared=entryXQ*entryXQ+entryYQ*entryYQ + +entryZQ*entryZQ; + const double mouthStartT=std::clamp( + mouthRadius/entryLength,0.0,1.0); + for(const auto& previousPassage:layout.passages) + { + if(previousPassage.stableId==passage.stableId + ||previousPassage.controlPoints.size()<2)continue; + for(std::size_t previousSegment=0; + previousSegment+1(previousA.positionSubvoxels[0]); + const double ay=static_cast(previousA.positionSubvoxels[1]); + const double az=static_cast(previousA.positionSubvoxels[2]); + const double dx=static_cast(previousB.positionSubvoxels[0])-ax; + const double dy=static_cast(previousB.positionSubvoxels[1])-ay; + const double dz=static_cast(previousB.positionSubvoxels[2])-az; + for(int sample=0;sample<=8;++sample) + { + const double t=static_cast(sample)/8.0; + const double px=ax+t*dx; + const double py=ay+t*dy; + const double pz=az+t*dz + -(previousA.radiusSubvoxels+t*( + static_cast(previousB.radiusSubvoxels) + -previousA.radiusSubvoxels)) + +0.20*qPerMetre; + const double projection=entryLengthSquared>0.0 + ?std::clamp(((px-upperMouth[0])*entryXQ + +(py-upperMouth[1])*entryYQ + +(pz-firstAxisZ)*entryZQ)/entryLengthSquared, + mouthStartT,1.0):mouthStartT; + const double ex=px-(upperMouth[0]+projection*entryXQ); + const double ey=py-(upperMouth[1]+projection*entryYQ); + const double ez=pz-(firstAxisZ+projection*entryZQ); + const double clearance=std::sqrt(ex*ex+ey*ey+ez*ez) + -mouthRadius; + previousTreadClearance=std::min(previousTreadClearance, + clearance); + } + } + } + const double clearanceScore=std::isfinite(candidateTreadClearance) + &&std::isfinite(previousTreadClearance) + ?std::min(candidateTreadClearance,previousTreadClearance)/qPerMetre + :0.0; + if(phaseScoreCount0) + { + 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(selected) + { + phase=selected->phase; + direction=selected->direction; + clockwise=direction>0; + } + } + } + direction=clockwise?1:-1; std::vector> xy; const auto pushXY=[&](std::array p) {if(xy.empty()||xy.back()!=p)xy.push_back(p);}; @@ -2731,6 +3004,12 @@ bool PlanResolvedLayoutPassagesV1(ResolvedLayoutSnapshotV3& layout,std::int32_t } ++loops; } + const std::int64_t mouthPlanSeparation=distanceQ( + lowerMouth[0]-upperMouth[0],lowerMouth[1]-upperMouth[1]); + const bool shortGeneratedClosure=!hasLowerTangent&&!hasAnchorHelix + &&pathStyle==1u&&loops<=1 + &&mouthPlanSeparation<=2*mouthRadius + &&verticalDistance<=2*mouthRadius+qPerMetre/2; if(hasLowerTangent) { std::uint32_t lowerPhase=static_cast(PassageSeed(seed, @@ -2789,6 +3068,48 @@ bool PlanResolvedLayoutPassagesV1(ResolvedLayoutSnapshotV3& layout,std::int32_t } pushXY(ringPoint(lowerPhase)); } + else if(shortGeneratedClosure) + { + // A one-turn generated route whose mouths share a plan location + // must leave and rejoin from different ring tangents. Returning on + // the entry chord creates a hairpin that overlaps its own tread. + double bestExitScore=-std::numeric_limits::infinity(); + std::uint32_t lowerPhase=phase; + const auto firstRing=ringPoint(phase,loops); + for(std::uint32_t candidate=0;candidate<16;++candidate) + { + if(candidate==phase)continue; + const std::uint32_t previous=static_cast( + (static_cast(candidate)-direction+16)%16); + const auto terminal=ringPoint(candidate,loops); + const auto beforeTerminal=ringPoint(previous,loops); + const double incomingX=static_cast(terminal[0]-beforeTerminal[0]); + const double incomingY=static_cast(terminal[1]-beforeTerminal[1]); + const double outgoingX=static_cast(lowerMouth[0]-terminal[0]); + const double outgoingY=static_cast(lowerMouth[1]-terminal[1]); + const double incomingLength=std::hypot(incomingX,incomingY); + const double outgoingLength=std::hypot(outgoingX,outgoingY); + if(incomingLength<=1.0e-6||outgoingLength<=1.0e-6)continue; + const double tangentAlignment=(incomingX*outgoingX+incomingY*outgoingY) + /(incomingLength*outgoingLength); + const double separation=std::hypot( + static_cast(terminal[0]-firstRing[0]), + static_cast(terminal[1]-firstRing[1])); + const double score=tangentAlignment + +0.20*std::clamp(separation/(2.0*qPerMetre),0.0,2.0); + if(score>bestExitScore) + {bestExitScore=score;lowerPhase=candidate;} + } + for(int step=1;step<=16;++step) + { + int directionIndex=static_cast(phase)+direction*step; + directionIndex=(directionIndex%16+16)%16; + pushXY(ringPoint(static_cast(directionIndex),loops)); + if(static_cast(directionIndex)==lowerPhase)break; + } + std::printf("[ResolvedPassageGeneratedExitApproach] id=%s turns=%u phase=%u exit_phase=%u tangent_score=%.3f status=PASS\n", + passage.stableId.c_str(),loops,phase,lowerPhase,bestExitScore); + } pushXY({lowerMouth[0],lowerMouth[1]});totalHorizontal=measurePath(); std::size_t lowerMouthIndex=xy.size()-1; std::vector> lowerLandingPoints; diff --git a/Standalone/Core/src/ShapeWorldInternal.h b/Standalone/Core/src/ShapeWorldInternal.h index 7dde8ea..e72b11e 100644 --- a/Standalone/Core/src/ShapeWorldInternal.h +++ b/Standalone/Core/src/ShapeWorldInternal.h @@ -971,6 +971,7 @@ public: const double floorThicknessVoxels=qPerMetre/Q; const double playerCenterAboveFloorQ=1.13*qPerMetre; float composed=density; + float deferredSupportDensity=std::numeric_limits::infinity(); const auto composeRouteMouthSupport=[&](std::int32_t strateIndex, const std::array& mouth,bool departure) { @@ -1045,7 +1046,6 @@ public: { const auto& passage=ResolvedLayout->passages[passageIndex]; if(passage.controlPoints.size()<2)continue; - const bool internalChainPassage=passageIndex>0; const auto supportStratum=[&](std::int32_t strateIndex) ->const VoxelForgeCore::ResolvedLayoutStratumV2* { @@ -1056,6 +1056,31 @@ public: }; const auto* supportUpper=supportStratum(passage.upperStrateIndex); const auto* supportLower=supportStratum(passage.lowerStrateIndex); + const auto mouthInsideContent=[&]( + const VoxelForgeCore::ResolvedLayoutStratumV2* stratum, + const std::array& mouth) + { + if(!stratum||!stratum->content.presentInCurrentField)return false; + const auto zMicrometres=static_cast(std::llround( + static_cast(mouth[2]) + *ResolvedLayout->coreVoxelSizeMicrometres/Q)); + return zMicrometres>=stratum->content.bottomMicrometres + &&zMicrometrescontent.topMicrometres; + }; + // A passage is internal when both mouths are bounded by their strate + // content and neither endpoint is a SurfaceWorld landing. Route order + // and the total number of strata are irrelevant. + const bool internalChainPassage=supportUpper&&supportLower + &&!supportUpper->passageCapabilities.hasSurfaceLanding + &&!supportLower->passageCapabilities.hasSurfaceLanding + &&mouthInsideContent(supportUpper,passage.upperMouthSubvoxels) + &&mouthInsideContent(supportLower,passage.lowerMouthSubvoxels); + const auto laterPassageMayCarve=[&](std::ptrdiff_t supportSegment, + double sampleX,double sampleY,double sampleZ) + { + return PassageSupportMayBeCarvedByLaterBore(passageIndex, + supportSegment,sampleX,sampleY,sampleZ,0.35*qPerMetre); + }; const bool floatingIslandPassage=supportUpper &&supportUpper->passageCapabilities.hasFloatingIslandLanding; const bool surfaceToTunnelSupport=supportUpper&&supportLower @@ -1223,7 +1248,8 @@ public: +playerVertical*playerVertical)-0.34*qPerMetre); } double cx=ax+t*dx,cy=ay+t*dy,cz=az+t*dz; - const bool smoothCarveJoin=index>2&&index+122 + &&index+(internalChainPassage?2:12)(nearestB.positionSubvoxels[2])-nearestA.positionSubvoxels[2]))) /distanceToCenter,0.0,1.0):0.0; - const float relief=VF_FieldPassageWallRelief(static_cast(X),static_cast(Y), - static_cast(Z),static_cast(Owner?Owner->Seed:0)); + const float relief=VF_FieldPassageWallRelief(static_cast(X), + static_cast(Y),static_cast(Z), + static_cast(Owner?Owner->Seed:0)); const float mouthSurfaceRelief=VF_FieldSurfaceRelief(static_cast(X)*1.5f, static_cast(Y)*1.5f,static_cast(Z)*1.5f, static_cast(Owner?Owner->Seed:0)^0x4D4F5554u); @@ -1427,7 +1454,8 @@ public: static_cast(mouthTerrainRelief),-1.0,1.0) *0.20*qPerMetre*mouthFloorBlend*floorReliefWeight *(upperMouthFloorFade?1.0:0.0); - const double wallReliefWeight=0.05+0.95*(1.0-verticalWallAlignment); + const double wallOpening=1.0-verticalWallAlignment; + const double wallReliefWeight=0.05+0.95*wallOpening; const double roughSignedDistance=bestSignedDistance +static_cast(std::clamp(relief,-1.0f,1.0f))*0.21*qPerMetre*wallReliefWeight +floorReliefOffset; @@ -1472,6 +1500,7 @@ public: static_cast(-roughSignedDistance/Q)); composed=std::lerp(composed,fullyCarvedDensity, static_cast(floorCarveWeight)); + const float composedAfterCarve=composed; const auto considerSupportSegment=[&](std::ptrdiff_t index) { const auto& segmentA=passage.controlPoints[static_cast(index)]; @@ -1570,15 +1599,25 @@ public: const double minimumSupportQueryDepth=index==selectedSupportSegmentIndex &&sparseRouteSupport?0.65:0.93; if(supportQueryDepth1.30*qPerMetre)return; + ||supportQueryDepth>1.30*qPerMetre) + return; const bool fallbackSupport=qz>=supportPlayerCenterZ-1.50*qPerMetre; const double supportThicknessVoxels=fallbackSupport ?qPerMetre/Q:0.5*qPerMetre/Q; // Keep the supported ramp to a comfortable walking width. Extending a // full bore-radius shelf across overlapping helix coils can block the // capsule on the neighbouring turn. - const bool lowerMouthBodyOverlap=ResolvedLayout->strates.size()>2 - &&index>=lowerMouthIndex-2 + const double lowerMouthDx=centerX + -static_cast(passage.lowerMouthSubvoxels[0]); + const double lowerMouthDy=centerY + -static_cast(passage.lowerMouthSubvoxels[1]); + const double lowerMouthDz=centerZ-radius + -static_cast(passage.lowerMouthSubvoxels[2]); + const double lowerMouthBodyDistance=DeterministicSqrt( + lowerMouthDx*lowerMouthDx+lowerMouthDy*lowerMouthDy + +lowerMouthDz*lowerMouthDz); + const bool lowerMouthBodyOverlap=internalChainPassage + &&lowerMouthBodyDistance<=2.0*qPerMetre &&supportQueryDepth<0.98*qPerMetre; // Keep a narrow walk core, but place its top on the native surface // so the support remains a worn ramp instead of a route-height slab. @@ -1731,7 +1770,8 @@ public: bestSupportUsesSignedDistance=signedDistanceSupport; bestSupportMouthBlend=mouthSupportBlend; bestSupportNativeBlend=mouthTread - ?0.85*mouthSupportBlend*(1.0-supportEdgeSmooth):0.0; + ?(internalChainPassage?1.0:0.85) + *mouthSupportBlend*(1.0-supportEdgeSmooth):0.0; }; std::ptrdiff_t supportCenterIndex=bestIndex; if(bestIndex=capsuleClearanceMetres*qPerMetre; + bool supportApplied=false; + bool supportNeedsDeferral=false; // Outside bridge and short-span segments, add the tread only where the // native field already provides a solid envelope; the capsule check keeps // that support below the player's walk space. @@ -1782,6 +1824,8 @@ public: ||shortSpanFloorSupport) &&(capsuleAllowsSupport||bestSupportDensity>=0.0)) { + supportNeedsDeferral=laterPassageMayCarve(bestSupportSegmentIndex, + qx,qy,qz); if(bestSupportIsMouthTread) { // Preserve the owner's native ground through the shallow mouth @@ -1797,13 +1841,36 @@ public: const double nativeSurfaceDetail=std::clamp( static_cast(mouthTerrainRelief),-1.0,1.0) *0.18*qPerMetre/Q; - const float nativeGroundWithRelief=static_cast(density - -nativeSurfaceDetail); + const float nativeGroundWithRelief=static_cast( + (supportNeedsDeferral?std::max(-1.0f,density):density) + -nativeSurfaceDetail); composed=std::lerp(composed,nativeGroundWithRelief, static_cast(bestSupportNativeBlend)); + supportApplied=composed(bestSupportDensity)); + else + { + const float densityBeforeSupport=composed; + composed=std::min(composed,static_cast(bestSupportDensity)); + supportApplied=composed minimum{},maximum{}; }; + struct FPassageLaterBoreCandidate + { + std::array minimum{},maximum{}; + double reach=0.0; + }; + struct FPassageLaterBoreSource + { + std::array minimum{},maximum{}; + std::vector candidates; + std::vector capsuleCandidatePassages; + }; struct FPassageProofNode { std::array minimum{},maximum{}; @@ -4237,6 +4316,7 @@ private: PassageNearestNodes.clear(); PassageProofBounds.clear();PassageProofNodes.clear(); PassageNearestNodes.resize(ResolvedLayout?ResolvedLayout->passages.size():0); + PassageLaterBoreSources.clear(); PassageColumnSegmentFlags.clear();PassageShortSpanSegmentFlags.clear(); PassageHasShortSpanFlags.clear(); if(!ResolvedLayout||ResolvedLayout->passages.empty())return; @@ -4336,6 +4416,75 @@ private: } } } + // Precompute the later-bore leaves that can intersect each tread segment. + // Passage support samples normally stay within this source envelope, so the + // hot query can test a short list of physical bounds instead of walking a + // full spatial tree for every density sample. + PassageLaterBoreSources.resize(ResolvedLayout->passages.size()); + const double qPerMetre=Q*voxelsPerMetre; + for(std::size_t sourcePassageIndex=0; + sourcePassageIndexpassages.size();++sourcePassageIndex) + { + const auto& sourcePassage=ResolvedLayout->passages[sourcePassageIndex]; + const std::size_t sourceSegmentCount=sourcePassage.controlPoints.size()>1 + ?sourcePassage.controlPoints.size()-1:0; + auto& sourceSegments=PassageLaterBoreSources[sourcePassageIndex]; + sourceSegments.resize(sourceSegmentCount); + for(std::size_t sourceSegment=0;sourceSegmentpassages.size();++laterPassageIndex) + { + for(const auto& node:PassageNearestNodes[laterPassageIndex]) + { + if(node.left>=0)continue; + const double reach=node.maximumRadius+0.35*qPerMetre+1.0e-6; + bool intersectsSource=true; + for(std::size_t axis=0;axis<3;++axis) + intersectsSource=intersectsSource + &&node.minimum[axis]-reach<=source.maximum[axis] + &&node.maximum[axis]+reach>=source.minimum[axis]; + if(!intersectsSource)continue; + source.candidates.push_back({node.minimum,node.maximum,reach}); + } + } + const double capsuleXYReach=0.39*qPerMetre; + for(std::size_t otherPassageIndex=0; + otherPassageIndexpassages.size();++otherPassageIndex) + { + if(otherPassageIndex==sourcePassageIndex + ||PassageNearestNodes[otherPassageIndex].empty())continue; + const auto& otherRoot=PassageNearestNodes[otherPassageIndex].front(); + const std::array blockerMinimum={ + otherRoot.minimum[0]-capsuleXYReach, + otherRoot.minimum[1]-capsuleXYReach, + otherRoot.minimum[2]-otherRoot.maximumRadius+0.20*qPerMetre}; + const std::array blockerMaximum={ + otherRoot.maximum[0]+capsuleXYReach, + otherRoot.maximum[1]+capsuleXYReach, + otherRoot.maximum[2]+2.06*qPerMetre}; + bool intersectsSource=true; + for(std::size_t axis=0;axis<3;++axis) + intersectsSource=intersectsSource + &&blockerMinimum[axis]<=source.maximum[axis] + &&blockerMaximum[axis]>=source.minimum[axis]; + if(intersectsSource) + source.capsuleCandidatePassages.push_back(otherPassageIndex); + } + } + } for(const auto& crossing:PassageVoidCrossings) { if(crossing.passageIndex=ResolvedLayout->passages.size())return false; + if(passageIndex=0 + &&static_cast(supportSegment) + (supportSegment)]; + const bool insideSourceEnvelope=qx>=source.minimum[0]&&qx<=source.maximum[0] + &&qy>=source.minimum[1]&&qy<=source.maximum[1] + &&qz>=source.minimum[2]&&qz<=source.maximum[2]; + if(insideSourceEnvelope) + { + for(const auto& candidate:source.candidates) + { + double distanceSquared=0.0; + for(std::size_t axis=0;axis<3;++axis) + { + const double q=axis==0?qx:axis==1?qy:qz; + const double distance=qcandidate.maximum[axis]?q-candidate.maximum[axis]:0.0; + distanceSquared+=distance*distance; + } + const double reach=candidate.reach; + if(distanceSquared<=reach*reach)return true; + } + return false; + } + } + for(std::size_t otherPassageIndex=passageIndex+1; + otherPassageIndexpassages.size();++otherPassageIndex) + { + const auto& otherPassage=ResolvedLayout->passages[otherPassageIndex]; + if(otherPassage.controlPoints.size()<2)continue; + if(PassagePotentialWithinNode(otherPassageIndex,0,0, + otherPassage.controlPoints.size()-1,qx,qy,qz,signedDistanceLimit))return true; + } + return false; + } + + bool PassageOtherCapsuleBlocksSupport(std::size_t passageIndex, + std::ptrdiff_t supportSegment,double qx,double qy,double qz, + double qPerMetre,double minimumClearanceMetres) const + { + if(passageIndex=0 + &&static_cast(supportSegment) + (supportSegment)]; + const bool insideSourceEnvelope=qx>=source.minimum[0]&&qx<=source.maximum[0] + &&qy>=source.minimum[1]&&qy<=source.maximum[1] + &&qz>=source.minimum[2]&&qz<=source.maximum[2]; + if(insideSourceEnvelope) + { + for(const std::size_t otherPassageIndex:source.capsuleCandidatePassages) + { + const auto& otherPassage=ResolvedLayout->passages[otherPassageIndex]; + if(PassagePlayerCapsuleBlocksSupportNode(otherPassageIndex,0,0, + otherPassage.controlPoints.size()-1,qx,qy,qz,qPerMetre, + minimumClearanceMetres))return true; + } + return false; + } + } + for(std::size_t otherPassageIndex=0; + otherPassageIndexpassages.size();++otherPassageIndex) + { + if(otherPassageIndex==passageIndex)continue; + const auto& otherPassage=ResolvedLayout->passages[otherPassageIndex]; + if(otherPassage.controlPoints.size()<2)continue; + if(PassagePlayerCapsuleBlocksSupportNode(otherPassageIndex,0,0, + otherPassage.controlPoints.size()-1,qx,qy,qz,qPerMetre, + minimumClearanceMetres))return true; + } + return false; + } + bool PassageMayAffectDensity(std::size_t passageIndex,std::ptrdiff_t carveBegin, std::ptrdiff_t carveEnd,std::ptrdiff_t lowerMouthIndex, std::ptrdiff_t mouthSearchBegin,std::ptrdiff_t mouthSearchEnd,std::ptrdiff_t lastIndex, @@ -4529,7 +4760,10 @@ private: const double vertical=std::max(0.0,std::abs(qz-centerZ)-0.54*qPerMetre); const double distance=DeterministicSqrt(horizontalX*horizontalX+horizontalY*horizontalY +vertical*vertical)-0.34*qPerMetre; - if(distance(broadRelief),-1.0,1.0)*1.10*qPerMetre +std::clamp(static_cast(surfaceRelief),-1.0,1.0)*0.35*qPerMetre; + // Add a subtle host-rock root only when the unmodified column lacks the + // vertical relief required for walkable passage support. + const double rootTaper=crossing.needsRootTaper + ?0.08*qPerMetre*smoothStep((t-0.35)/0.50):0.0; const double radius=static_cast(crossing.minimumRadiusSubvoxels) - +std::max(0.15*qPerMetre,irregularity)+baseFlare+ceilingFlare; + +std::max(0.15*qPerMetre,irregularity)+rootTaper+baseFlare+ceilingFlare; const double radialDistanceField=radialDistance-radius; const double topDistance=qz-crossing.topZSubvoxels; const double bottomDistance=crossing.bottomZSubvoxels-qz; @@ -4780,6 +5018,67 @@ private: return ApplyResolvedStrateBoundaryBand(density,X,Y,Z); } + double PassageColumnNaturalReliefMetres( + const FStandalonePassageVoidCrossing& crossing,double qPerMetre) const + { + constexpr double Q=static_cast(VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel); + constexpr std::array HeightFractions{{0.25,0.35,0.45,0.55,0.65,0.75}}; + constexpr std::size_t DirectionCount=16; + const auto smoothStep=[](double value) + { + value=std::clamp(value,0.0,1.0); + return value*value*(3.0-2.0*value); + }; + std::array meanRadii{}; + std::size_t heightCount=0; + const auto seed=static_cast(Owner?Owner->Seed:0); + const double height=static_cast(crossing.topZSubvoxels + -crossing.bottomZSubvoxels); + if(height<=0.0)return 0.0; + for(const double fraction:HeightFractions) + { + const double qz=crossing.topZSubvoxels-height*fraction; + const float z=static_cast(qz/Q); + const float scaledZ=z*0.12f; + const float driftX=VF_FieldSurfaceRelief(0.0f,0.0f,scaledZ,seed^0x434f4c58u); + const float driftY=VF_FieldSurfaceRelief(0.0f,0.0f,scaledZ,seed^0x434f4c59u); + const double axisX=crossing.centerXSubvoxels + +std::clamp(static_cast(driftX),-1.0,1.0)*0.35*qPerMetre; + const double axisY=crossing.centerYSubvoxels + +std::clamp(static_cast(driftY),-1.0,1.0)*0.35*qPerMetre; + const double t=(crossing.topZSubvoxels-qz)/height; + const double baseFlare=6.0*qPerMetre*smoothStep((t-0.82)/0.18); + const double ceilingFlare=2.0*qPerMetre*(1.0-smoothStep(t/0.075)); + double meanRadius=0.0; + for(std::size_t direction=0;direction((axisX+radius*std::cos(angle))/Q); + const float sampleY=static_cast((axisY+radius*std::sin(angle))/Q); + const float broadRelief=VF_FieldSurfaceRelief(sampleX*0.12f, + sampleY*0.12f,scaledZ,seed^0x434f4c52u); + const float surfaceRelief=VF_FieldSurfaceRelief(sampleX,sampleY,z, + seed^0x434f4c53u); + const double irregularity=1.25*qPerMetre + +std::clamp(static_cast(broadRelief),-1.0,1.0)*1.10*qPerMetre + +std::clamp(static_cast(surfaceRelief),-1.0,1.0)*0.35*qPerMetre; + radius=crossing.minimumRadiusSubvoxels + +std::max(0.15*qPerMetre,irregularity)+baseFlare+ceilingFlare; + } + meanRadius+=radius; + } + meanRadii[heightCount++]=meanRadius/DirectionCount/qPerMetre; + } + if(heightCount<2)return 0.0; + const auto begin=meanRadii.begin(); + const auto end=begin+static_cast(heightCount); + return *std::max_element(begin,end)-*std::min_element(begin,end); + } + void BuildPassageVoidCrossingPlan() { BuildResolvedBoundaryZIntervals(); @@ -4789,7 +5088,8 @@ private: {BuildPassageSpatialIndices();return;} auto& cache=*Owner->PassageVoidCrossingCache; std::lock_guard lock(cache.mutex); - const std::string cacheKey=ResolvedLayout->stableIdentity+"/passage-void-columns-v1"; + const std::string cacheKey=ResolvedLayout->stableIdentity+"/passage-void-columns-v2-seed-" + +std::to_string(Owner->Seed); const auto cached=cache.byLayout.find(cacheKey); if(cached!=cache.byLayout.end()) {PassageVoidCrossings=cached->second;BuildPassageSpatialIndices();return;} @@ -4968,6 +5268,12 @@ private: maximumCrossingZ+5.0*qPerMetre)); crossing.bottomZSubvoxels=static_cast(std::llround( minimumCrossingZ-6.0*qPerMetre)); + crossing.naturalSurfaceReliefMetres=PassageColumnNaturalReliefMetres( + crossing,qPerMetre); + // The support-column audit requires 20 cm of vertical relief. + // Add a shallow root flare only when the existing field profile + // falls short, so naturally varied legacy columns remain intact. + crossing.needsRootTaper=crossing.naturalSurfaceReliefMetres<0.20; } PassageVoidCrossings.push_back(crossing); if(crossing.needsColumn) @@ -4975,7 +5281,7 @@ private: const double coreRadius=crossing.nominalRadiusSubvoxels/qPerMetre; const double minimumWall=coreRadius-crossing.maximumRouteOffsetMetres -crossing.maximumBoreRadiusMetres; - std::printf("[PassageVoidColumnPlan] id=%s segments=%zu..%zu unsupported_samples=%llu span_m=%.3f threshold_m=%.3f connected=%d/%d axis_m=(%.3f,%.3f) z_m=%.3f..%.3f route_radius_m=%.3f bore_radius_m=%.3f min_radius_m=%.3f nominal_radius_m=%.3f max_radius_m=%.3f nominal_wall_m=%.3f flare_m=6.000 status=%s\n", + std::printf("[PassageVoidColumnPlan] id=%s segments=%zu..%zu unsupported_samples=%llu span_m=%.3f threshold_m=%.3f connected=%d/%d axis_m=(%.3f,%.3f) z_m=%.3f..%.3f route_radius_m=%.3f bore_radius_m=%.3f min_radius_m=%.3f nominal_radius_m=%.3f max_radius_m=%.3f nominal_wall_m=%.3f natural_relief_m=%.3f root_taper=%d flare_m=6.000 status=%s\n", passage.stableId.c_str(),crossing.firstSegment,crossing.lastSegment, static_cast(crossing.unsupportedSamples),gapLength, shortSpanMaximumMetres,static_cast(crossing.connectedAtStart), @@ -4985,6 +5291,7 @@ private: crossing.maximumRouteOffsetMetres,crossing.maximumBoreRadiusMetres, crossing.minimumRadiusSubvoxels/qPerMetre,coreRadius, crossing.maximumRadiusSubvoxels/qPerMetre,minimumWall, + crossing.naturalSurfaceReliefMetres,static_cast(crossing.needsRootTaper), crossing.connectedAtStart&&crossing.connectedAtEnd?"PLANNED":"UNATTACHED"); } else @@ -10801,6 +11108,7 @@ private: std::vector PassageFeatureNodes; bool PassageFeatureBoundsFailOpen=false; std::vector> PassageNearestNodes; + std::vector> PassageLaterBoreSources; std::vector PassageProofBounds; std::vector PassageProofNodes; std::vector> PassageColumnSegmentFlags,PassageShortSpanSegmentFlags; diff --git a/Standalone/Research/WorldAssemblerM3/Linux67/CaptureOwnerPassagesL67.py b/Standalone/Research/WorldAssemblerM3/Linux67/CaptureOwnerPassagesL67.py new file mode 100644 index 0000000..a11dbf2 --- /dev/null +++ b/Standalone/Research/WorldAssemblerM3/Linux67/CaptureOwnerPassagesL67.py @@ -0,0 +1,364 @@ +#!/usr/bin/env python3 +"""Render arrival, departure, and mid-tunnel views for the four-strate owner plan.""" +import json +import importlib.util +import argparse +import math +import os +import re +import subprocess +import time +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[4] +OUT = ROOT / "Saved/Linux/l67/captures-run" +BIN = ROOT / "Saved/Linux/l67/bin-final" +CAPTURES = Path(__file__).resolve().parent / "captures" +HEADLESS = BIN / "VoxelForgeCoreHeadless-l67-final-clang" +RENDERER = BIN / "VoxelForgeOffscreenCapture-l67-final-clang" +PNG_DIR = OUT / "capture-png" +RECIPE_INPUTS = ( + ("passage-0-1", ROOT / "Standalone/data/owner_world.json", + "surface-world", "SurfaceWorld", "upper"), + ("passage-1-2", ROOT / "Standalone/data/owner_world.json", + "surface-world", "FloatingIslands", "upper"), + ("passage-2-3", ROOT / "Standalone/data/owner_world.json", + "surface-world", "TunnelNetwork", "upper"), +) +SUBVOXELS_PER_VOXEL = 256.0 +RING_AUDIT = ROOT / "Standalone/Research/WorldAssemblerM3/Linux32/AuditRingTerraceL32.py" +spec = importlib.util.spec_from_file_location("ring_profile_l32", RING_AUDIT) +ring_profile = importlib.util.module_from_spec(spec) +spec.loader.exec_module(ring_profile) + +for directory in (OUT, BIN, PNG_DIR, CAPTURES): + directory.mkdir(parents=True, exist_ok=True) +ENV = os.environ.copy() +ENV["LP_NUM_THREADS"] = "12" +ENV["XDG_CACHE_HOME"] = str(OUT / "xdg-cache") +PARSER = argparse.ArgumentParser(description=__doc__) +PARSER.add_argument("--scene", choices=[value[0] for value in RECIPE_INPUTS]) +PARSER.add_argument("--view", choices=("arrival-mouth", "departure-mouth", "mid-tunnel")) +OPTIONS = PARSER.parse_args() + + +def run(name, command): + log = OUT / f"capture-{name}.log" + wrapped = ["taskset", "-c", "0-11", "prlimit", "--as=6442450944", "--", + "timeout", "1800s", *map(str, command)] + started = time.monotonic() + with log.open("w", encoding="utf-8") as stream: + result = subprocess.run(wrapped, cwd=ROOT, env=ENV, stdout=stream, + stderr=subprocess.STDOUT, timeout=1800, check=False) + elapsed = time.monotonic() - started + print(f"[L35CaptureRun] step={name} elapsed={elapsed:.1f}s exit={result.returncode} log={log}", + flush=True) + if result.returncode != 0: + raise SystemExit(f"{name} failed; see {log}") + return log + + +def add(a, b): + return tuple(x + y for x, y in zip(a, b)) + + +def sub(a, b): + return tuple(x - y for x, y in zip(a, b)) + + +def mul(a, scale): + return tuple(x * scale for x in a) + + +def norm(a): + length = math.sqrt(sum(x * x for x in a)) + if length <= 1.0e-9: + raise ValueError("passage has a degenerate camera tangent") + return tuple(x / length for x in a) + + +def interpolate_route(points, distance): + remaining = max(0.0, distance) + for a, b in zip(points, points[1:]): + delta = sub(b[0], a[0]) + length = math.sqrt(sum(x * x for x in delta)) + if length <= 1.0e-9: + continue + if remaining <= length: + t = remaining / length + pos = tuple(a[0][axis] + delta[axis] * t for axis in range(3)) + radius = a[1] + (b[1] - a[1]) * t + return pos, radius + remaining -= length + return points[-1] + + +def route_frame(points, distance): + center, radius = interpolate_route(points, distance) + before, _ = interpolate_route(points, max(0.0, distance - 0.25)) + after, _ = interpolate_route(points, distance + 0.25) + tangent = norm(sub(after, before)) + horizontal = norm((tangent[0], tangent[1], 0.0)) + side = (-horizontal[1], horizontal[0], 0.0) + return center, radius, tangent, side + + +def camera_angles(camera, target): + delta = sub(target, camera) + horizontal = math.hypot(delta[0], delta[1]) + yaw = math.degrees(math.atan2(delta[0], delta[1])) + pitch = math.degrees(math.atan2(delta[2], horizontal)) + return yaw, pitch + + +def fmt(values): + return ",".join(f"{value:.6f}" for value in values) + + +def load_reference_cameras(): + log = ROOT / "Saved/Linux/l34/run-capture-standard-set-final-radius3.log" + pattern = re.compile( + r"^\[L34CaptureCamera\] view=(\S+).*?camera_m=\(([^)]*)\) " + r"target_m=\(([^)]*)\) clip=(\S+)", re.M) + values = {} + for name, camera, target, clip in pattern.findall( + log.read_text(encoding="utf-8", errors="replace")): + parse = lambda value: tuple(float(part) for part in value.split(",")) + values[name] = (parse(camera), parse(target), None if clip == "none" else parse(clip)) + return values + + +REFERENCE_CAMERAS = {} + + +def terrain_surface_near(mesh, x, y, expected_height): + bounds = (x - 2.0, x + 2.0, y - 2.0, y + 2.0) + _, triangles, _ = ring_profile.read_scene( + str(mesh), None, -1.0e9, 1.0e9, bounds) + buckets = ring_profile.build_buckets(triangles, True, bounds, cell_size=1.0) + hits = [] + for dy in range(-4, 5): + for dx in range(-4, 5): + sample_x, sample_y = x + dx * 0.5, y + dy * 0.5 + hit = ring_profile.sample_surface(buckets, sample_x, sample_y, True, 1.0, + preferred_height=expected_height) + if hit is not None: + hits.append((math.hypot(dx * 0.5, dy * 0.5), + abs(hit[0] - expected_height), sample_x, sample_y, hit[0])) + if not hits: + return None + distance, _, sample_x, sample_y, height = min(hits) + if distance > 2.0 or abs(height - expected_height) > 8.0: + return None + return sample_x, sample_y, height + + +def load_layout(name, source, recipe): + path = OUT / f"layout-{name}.json" + run(f"layout-{name}", [HEADLESS, source, "--workers=12", "--world-assembler-prototype", + "--stream-benchmark", "--stream-pipeline", "--force-generate", "--clip-radius=2", + "--max-clip-level=0", "--max-level=0", f"--recipe={recipe}", + f"--resolved-layout-output={path}", "--resolved-layout-only"]) + return json.loads(path.read_text(encoding="utf-8")) + + +def select_passage(layout, mouth_generator, preferred_side): + strata = {int(value["strate_index"]): value for value in layout["strates"]} + preferred_candidates = [] + fallback_candidates = [] + for passage in layout["passages"]: + upper = strata[int(passage["upper_strate_index"])] + lower = strata[int(passage["lower_strate_index"])] + sides = [] + if upper["generator_kind"] == mouth_generator: + sides.append("upper") + if lower["generator_kind"] == mouth_generator: + sides.append("lower") + if preferred_side in sides: + preferred_candidates.append((passage, preferred_side == "lower")) + elif sides: + fallback_candidates.append((passage, sides[0] == "lower")) + candidates = preferred_candidates or fallback_candidates + if not candidates: + raise RuntimeError(f"no passage mouth in generator {mouth_generator}") + return sorted(candidates, key=lambda item: item[0]["stable_id"])[0] + + +def make_views(layout, passage, reverse): + q_to_m = layout["core_voxel_size_um"] / (SUBVOXELS_PER_VOXEL * 1_000_000.0) + controls = [ + (tuple(value * q_to_m for value in point["position_q"]), + point["radius_q"] * q_to_m) + for point in passage["control_points"] + ] + lower_mouth = tuple(value * q_to_m for value in passage["lower_mouth_q"]) + lower_index = min(range(len(controls)), key=lambda i: sum( + (controls[i][0][axis] - lower_mouth[axis]) ** 2 for axis in range(3))) + path = controls[:lower_index + 1] + if len(path) < 2: + raise RuntimeError(f"{passage['stable_id']} has no mouth-to-mouth route") + if reverse: + path.reverse() + lengths = [math.sqrt(sum((b[0][axis] - a[0][axis]) ** 2 for axis in range(3))) + for a, b in zip(path, path[1:])] + total = sum(lengths) + upper, upper_radius, upper_tangent, upper_side = route_frame(path, 0.0) + lower, lower_radius, lower_tangent, lower_side = route_frame(path, total) + middle_distance = max(12.0, min(total - 12.0, total * 0.4)) if total > 24.0 else total * 0.4 + entry_distance = min(6.0, total * 0.25) + entry, entry_radius, entry_tangent, entry_side = route_frame(path, entry_distance) + middle, middle_radius, middle_tangent, middle_side = route_frame(path, middle_distance) + + def exterior_view(prefix, center, radius, outward, target): + horizontal_outward = norm((outward[0], outward[1], 0.0)) + eye_height = center[2] - radius + 1.6 + eye = (center[0] + horizontal_outward[0] * 5.5, + center[1] + horizontal_outward[1] * 5.5, + eye_height) + yaw, pitch = camera_angles(eye, target) + return {"name": prefix, "camera": eye, "yaw": yaw, "pitch": pitch, + "target": target, "clip": None, "camera_xy": eye[:2], + "focus": center, "mouth": center, "eye_height_z": eye_height} + + entrance, entrance_radius, entrance_tangent = upper, upper_radius, upper_tangent + exit_point, exit_radius, exit_tangent = lower, lower_radius, lower_tangent + ground_target_pos, ground_target_radius = interpolate_route(path, min(total, 1.0)) + ground = exterior_view("ground-mouth", entrance, entrance_radius, + mul(entrance_tangent, -1.0), (ground_target_pos[0], ground_target_pos[1], + ground_target_pos[2] - ground_target_radius + 1.0)) + ground["focus"] = entrance + ground["outside_camera"] = True + + def cutaway_view(prefix, center, radius, tangent, side, distance, target_distance): + camera = (center[0] - side[0] * 7.0, center[1] - side[1] * 7.0, + center[2] - radius + 1.6) + target_pos, target_radius = interpolate_route(path, target_distance) + target = (target_pos[0], target_pos[1], + target_pos[2] - target_radius + 1.6) + yaw, pitch = camera_angles(camera, target) + plane = (side[0], side[1], 0.0, + -(side[0] * center[0] + side[1] * center[1])) + return {"name": prefix, "camera": camera, "yaw": yaw, "pitch": pitch, + "target": target, "clip": plane, "camera_xy": camera[:2], + "focus": center, "mouth": center} + + # Place each interface view at its true route endpoint. The endpoint camera + # is a side cutaway so the solid terrain in front of an underground mouth + # cannot hide the floor-to-tunnel connection. + arrival_distance = 0.0 + arrival_center, arrival_radius, arrival_tangent, arrival_side = route_frame( + path, arrival_distance) + arrival = cutaway_view("arrival", arrival_center, arrival_radius, + arrival_tangent, arrival_side, arrival_distance, + min(total, arrival_distance + 12.0)) + arrival["camera_location"] = "passage-air" + arrival["focus_radius"] = 20.0 + arrival["yaw"], arrival["pitch"] = camera_angles( + arrival["camera"], arrival["target"]) + + entry_camera_distance = min(8.0, total * 0.1) + entry_center, entry_radius, entry_tangent, entry_side = route_frame( + path, entry_camera_distance) + # Player-height view from just inside the passage, aimed through its actual + # interface mouth so the floor, bore, and opening share one frame. + entry_camera = (entry_center[0], entry_center[1], + entry_center[2] - entry_radius + 1.6) + entry_target_pos, entry_target_radius = interpolate_route( + path, min(total, entry_distance + 14.0)) + entry_target = (entry_target_pos[0], entry_target_pos[1], + entry_camera[2] - 2.5) + entry_yaw, entry_pitch = camera_angles(entry_camera, entry_target) + entry_view = {"name": "rock-entry", "camera": entry_camera, + "yaw": entry_yaw, "pitch": entry_pitch, "target": entry_target, + "clip": None, "camera_xy": entry_camera[:2], + "focus": entry_center, "mouth": entrance, "outside_camera": False, + "camera_location": "passage-air", "focus_radius": 20.0, + "eye_height_z": entry_camera[2]} + middle_view = cutaway_view("mid-tunnel", middle, middle_radius, middle_tangent, + middle_side, middle_distance, min(total, middle_distance + 2.0)) + ground["focus_radius"] = 20.0 + middle_view["focus_radius"] = 20.0 + middle_camera, _ = interpolate_route(path, max(0.0, middle_distance - 6.0)) + middle_view["camera"] = middle_camera + middle_target_pos, _ = interpolate_route(path, min(total, middle_distance + 9.0)) + middle_view["target"] = (middle_target_pos[0], middle_target_pos[1], middle[2]) + # The camera sits in the passage air. Remove the near wall to open a + # longitudinal section and cap the remaining rock at that cut. + middle_cut_point = (middle[0] - middle_side[0] * 2.5, + middle[1] - middle_side[1] * 2.5, middle[2]) + middle_view["clip"] = (*middle_side, + -sum(middle_side[axis] * middle_cut_point[axis] for axis in range(3))) + middle_view["camera_location"] = "passage-air" + middle_view["yaw"], middle_view["pitch"] = camera_angles( + middle_view["camera"], middle_view["target"]) + return (ground, entry_view, middle_view, arrival), total, lower_index + + +def export_capture(scene_name, source, recipe, passage, endpoint, view): + stem = f"{passage['stable_id'].replace('/', '-')}-{endpoint}" + view["name"] = stem + mesh = OUT / f"{stem}.vfmesh" + png = PNG_DIR / f"{stem}.png" + jpg = CAPTURES / f"{stem}.jpg" + yaw, pitch = view["yaw"], view["pitch"] + camera = view["camera"] + export_camera = view.get("focus", camera) + run(f"{stem}-export", [HEADLESS, source, "--workers=12", + "--world-assembler-prototype", "--stream-benchmark", "--stream-pipeline", + "--force-generate", "--clip-radius=3", "--max-clip-level=0", "--max-level=0", + "--render-distance-chunks=0", f"--recipe={recipe}", + f"--camera={fmt((*export_camera, yaw, pitch))}", f"--mesh-export={mesh}"]) + render_clip = view["clip"] + surface = None + if view.get("outside_camera", False): + surface = terrain_surface_near(mesh, *view["camera_xy"], view["target"][2]) + if surface is None: + camera = (view["camera_xy"][0], view["camera_xy"][1], view["eye_height_z"]) + else: + camera = (surface[0], surface[1], surface[2] + 1.6) + elif render_clip is not None: + camera = view["camera"] + yaw, pitch = camera_angles(camera, view["target"]) + print(f"[L67CaptureCamera] view={stem} camera_m=({fmt(camera)}) " + f"target_m=({fmt(view['target'])}) " + f"clip={fmt(render_clip) if render_clip is not None else 'none'} fov_deg=80", + flush=True) + render = [RENDERER, mesh, png, "--size=1600x900", "--fov-deg=80", + f"--camera-m={fmt(camera)}", f"--yaw-deg={yaw:.6f}", f"--pitch-deg={pitch:.6f}", + "--tile-skirts=off", "--shade=readable", "--headlamp=on", "--height-tint=on", + f"--height-center-m={view['target'][2]:.6f}", "--height-range-m=8", "--tile-edges=off"] + if view.get("focus_radius", 24.0) is not None: + render.append(f"--focus-region={fmt((*view['focus'], view.get('focus_radius', 24.0)))}") + if render_clip is not None: + render.append(f"--clip-plane={fmt(render_clip)}") + run(f"{stem}-render", render) + run(f"{stem}-jpeg", ["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", + "-y", "-i", png, "-frames:v", "1", "-q:v", "4", jpg]) + size = jpg.stat().st_size if jpg.exists() else 0 + print(f"[L67CaptureJPEG] file={jpg.relative_to(ROOT)} bytes={size} " + f"status={'PASS' if 0 < size <= 300_000 else 'FAIL'}", flush=True) + if not 0 < size <= 300_000: + raise SystemExit(f"{jpg.name} is missing or exceeds 300 KB") + + +for scene_name, source, recipe, mouth_generator, preferred_side in RECIPE_INPUTS: + if OPTIONS.scene and scene_name != OPTIONS.scene: + continue + layout = load_layout(scene_name, source, recipe) + passage, _ = select_passage(layout, mouth_generator, preferred_side) + forward_views, route_length, lower_index = make_views(layout, passage, False) + reverse_views, _, _ = make_views(layout, passage, True) + print(f"[L67CapturePassage] interface={scene_name.removeprefix('passage-')} " + f"id={passage['stable_id']} route_m={route_length:.3f} " + f"control_points={lower_index + 1}", flush=True) + endpoint_views = ( + ("arrival-mouth", forward_views[0] if scene_name == "passage-0-1" else forward_views[1]), + ("departure-mouth", reverse_views[3]), + ) + for endpoint, view in endpoint_views: + if OPTIONS.view and endpoint != OPTIONS.view: + continue + export_capture(scene_name, source, recipe, passage, endpoint, view) + if scene_name == "passage-1-2" and (OPTIONS.view is None or OPTIONS.view == "mid-tunnel"): + export_capture(scene_name, source, recipe, passage, "mid-tunnel", forward_views[2]) diff --git a/Standalone/Research/WorldAssemblerM3/Linux67/REPORT.md b/Standalone/Research/WorldAssemblerM3/Linux67/REPORT.md new file mode 100644 index 0000000..3f5b004 --- /dev/null +++ b/Standalone/Research/WorldAssemblerM3/Linux67/REPORT.md @@ -0,0 +1,81 @@ +# L67 — four-strate passage walkability + +## Cause and fix + +The FI, TN, and VS paths use the compiled analytic base evaluator and then the shared strate composer. The “native” 5/5 result was the same composed field with resolved passages removed; it was not a raw-generator probe. The band stage is therefore not the difference. The passage carve and the support/capsule gates are. + +- **0–1 SW→FI:** at the failed probe `(21.366, -11.480, -726.367)`, passage 0 first restored support, then passage 1’s bore changed density to `+1.613` (`carve_sdf=-0.292`), removing it. The shared composer now defers that support only when a physically overlapping later bore can reach it, then applies it after the later carve. The column’s natural relief is predictor-driven and tapers at the root; relief rose from `0.172 m` to `0.219 m`. +- **1–2 FI→TN:** at `(2.419, -13.433, -735.870)`, support restoration was rejected by an adjacent route segment’s player-capsule test (`clearance=-0.245 m`, required `0.045 m`). The segment-specific overlap candidates now keep that test local to physically relevant route geometry. The reported `0.2498 m` step came from the audit switching to a neighboring zero crossing on its `0.25 m` scan. Refining and following the same root shows a continuous floor; the step audit now reports `0.000 m`. +- **2–3 TN→VS:** at `(-1.365, -15.673, -897.088)`, the same capsule gate rejected support next to route segment 81 (`clearance=-0.329 m`). The shared segment-overlap handling fixes this link too. + +The L66 `passageIndex>0` / `strates.size()>2` switches are gone. “Internal chain” now means both mouths lie inside their endpoint strata content bands and neither endpoint is a SurfaceWorld landing. Lower-mouth body overlap uses actual 3D distance to the mouth and tread, independent of plan length or passage index. + +## Audit + +All existing thresholds are unchanged. The L66 baseline binary reproduced the original failures. + +| Passage | L66 negative control | L67 result | +|---|---|---| +| 0–1 SW→FI | 59 unsupported; column relief `0.172 m < 0.20 m` | 0 unsupported / 0 blocked / 0 grade failures; wall/floor ripple `1.49 / 0.06 cm`; column relief `0.219 m`; wall relief `0.267 m` | +| 1–2 FI→TN | 3 unsupported; wall/floor ripple `6.78 / 2.90 cm`; apparent `0.2498 m` step | 0 / 0 / 0; wall/floor ripple `2.29 / 1.23 cm`; refined step `0.000 m`; wall relief `0.257 m` | +| 2–3 TN→VS | 2 unsupported | 0 / 0 / 0; wall/floor ripple `1.48 / 0.47 cm`; wall relief `0.248 m` | + +All three now pass support, blocked, grade, mouth-binding, ripple, and naturalness gates. The detailed final audit is in [`owner-four-strata-route4.log`](../../../../Saved/Linux/l67/logs/owner-four-strata-route4.log); the measured composition trace is in [`l66-root-refined-audit.log`](../../../../Saved/Linux/l67/logs/l66-root-refined-audit.log). + +## Controls, hashes, and builds + +- The L66 build remains a negative control: it reproduces `59 / 3 / 2` unsupported and the `0.172 m` column relief failure. The expected audit exit is 10. +- The pinned fast tier passes: **269 PASS, 5 diagnostic, overall PASS**. The prior L52 surface-world hash comparison is diagnostic because the route output changed; the changed style-1 baseline is [pinned at v212](hash-baselines/owner-hash-surface-world-style1-v212.log). See the [fast summary](../../../../Saved/Linux/l67/verify/fast-summary.json). +- Evaluator version is **212** (from 211), required because shared tread composition and route selection changed generated output. Source stamp: `d49aa8d941fb390818569243d7783f3e2799795244a8d7ca48e3e640d4a2876f`. +- All four strata produced 12 equal hash rows at each of W1/W8/W12 ([determinism summary](../../../../Saved/Linux/l67/verify/owner-w1-w8-w12-determinism.json)). Clang and GCC builds both pass. +- Changed implementation files are `Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h`, `Standalone/Core/src/ResolvedLayoutInternal.h`, `Standalone/Core/src/ShapeWorldInternal.h`, `Standalone/Source/OwnerWorldLoader.h`, `Standalone/Source/StreamBenchmarkPipeline.h`, and `Standalone/Tools/verify/verify.py`. No Unreal `Source/` files changed. + +## Cost + +Three interleaved whole-view pairs per strate, L66→L67 median seconds ([summary](../../../../Saved/Linux/l67/cost/cost-summary.json)): + +| Strate | L66 | L67 | Change | +|---:|---:|---:|---:| +| 0 | 1.101 | 1.121 | +1.8% | +| 1 | 1.575 | 1.552 | −1.5% | +| 2 | 0.581 | 0.602 | +3.6% | +| 3 | 0.216 | 0.212 | −1.9% | + +Strate 2’s samples overlap (`0.582–0.600 s`) and paired results are within observed run variation. The route chooser now selects the shortest candidate within `0.10 m` of maximum sampled tread clearance, with tangent alignment as a tie-breaker. It also skips an extra closure turn when mouth separation exceeds the tread envelope; the FI→TN route is `114.231 m` rather than the earlier `165.799 m` candidate. + +## Captures + +Seven JPEGs are in `captures/`; each is under 300 KB. The TN→VS endpoint meshes are sparse in these offscreen views, so those two frames show the nearby cave/strate surface more clearly than the tread. + +- 0–1 arrival [26,342 B](captures/passage-0-1-0-arrival-mouth.jpg), departure [40,012 B](captures/passage-0-1-0-departure-mouth.jpg) +- 1–2 arrival [34,298 B](captures/passage-1-2-0-arrival-mouth.jpg), departure [42,450 B](captures/passage-1-2-0-departure-mouth.jpg) +- 2–3 arrival [23,937 B](captures/passage-2-3-0-arrival-mouth.jpg), departure [23,380 B](captures/passage-2-3-0-departure-mouth.jpg) +- Mid-tunnel, 1–2 [22,392 B](captures/passage-1-2-0-mid-tunnel.jpg) + +## Failed runs + +- The expected L66 negative-control audit exited 10 after reproducing the failures. +- One p0 clipped arrival-camera trial exited 3 because the clip plane left two open contours. It was replaced by the successful uncut SurfaceWorld entrance view. +- A p2 clipped arrival-camera trial also exited 3 on open contours. The final endpoint image uses the no-clip interior view. Both cap failures are recorded in [`failed-capture-trials.log`](../../../../Saved/Linux/l67/logs/failed-capture-trials.log). +- No final capture, Clang/GCC build, determinism run, fast-tier gate, or cost run failed. No run exceeded 30 minutes. + +## Audit controls (follow-up) + +This section supersedes the earlier wall-ripple values; the support, blocked, grade, mouth, and column results remain as reported. The residual-only gauge omitted `pose.radius` and hid an authored-radius beat: + +| Field / gauge | Old gauge (authored radius, linear detrend) | Residual-only gauge | +|---|---:|---:| +| L66 four-strata field | 6.78 cm | 2.21 cm | +| L67 four-strata field | 14.52 cm | 2.29 cm | + +On the old gauge, the geometry change raised 1–2 by 7.74 cm. On L67 geometry, the residual-only gauge then removed 12.23 cm; the reported 6.78→2.29 cm drop therefore masked the radius beat. The final gauge keeps `sampleRadius + 0.5*(left+right)` and cubic-detrends each 32 m window. The 0.18 m is the probe depth inside the authored radius; its former standalone subtraction is removed. The cubic baseline leaves the 0–1 and 2–3 taper profiles below 0.01 cm, while 1–2 retains a 4.77 cm authored-radius beat at 7.06 m. Final ripple results are 1.45 / 7.27 / 0.92 cm: 1–2 fails the unchanged 3.5 cm wall limit. Support, blocked, grade, and mouth-binding checks still pass for all three; overall passage audit is 2/3. See [`audit-control-l67-final-owner.log`](../../../../Saved/Linux/l67/logs/audit-control-l67-final-owner.log) and [`audit-control-authored-radius-all.log`](../../../../Saved/Linux/l67/logs/audit-control-authored-radius-all.log). + +| Negative control under the updated gauge | Measured | Result | +|---|---:|---| +| L35 wall ribs | 9.75 cm at 6.50 m; limit 3.50 cm | Detected | +| Synthetic wall spheres / floor beads | 5.98 / 5.98 cm; limits 3.50 / 2.50 cm | Detected | +| Synthetic signed-distance floor step | 0.25 m step; 2.97 cm ripple vs 2.50 cm limit | Detected | +| L36 washboard field, five-step root refinement | 6.46 cm; limit 2.50 cm | Detected | +| L23 terrace profile | 2.00 m flat then 0.25 m step | Detected | + +No control passed, so no old audit behavior was restored. Logs: [L35 ribs](../../../../Saved/Linux/l67/logs/audit-control-l35-wall-ribs-cubic-audit.log), [L36 washboard](../../../../Saved/Linux/l67/logs/audit-control-l36-washboard-l35field-bisection.log). The L67 synthetic controls are in the final owner log above. One direct link of the current headless code against archived Core libraries failed route setup and was discarded; the control runs used source/Core-matched L35 builds. The follow-up changed only `Standalone/Source/StreamBenchmarkPipeline.h`; Clang Linux build succeeded. No timing run, commit, push, or stash was made. diff --git a/Standalone/Research/WorldAssemblerM3/Linux67/RunInterleavedWholeViewCostL67.py b/Standalone/Research/WorldAssemblerM3/Linux67/RunInterleavedWholeViewCostL67.py new file mode 100644 index 0000000..5c8e0e8 --- /dev/null +++ b/Standalone/Research/WorldAssemblerM3/Linux67/RunInterleavedWholeViewCostL67.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Measure three interleaved L66/L67 whole-view pairs for each owner strate.""" +import json +import os +import re +import subprocess +import time +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[4] +OUT = ROOT / "Saved/Linux/l67/cost" +DATA = ROOT / "Standalone/data/owner_world.json" +BASELINE = ROOT / "Saved/Linux/l67/bin-l66-baseline/VoxelForgeCoreHeadless-l67-l66-baseline-clang" +CANDIDATE = ROOT / "Saved/Linux/l67/bin-final/VoxelForgeCoreHeadless-l67-final-clang" +CAMERAS = { + 0: "0.000000,0.000000,-374.939453,0.000000,0.000000", + 1: "25.836914,3.199219,-737.000000,0.000000,0.000000", + 2: "19.166016,-14.200195,-753.374023,0.000000,0.000000", + 3: "-20.062500,-5.068359,-900.353516,0.000000,0.000000", +} +ORDER = ("l66", "l67", "l67", "l66", "l66", "l67") +PREFIXES = ( + "[PipelineFullDetailHash]", "[PipelineWholeViewHash]", + "[PipelineLODHash]", "[PipelineExactQueryHash]", +) +ELAPSED_RE = re.compile(r"\belapsed_ms=([0-9.]+)") +WHOLE_VIEW_RE = re.compile(r"^\[StreamPipeline\].*?whole_view_ready=([0-9.]+)s", re.M) +ENV = os.environ.copy() + + +def run(strate, repetition, variant): + binary = BASELINE if variant == "l66" else CANDIDATE + name = f"strate{strate}-pair{repetition // 2 + 1}-{variant}" + log_path = OUT / f"{name}.log" + layout_path = OUT / f"{name}.json" + command = [ + str(binary), str(DATA), "--workers=1", "--world-assembler-prototype", + "--stream-benchmark", "--stream-pipeline", "--force-generate", + "--clip-radius=1", "--max-clip-level=8", "--max-level=0", + "--recipe=surface-world", "--stream-hash", "--max-clip-level=0", + "--render-distance-chunks=0", f"--camera={CAMERAS[strate]}", + f"--resolved-layout-output={layout_path}", + ] + wrapped = [ + "taskset", "-c", "0-11", "prlimit", "--as=6442450944", "--", + "timeout", "1800s", *command, + ] + start = time.monotonic() + with log_path.open("w", encoding="utf-8") as stream: + result = subprocess.run(wrapped, cwd=ROOT, env=ENV, stdout=stream, + stderr=subprocess.STDOUT, timeout=1800, check=False) + elapsed = time.monotonic() - start + lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines() + hashes = [line for line in lines if line.startswith(PREFIXES)] + pipeline_elapsed = [float(value) for line in lines + if line.startswith("[PipelineWholeViewHash]") + for value in ELAPSED_RE.findall(line)] + whole_view_match = WHOLE_VIEW_RE.search("\n".join(lines)) + record = { + "strate": strate, "pair_order": repetition, "variant": variant, + "elapsed_seconds": elapsed, "pipeline_elapsed_ms": pipeline_elapsed, + "whole_view_seconds": float(whole_view_match.group(1)) + if whole_view_match else None, + "exit_code": result.returncode, "hash_rows": len(hashes), + "log": str(log_path.relative_to(ROOT)), + } + print(json.dumps(record, sort_keys=True), flush=True) + return record + + +def main(): + OUT.mkdir(parents=True, exist_ok=True) + results = [] + for strate in range(4): + for repetition, variant in enumerate(ORDER): + record = run(strate, repetition, variant) + results.append(record) + if record["exit_code"] != 0 or record["hash_rows"] != 12: + break + else: + continue + break + strata = {} + for strate in range(4): + values = {variant: [row["whole_view_seconds"] for row in results + if row["strate"] == strate and row["variant"] == variant + and row["whole_view_seconds"] is not None] + for variant in ("l66", "l67")} + if all(len(values[variant]) == 3 for variant in ("l66", "l67")): + l66 = sorted(values["l66"])[1] + l67 = sorted(values["l67"])[1] + strata[str(strate)] = {"l66_seconds": values["l66"], + "l67_seconds": values["l67"], "l66_median_seconds": l66, + "l67_median_seconds": l67, + "change_percent": (l67 / l66 - 1.0) * 100.0} + summary = {"pairs_per_strate": 3, "order": ORDER, "strata": strata, + "runs": results, + "complete": len(results) == 24 + and all(x["exit_code"] == 0 and x["hash_rows"] == 12 + for x in results)} + (OUT / "cost-summary.json").write_text(json.dumps(summary, indent=2) + "\n", + encoding="utf-8") + print(f"[L67CostSummary] complete={summary['complete']} runs={len(results)}", + flush=True) + return 0 if summary["complete"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Standalone/Research/WorldAssemblerM3/Linux67/RunOwnerW12DeterminismL67.py b/Standalone/Research/WorldAssemblerM3/Linux67/RunOwnerW12DeterminismL67.py new file mode 100644 index 0000000..8c5f759 --- /dev/null +++ b/Standalone/Research/WorldAssemblerM3/Linux67/RunOwnerW12DeterminismL67.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Compare owner four-strata W12 hashes with the pinned W1/W8 cameras.""" +import json +import re +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[4] +OUT = ROOT / "Saved/Linux/l67" +DATA = ROOT / "Standalone/data/owner_world.json" +BINARY = OUT / "bin-final/VoxelForgeCoreHeadless-l67-final-clang" +HASH_RE = re.compile(r"^\[Pipeline(?:FullDetailHash|WholeViewHash|LODHash|ExactQueryHash)\].*$", re.M) +CAMERA_RE = re.compile(r"camera_m=\(([-0-9.]+),([-0-9.]+),([-0-9.]+)\)") +RESULTS = [] + + +def hashes(path): + return HASH_RE.findall(path.read_text(encoding="utf-8", errors="replace")) + + +def main(): + logs = OUT / "logs" + layouts = OUT / "layouts" + logs.mkdir(parents=True, exist_ok=True) + layouts.mkdir(parents=True, exist_ok=True) + for strate in range(4): + w1_path = OUT / "verify" / f"owner-new-plan-clang-strate{strate}-w1.log" + w8_path = OUT / "verify" / f"owner-new-plan-clang-strate{strate}-w8.log" + match = CAMERA_RE.search(w1_path.read_text(encoding="utf-8", errors="replace")) + if not match: + raise RuntimeError(f"camera not found in {w1_path}") + camera = ",".join(match.groups()) + ",0.000000,0.000000" + log_path = logs / f"owner-new-plan-clang-strate{strate}-w12-final.log" + layout_path = layouts / f"owner-new-plan-clang-strate{strate}-w12-final.json" + command = [ + "taskset", "-c", "0-11", "prlimit", "--as=6442450944", "--", + "timeout", "1800s", str(BINARY), str(DATA), "--workers=12", + "--world-assembler-prototype", "--stream-benchmark", "--stream-pipeline", + "--force-generate", "--clip-radius=1", "--max-clip-level=8", + "--max-level=0", "--recipe=surface-world", "--stream-hash", + "--max-clip-level=0", "--render-distance-chunks=0", f"--camera={camera}", + f"--resolved-layout-output={layout_path}", + ] + with log_path.open("w", encoding="utf-8") as stream: + result = subprocess.run(command, cwd=ROOT, stdout=stream, + stderr=subprocess.STDOUT, timeout=1800, check=False) + current = hashes(log_path) + w1 = hashes(w1_path) + w8 = hashes(w8_path) + passed = result.returncode == 0 and len(current) == 12 \ + and current == w1 == w8 + row = {"strate": strate, "exit_code": result.returncode, + "w1_hashes": len(w1), "w8_hashes": len(w8), + "w12_hashes": len(current), "w1_w8_w12_equal": passed, + "log": str(log_path.relative_to(ROOT))} + RESULTS.append(row) + print(json.dumps(row, sort_keys=True), flush=True) + summary = {"status": "PASS" if all(row["w1_w8_w12_equal"] for row in RESULTS) + else "FAIL", "strata": RESULTS} + (OUT / "verify" / "owner-w1-w8-w12-determinism.json").write_text( + json.dumps(summary, indent=2) + "\n", encoding="utf-8") + print(f"[L67OwnerW1W8W12] status={summary['status']}", flush=True) + return 0 if summary["status"] == "PASS" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Standalone/Research/WorldAssemblerM3/Linux67/captures/passage-0-1-0-arrival-mouth.jpg b/Standalone/Research/WorldAssemblerM3/Linux67/captures/passage-0-1-0-arrival-mouth.jpg new file mode 100644 index 0000000..fa4f74f Binary files /dev/null and b/Standalone/Research/WorldAssemblerM3/Linux67/captures/passage-0-1-0-arrival-mouth.jpg differ diff --git a/Standalone/Research/WorldAssemblerM3/Linux67/captures/passage-0-1-0-departure-mouth.jpg b/Standalone/Research/WorldAssemblerM3/Linux67/captures/passage-0-1-0-departure-mouth.jpg new file mode 100644 index 0000000..873350e Binary files /dev/null and b/Standalone/Research/WorldAssemblerM3/Linux67/captures/passage-0-1-0-departure-mouth.jpg differ diff --git a/Standalone/Research/WorldAssemblerM3/Linux67/captures/passage-1-2-0-arrival-mouth.jpg b/Standalone/Research/WorldAssemblerM3/Linux67/captures/passage-1-2-0-arrival-mouth.jpg new file mode 100644 index 0000000..78bc39a Binary files /dev/null and b/Standalone/Research/WorldAssemblerM3/Linux67/captures/passage-1-2-0-arrival-mouth.jpg differ diff --git a/Standalone/Research/WorldAssemblerM3/Linux67/captures/passage-1-2-0-departure-mouth.jpg b/Standalone/Research/WorldAssemblerM3/Linux67/captures/passage-1-2-0-departure-mouth.jpg new file mode 100644 index 0000000..3e37e78 Binary files /dev/null and b/Standalone/Research/WorldAssemblerM3/Linux67/captures/passage-1-2-0-departure-mouth.jpg differ diff --git a/Standalone/Research/WorldAssemblerM3/Linux67/captures/passage-1-2-0-mid-tunnel.jpg b/Standalone/Research/WorldAssemblerM3/Linux67/captures/passage-1-2-0-mid-tunnel.jpg new file mode 100644 index 0000000..a375293 Binary files /dev/null and b/Standalone/Research/WorldAssemblerM3/Linux67/captures/passage-1-2-0-mid-tunnel.jpg differ diff --git a/Standalone/Research/WorldAssemblerM3/Linux67/captures/passage-2-3-0-arrival-mouth.jpg b/Standalone/Research/WorldAssemblerM3/Linux67/captures/passage-2-3-0-arrival-mouth.jpg new file mode 100644 index 0000000..9b73ba9 Binary files /dev/null and b/Standalone/Research/WorldAssemblerM3/Linux67/captures/passage-2-3-0-arrival-mouth.jpg differ diff --git a/Standalone/Research/WorldAssemblerM3/Linux67/captures/passage-2-3-0-departure-mouth.jpg b/Standalone/Research/WorldAssemblerM3/Linux67/captures/passage-2-3-0-departure-mouth.jpg new file mode 100644 index 0000000..fdc08dd Binary files /dev/null and b/Standalone/Research/WorldAssemblerM3/Linux67/captures/passage-2-3-0-departure-mouth.jpg differ diff --git a/Standalone/Source/OwnerWorldLoader.h b/Standalone/Source/OwnerWorldLoader.h index 1aabf11..fe5e2d4 100644 --- a/Standalone/Source/OwnerWorldLoader.h +++ b/Standalone/Source/OwnerWorldLoader.h @@ -43,8 +43,10 @@ struct FStandalonePassageVoidCrossing std::int64_t minimumRadiusSubvoxels=0; std::int64_t nominalRadiusSubvoxels=0; std::int64_t maximumRadiusSubvoxels=0; + double naturalSurfaceReliefMetres=0.0; double maximumRouteOffsetMetres=0.0; double maximumBoreRadiusMetres=0.0; + bool needsRootTaper=false; }; struct FStandalonePassageVoidCrossingCache diff --git a/Standalone/Source/StreamBenchmarkPipeline.h b/Standalone/Source/StreamBenchmarkPipeline.h index a3ca237..347cbf8 100644 --- a/Standalone/Source/StreamBenchmarkPipeline.h +++ b/Standalone/Source/StreamBenchmarkPipeline.h @@ -2357,19 +2357,22 @@ namespace StandaloneStreamBenchmark {{0.0,-supportProbeRadiusMetres}}}}; std::uint32_t supportedProbes=0; std::uint32_t nativeSupportedProbes=0; - double firstUnsupportedX=x,firstUnsupportedY=y,lastUnsupportedDensity=0.0; + double firstUnsupportedX=x,firstUnsupportedY=y, + firstUnsupportedZ=footZ,lastUnsupportedDensity=0.0; std::array supportMask{}; for(std::size_t offsetIndex=0;offsetIndex>& output) { if(values.size()<64)return; + // Remove a smooth mouth taper across each 32 m window while + // preserving the authored radius, where regular wall ribs can live. + // A cubic baseline tracks the broad flare without fitting the + // 0.5–8 m periods that this audit is meant to detect. + constexpr std::size_t Degree=3; + std::array,Degree+1> normal{}; const double meanIndex=0.5*static_cast(values.size()-1); - double mean=0.0,xy=0.0,xx=0.0; - for(double value:values)mean+=value; - mean/=static_cast(values.size()); + const double halfSpan=std::max(1.0,meanIndex); for(std::size_t i=0;i(i)-meanIndex)*SampleSpacingMetres; - xy+=x*(values[i]-mean);xx+=x*x; + const double x=(static_cast(i)-meanIndex)/halfSpan; + std::array powers{}; + powers[0]=1.0; + for(std::size_t power=1;power + std::abs(normal[pivot][column]))pivot=row; + if(std::abs(normal[pivot][column])<=1.0e-12)return; + if(pivot!=column)std::swap(normal[pivot],normal[column]); + const double divisor=normal[column][column]; + for(std::size_t value=column;value<=Degree+1;++value) + normal[column][value]/=divisor; + for(std::size_t row=0;row<=Degree;++row) + { + if(row==column)continue; + const double scale=normal[row][column]; + for(std::size_t value=column;value<=Degree+1;++value) + normal[row][value]-=scale*normal[column][value]; + } } - const double slope=xx>0.0?xy/xx:0.0; for(std::size_t i=0;i(i)-meanIndex)*SampleSpacingMetres; + const double x=(static_cast(i)-meanIndex)/halfSpan; + double trend=normal[Degree][Degree+1]; + for(std::size_t coefficient=Degree;coefficient-->0;) + trend=trend*x+normal[coefficient][Degree+1]; output.push_back({startDistance+static_cast(i)*SampleSpacingMetres, - values[i]-mean-slope*x}); + values[i]-trend}); } }; const auto appendDetrendedPairs=[]( @@ -2915,6 +2959,50 @@ namespace StandaloneStreamBenchmark audit.floorControlAmplitudeMetres=audit.controlAmplitudeMetres; audit.floorControlDetected=audit.floorControlAmplitudeMetres >=FloorRippleAmplitudeLimitMetres; + // A signed-distance floor with a real 0.25 m ledge must still trip + // the same floor-ripple gauge after the crossing-refinement change. + std::vector> floorStepResiduals; + floorStepResiduals.reserve(static_cast(WindowLengthMetres/ + SampleSpacingMetres)+1); + constexpr double FloorStepControlProbeSpacingMetres=0.25; + for(std::size_t sample=0; + sample<=static_cast(WindowLengthMetres/SampleSpacingMetres); + ++sample) + { + const double distance=static_cast(sample)*SampleSpacingMetres; + const double stepHeight=distance>=16.0?0.25:0.0; + double previousDensity=0.0,previousZ=0.0; + bool havePrevious=false,haveFloor=false; + double bestFloor=0.0,bestDistance=std::numeric_limits::infinity(); + for(int vertical=0;vertical<7;++vertical) + { + const double z=-1.0+vertical*FloorStepControlProbeSpacingMetres; + const double density=z-stepHeight; + if(havePrevious&&previousDensity<0.0&&density>=0.0) + { + double lowZ=previousZ,highZ=z; + for(int refine=0;refine<5;++refine) + { + const double middleZ=(lowZ+highZ)*0.5; + if(middleZ-stepHeight<0.0)lowZ=middleZ; + else highZ=middleZ; + } + const double crossing=(lowZ+highZ)*0.5; + const double candidateDistance=std::abs(crossing); + if(candidateDistance> floorStepSamples; + appendDetrendedPairs(floorStepResiduals,floorStepSamples); + double floorStepPeriod=0.0; + audit.floorStepControlAmplitudeMetres=sinusoidAmplitude( + floorStepSamples,floorStepPeriod); + audit.floorStepControlDetected=audit.floorStepControlAmplitudeMetres + >=FloorRippleAmplitudeLimitMetres; const std::array windowStarts{{0.03,0.31,0.457,0.61,0.91}}; std::vector> coherentSamples; std::vector> coherentFloorSamples; @@ -2955,8 +3043,27 @@ namespace StandaloneStreamBenchmark if(havePrevious&&previousDensity<0.0&&density>=0.0) { const double denominator=density-previousDensity; - const double crossing=denominator>1.0e-12 + double crossing=denominator>1.0e-12 ?previousZ+(z-previousZ)*(-previousDensity)/denominator:z; + // Density can change sharply at a composed floor. Refine + // the sign bracket instead of assuming it is linear over + // an entire Core voxel; linear interpolation can report a + // one-voxel step even when the zero crossing is nearby. + double lowZ=previousZ,highZ=z; + double lowDensity=previousDensity,highDensity=density; + for(int refine=0;refine<5;++refine) + { + const double middleZ=(lowZ+highZ)*0.5; + const double middleDensity=static_cast(Field.densityAt({ + pose.x/coreMetres,pose.y/coreMetres,middleZ/coreMetres})) + *coreMetres; + if(middleDensity<0.0) + {lowZ=middleZ;lowDensity=middleDensity;} + else + {highZ=middleZ;highDensity=middleDensity;} + } + (void)lowDensity;(void)highDensity; + crossing=(lowZ+highZ)*0.5; const double candidateDistance=std::abs(crossing-expectedFloor); if(candidateDistance0.75||right< -0.75||right>0.75)continue; + // 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)); ++audit.samples; } @@ -3052,7 +3162,8 @@ namespace StandaloneStreamBenchmark ?static_cast(audit.floorSamples)/audit.floorExpectedSamples:0.0; audit.passed=audit.controlDetected&&coverage>=0.75 &&audit.localPeakAmplitudeMetres=0.75 + &&audit.floorControlDetected&&audit.floorStepControlDetected + &&floorCoverage>=0.75 &&audit.floorAmplitudeMetres(rippleAudit.floorExpectedSamples), rippleAudit.floorControlAmplitudeMetres, rippleAudit.floorControlDetected?"true":"false", + rippleAudit.floorStepControlAmplitudeMetres, + rippleAudit.floorStepControlDetected?"true":"false", rippleAudit.passed?"PASS":"FAIL"); + std::printf("[PassageFloorStepNegativeControl] id=%s source=synthetic_signed_distance_step height_m=0.250 threshold_m=0.025 amplitude_m=%.4f detected=%s\n", + passage.stableId.c_str(),rippleAudit.floorStepControlAmplitudeMetres, + rippleAudit.floorStepControlDetected?"true":"false"); for(std::size_t sample=0;sample(ripplePasses), static_cast(passageCount), rippleControlsDetected==passageCount?"DETECTED":"MISSED", floorRippleControlsDetected==passageCount?"DETECTED":"MISSED", + floorStepRippleControlsDetected==passageCount?"DETECTED":"MISSED", ripplePasses==passageCount?"PASS":"FAIL"); const bool auditPassed=passageCount>0&&passagePasses==passageCount &&naturalPasses==passageCount&&l23SmoothWallDetected diff --git a/Standalone/Tools/verify/verify.py b/Standalone/Tools/verify/verify.py index 04916f0..75b6ca8 100644 --- a/Standalone/Tools/verify/verify.py +++ b/Standalone/Tools/verify/verify.py @@ -29,6 +29,7 @@ LINUX54 = ROOT / "Standalone/Research/WorldAssemblerM3/Linux54" LINUX56 = ROOT / "Standalone/Research/WorldAssemblerM3/Linux56" LINUX58 = ROOT / "Standalone/Research/WorldAssemblerM3/Linux58" LINUX60 = ROOT / "Standalone/Research/WorldAssemblerM3/Linux60" +LINUX67 = ROOT / "Standalone/Research/WorldAssemblerM3/Linux67" OWNER = ROOT / "Standalone/data/regression/owner_world_2strate_l61.json" OWNER_NEW = ROOT / "Standalone/data/owner_world.json" OWNER_2STRATE_SHA256 = "7b095f9bcc6b90133d5a463f99f0b571133b2c549ce1ecf2d64fe9175e41e183" @@ -623,9 +624,25 @@ def run_hash_pairs(runner: VerificationRunner, l52_baseline = ROOT / "Saved/Linux/l52" / f"owner-hash-{recipe}-style{style}-w12.log" l52_hashes = tuple(HASH_RE.findall(l52_baseline.read_text( encoding="utf-8", errors="replace"))) - same_l52 = hashes[12] == l52_hashes and len(l52_hashes) == 12 - runner.require(f"owner-hash-{recipe}-w12-matches-L52", same_l52, - f"current={len(hashes[12])} L52={len(l52_hashes)} exact canonical hash rows") + if recipe == "surface-world": + expected_baseline = LINUX67 / "hash-baselines" / ( + "owner-hash-surface-world-style1-v212.log") + expected_label = "L67-v212" + else: + expected_baseline = l52_baseline + expected_label = "L52" + expected_hashes = tuple(HASH_RE.findall(expected_baseline.read_text( + encoding="utf-8", errors="replace"))) + same_expected = hashes[12] == expected_hashes and len(expected_hashes) == 12 + runner.require(f"owner-hash-{recipe}-w12-matches-{expected_label}", same_expected, + f"current={len(hashes[12])} {expected_label}={len(expected_hashes)} exact canonical hash rows") + if recipe == "surface-world": + diagnostic = {"name": "owner-hash-surface-world-matches-L52-diagnostic", + "status": "DIAGNOSTIC", + "detail": f"matches={hashes[12] == l52_hashes} L52 rows={len(l52_hashes)}; " + "the v212 generated-route change has its own pinned baseline"} + runner.steps.append(diagnostic) + print(f"[{runner.tier}] {diagnostic['name']} {diagnostic['detail']}", flush=True) baseline = ROOT / "Saved/Linux/l46" / f"owner-hash-{recipe}-style{style}-w12.log" old_hashes = tuple(HASH_RE.findall(baseline.read_text(encoding="utf-8", errors="replace"))) same_l46 = hashes[12] == old_hashes and len(old_hashes) == 12