diff --git a/Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h b/Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h index 8c44461..a271b75 100644 --- a/Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h +++ b/Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h @@ -1226,6 +1226,39 @@ namespace VoxelForgeCore std::vector controlPoints; }; + // Passage shell, relief, smooth-max support, and rounding guard in the shared + // planner's integer micrometre coordinate space. Keep this aligned with the + // passage evaluator's L130 reach contract. + inline constexpr double ResolvedPassageInfluenceReachMicrometresV1 = + (0.75 + 0.21 + 0.50 / 6.0 + 0.01) * 1000000.0; + // The bounded joint covers the planner's 2 m mouth overlap and 6 m portal lead. + inline constexpr std::int64_t ResolvedPassageMouthJointRadiusMicrometresV1 = 8000000; + + // A planner passes the conservative bounds of each layer primitive through this + // shared gate. Only explicitly associated mouth-joint primitives may overlap + // the bounded spatial joint around their named passage mouth. + struct ResolvedLayoutPrimitiveBoundsV1 + { + std::string stableId; + std::int32_t strateIndex = -1; + std::array boundsMinimumMicrometres{}; + std::array boundsMaximumMicrometres{}; + std::string mouthJointPassageStableId; + std::int64_t mouthJointRadiusMicrometres = 0; + }; + + struct ResolvedLayoutPassageExclusionAuditV1 + { + std::uint64_t primitivesChecked = 0; + std::uint64_t overlapsOutsideJoint = 0; + std::uint64_t mouthJointAssociations = 0; + std::uint32_t negativeControlsRun = 0; + std::uint32_t negativeControlsRejected = 0; + std::string firstOverlappingPrimitive; + std::string firstOverlappingPassage; + bool passed = false; + }; + struct ResolvedLayoutPassageConfigV1 { std::int32_t upperStrateIndex = -1; @@ -1340,6 +1373,14 @@ namespace VoxelForgeCore std::vector& rooms, std::vector& edges, std::string& error); + VFCORE_API bool IsResolvedLayoutPrimitiveOutsidePassageEnvelopeV1( + const ResolvedLayoutSnapshotV3& layout, + const ResolvedLayoutPrimitiveBoundsV1& primitive, + std::string& conflictingPassageStableId); + VFCORE_API ResolvedLayoutPassageExclusionAuditV1 + AuditResolvedLayoutPassageExclusionV1( + const ResolvedLayoutSnapshotV3& layout, + std::span primitives); VFCORE_API bool ResolveResolvedLayoutOreVeinsV1( ResolvedLayoutSnapshotV3& layout, std::int32_t seed, std::span requests, diff --git a/Standalone/Core/src/ResolvedLayoutInternal.h b/Standalone/Core/src/ResolvedLayoutInternal.h index 7b7520a..2550c77 100644 --- a/Standalone/Core/src/ResolvedLayoutInternal.h +++ b/Standalone/Core/src/ResolvedLayoutInternal.h @@ -2306,6 +2306,301 @@ bool ResolveResolvedCavernGraphV1(ResolvedLayoutSnapshotV3& layout,std::int32_t layout=std::move(candidate);return true; } +namespace +{ + struct FResolvedPassageEnvelopeSegment + { + std::string stableId; + std::int32_t upperStrateIndex=-1,lowerStrateIndex=-1; + std::array a{},b{}; + std::array upperMouthCenter{},lowerMouthCenter{}; + double radiusMicrometres=0.0; + double alongMicrometres=0.0,lengthMicrometres=0.0,totalLengthMicrometres=0.0; + }; + + std::vector BuildResolvedPassageEnvelopeSegments( + const ResolvedLayoutSnapshotV3& layout) + { + std::vector result; + for(const auto& passage:layout.passages) + { + if(passage.controlPoints.size()<2||layout.coreVoxelSizeMicrometres==0)continue; + const std::size_t routeEnd=std::clamp( + passage.lowerMouthControlIndex,1,passage.controlPoints.size()-1); + std::vector controls; + controls.reserve(passage.controlPoints.size()); + const double scale=static_cast(layout.coreVoxelSizeMicrometres) + /WorldAssemblySubvoxelsPerVoxel; + for(const auto& point:passage.controlPoints) + controls.push_back({point.positionSubvoxels[0]*scale, + point.positionSubvoxels[1]*scale,point.positionSubvoxels[2]*scale, + point.radiusSubvoxels*scale}); + const auto segments=PassageSpline::BuildSegments(controls,routeEnd,2.0, + 1000000.0); + if(segments.empty())continue; + const double totalLength=(segments.back().alongMetres + +segments.back().lengthMetres)*1000000.0; + const std::size_t lowerMouthIndex=std::min(routeEnd,controls.size()-1); + for(const auto& segment:segments) + { + FResolvedPassageEnvelopeSegment envelope; + envelope.stableId=passage.stableId; + envelope.upperStrateIndex=passage.upperStrateIndex; + envelope.lowerStrateIndex=passage.lowerStrateIndex; + for(std::size_t axis=0;axis<3;++axis) + { + envelope.a[axis]=segment.a[axis]; + envelope.b[axis]=segment.b[axis]; + envelope.upperMouthCenter[axis]=controls.front()[axis]; + envelope.lowerMouthCenter[axis]=controls[lowerMouthIndex][axis]; + } + envelope.radiusMicrometres=std::max(segment.a[3],segment.b[3]); + envelope.alongMicrometres=segment.alongMetres*1000000.0; + envelope.lengthMicrometres=segment.lengthMetres*1000000.0; + envelope.totalLengthMicrometres=totalLength; + result.push_back(std::move(envelope)); + } + } + return result; + } + + bool ResolvedPrimitiveBoundsOverlap(const ResolvedLayoutPrimitiveBoundsV1& primitive, + const std::array& segmentA,const std::array& segmentB, + double envelopeRadius) + { + std::array direction{}; + std::vector breakpoints{0.0,1.0}; + for(std::size_t axis=0;axis<3;++axis) + { + direction[axis]=segmentB[axis]-segmentA[axis]; + if(std::abs(direction[axis])<=1.0e-18)continue; + for(const auto boundary:{static_cast(primitive.boundsMinimumMicrometres[axis]), + static_cast(primitive.boundsMaximumMicrometres[axis])}) + { + const double t=(boundary-segmentA[axis])/direction[axis]; + if(t>0.0&&t<1.0)breakpoints.push_back(t); + } + } + std::sort(breakpoints.begin(),breakpoints.end()); + breakpoints.erase(std::unique(breakpoints.begin(),breakpoints.end()), + breakpoints.end()); + const auto distanceSquared=[&](double t) + { + double result=0.0; + for(std::size_t axis=0;axis<3;++axis) + { + const double position=segmentA[axis]+direction[axis]*t; + const double gap=positionprimitive.boundsMaximumMicrometres[axis] + ?position-primitive.boundsMaximumMicrometres[axis]:0.0; + result+=gap*gap; + } + return result; + }; + double closestSquared=std::min(distanceSquared(0.0),distanceSquared(1.0)); + for(std::size_t index=1;indexprimitive.boundsMaximumMicrometres[axis]) + {boundary=primitive.boundsMaximumMicrometres[axis];active=true;} + if(!active)continue; + quadratic+=direction[axis]*direction[axis]; + linear+=direction[axis]*(segmentA[axis]-boundary); + } + if(quadratic>1.0e-18) + { + const double closest=std::clamp(-linear/quadratic,low,high); + closestSquared=std::min(closestSquared,distanceSquared(closest)); + } + closestSquared=std::min(closestSquared,distanceSquared(low)); + closestSquared=std::min(closestSquared,distanceSquared(high)); + } + return closestSquared<=envelopeRadius*envelopeRadius; + } +} + +bool IsResolvedLayoutPrimitiveOutsidePassageEnvelopeV1( + const ResolvedLayoutSnapshotV3& layout, + const ResolvedLayoutPrimitiveBoundsV1& primitive, + std::string& conflictingPassageStableId) +{ + conflictingPassageStableId.clear(); + for(std::size_t axis=0;axis<3;++axis) + if(primitive.boundsMinimumMicrometres[axis] + >primitive.boundsMaximumMicrometres[axis]) + {conflictingPassageStableId="invalid-primitive-bounds";return false;} + if(primitive.mouthJointRadiusMicrometres<0 + ||primitive.mouthJointRadiusMicrometres + >ResolvedPassageMouthJointRadiusMicrometresV1) + {conflictingPassageStableId="invalid-mouth-joint-radius";return false;} + + const auto envelope=BuildResolvedPassageEnvelopeSegments(layout); + bool matchedMouthJoint=primitive.mouthJointPassageStableId.empty(); + for(const auto& segment:envelope) + { + const bool isUpper=primitive.strateIndex==segment.upperStrateIndex; + const bool isLower=primitive.strateIndex==segment.lowerStrateIndex; + if(!isUpper&&!isLower)continue; + const bool authorized=primitive.mouthJointPassageStableId==segment.stableId; + if(authorized)matchedMouthJoint=true; + const double radius=segment.radiusMicrometres + +ResolvedPassageInfluenceReachMicrometresV1+1000.0; + const auto rejectAt=[&](double t) + { + conflictingPassageStableId=segment.stableId; + const std::array center{ + std::lerp(segment.a[0],segment.b[0],t), + std::lerp(segment.a[1],segment.b[1],t), + std::lerp(segment.a[2],segment.b[2],t)}; + const auto mouthDistance=[&](const auto& mouth) + { + const double dx=center[0]-mouth[0],dy=center[1]-mouth[1], + dz=center[2]-mouth[2]; + return std::sqrt(dx*dx+dy*dy+dz*dz)/1.0e6; + }; + std::printf("[PassageExclusionConflictDetail] primitive=%s passage=%s station_m=%.3f authorized_joint=%s joint_radius_m=%.3f envelope_radius_m=%.3f nearest_mouth_m=%.3f center_m=(%.3f,%.3f,%.3f) bounds_min_m=(%.3f,%.3f,%.3f) bounds_max_m=(%.3f,%.3f,%.3f)\n", + primitive.stableId.c_str(),segment.stableId.c_str(), + (segment.alongMicrometres+segment.lengthMicrometres*t)/1.0e6, + authorized?"true":"false", + primitive.mouthJointRadiusMicrometres/1.0e6,radius/1.0e6, + std::min(mouthDistance(segment.upperMouthCenter), + mouthDistance(segment.lowerMouthCenter)), + center[0]/1.0e6,center[1]/1.0e6,center[2]/1.0e6, + primitive.boundsMinimumMicrometres[0]/1.0e6, + primitive.boundsMinimumMicrometres[1]/1.0e6, + primitive.boundsMinimumMicrometres[2]/1.0e6, + primitive.boundsMaximumMicrometres[0]/1.0e6, + primitive.boundsMaximumMicrometres[1]/1.0e6, + primitive.boundsMaximumMicrometres[2]/1.0e6); + return false; + }; + const auto overlaps=[&](double t0,double t1) + { + if(t1<=t0)return false; + std::array a{},b{}; + for(std::size_t axis=0;axis<3;++axis) + { + a[axis]=std::lerp(segment.a[axis],segment.b[axis],t0); + b[axis]=std::lerp(segment.a[axis],segment.b[axis],t1); + } + return ResolvedPrimitiveBoundsOverlap(primitive,a,b,radius); + }; + if(authorized&&primitive.mouthJointRadiusMicrometres>0) + { + const auto& center=isUpper?segment.upperMouthCenter:segment.lowerMouthCenter; + std::array delta{}; + double quadratic=0.0,linear=0.0,constant=0.0; + for(std::size_t axis=0;axis<3;++axis) + { + delta[axis]=segment.b[axis]-segment.a[axis]; + const double offset=segment.a[axis]-center[axis]; + quadratic+=delta[axis]*delta[axis]; + linear+=offset*delta[axis]; + constant+=offset*offset; + } + const double jointReach=primitive.mouthJointRadiusMicrometres+radius; + constant-=jointReach*jointReach; + const double discriminant=linear*linear-quadratic*constant; + if(quadratic<=1.0e-18) + { + // A degenerate segment that is wholly in the joint is authorized; + // one outside it remains subject to the full envelope check. + if(constant<=0.0)continue; + if(overlaps(0.0,1.0)) + return rejectAt(0.5); + continue; + } + if(discriminant<0.0) + { + if(overlaps(0.0,1.0)) + return rejectAt(0.5); + continue; + } + const double root=std::sqrt(discriminant); + const double insideFirst=(-linear-root)/quadratic; + const double insideLast=(-linear+root)/quadratic; + const double clippedFirst=std::clamp(insideFirst,0.0,1.0); + const double clippedLast=std::clamp(insideLast,0.0,1.0); + if(insideFirst>=1.0||insideLast<=0.0) + { + if(overlaps(0.0,1.0)) + return rejectAt(0.5); + continue; + } + if(overlaps(0.0,clippedFirst)||overlaps(clippedLast,1.0)) + return rejectAt(clippedFirst>0.0?clippedFirst*0.5: + (1.0+clippedLast)*0.5); + continue; + } + if(overlaps(0.0,1.0)) + return rejectAt(0.5); + } + if(!matchedMouthJoint) + {conflictingPassageStableId=primitive.mouthJointPassageStableId;return false;} + return true; +} + +ResolvedLayoutPassageExclusionAuditV1 AuditResolvedLayoutPassageExclusionV1( + const ResolvedLayoutSnapshotV3& layout, + std::span primitives) +{ + ResolvedLayoutPassageExclusionAuditV1 result; + for(const auto& primitive:primitives) + { + ++result.primitivesChecked; + if(!primitive.mouthJointPassageStableId.empty()) + ++result.mouthJointAssociations; + std::string conflict; + if(!IsResolvedLayoutPrimitiveOutsidePassageEnvelopeV1(layout,primitive,conflict)) + { + ++result.overlapsOutsideJoint; + if(result.firstOverlappingPrimitive.empty()) + { + result.firstOverlappingPrimitive=primitive.stableId; + result.firstOverlappingPassage=std::move(conflict); + } + } + } + + const auto passage=std::find_if(layout.passages.begin(),layout.passages.end(), + [](const auto& value){return value.controlPoints.size()>=2;}); + if(passage!=layout.passages.end()&&layout.coreVoxelSizeMicrometres>0) + { + const auto& point=passage->controlPoints[passage->controlPoints.size()/2] + .positionSubvoxels; + ResolvedLayoutPrimitiveBoundsV1 control; + control.stableId="negative-control/forced-inside-passage-envelope"; + control.strateIndex=passage->lowerStrateIndex; + const double scale=static_cast(layout.coreVoxelSizeMicrometres) + /WorldAssemblySubvoxelsPerVoxel; + for(std::size_t axis=0;axis<3;++axis) + { + const auto center=static_cast(std::llround(point[axis]*scale)); + control.boundsMinimumMicrometres[axis]=center-250000; + control.boundsMaximumMicrometres[axis]=center+250000; + } + ++result.negativeControlsRun; + std::string conflict; + if(!IsResolvedLayoutPrimitiveOutsidePassageEnvelopeV1(layout,control,conflict) + &&conflict==passage->stableId) + ++result.negativeControlsRejected; + } + result.passed=result.overlapsOutsideJoint==0 + &&result.negativeControlsRun==1 + &&result.negativeControlsRejected==result.negativeControlsRun; + return result; +} + bool PlanResolvedCavernNetworkV1(const ResolvedLayoutSnapshotV3& layout, std::int32_t seed,std::int32_t strateIndex, const ResolvedCavernPlannerSettingsV1& settings, @@ -3484,6 +3779,127 @@ bool PlanResolvedCavernNetworkV1(const ResolvedLayoutSnapshotV3& layout, std::atan2(static_cast(rise),static_cast(run)) *180.0/3.14159265358979323846); } + auto boundedGraph=layout; + std::string boundsError; + if(!ResolveResolvedCavernGraphV1(boundedGraph,seed,rooms,edges,boundsError)) + {error="cavern planner could not validate primitive bounds: "+boundsError;return false;} + std::vector primitiveBounds; + primitiveBounds.reserve(boundedGraph.cavernRooms.size()+boundedGraph.cavernEdges.size()); + const std::int64_t mouthJointFeatureSpanMicrometres= + MouthOverlap+PortalLeadLength+2*settings.destinationRadiusMicrometres; + const auto mouthJointAtProfileDistance=[&](std::int64_t distance) + ->std::string + { + if(distance<=mouthJointFeatureSpanMicrometres) + return arrivalPassage->stableId; + if(departurePassage&&totalRouteLength-distance + <=mouthJointFeatureSpanMicrometres) + return departurePassage->stableId; + return {}; + }; + const auto associateSpatialMouthJoint=[&](ResolvedLayoutPrimitiveBoundsV1& bounds) + { + if(!bounds.mouthJointPassageStableId.empty())return; + const double jointRadius= + static_cast(ResolvedPassageMouthJointRadiusMicrometresV1); + double nearestSquared=jointRadius*jointRadius; + for(const auto& passage:layout.passages) + { + if(bounds.strateIndex!=passage.upperStrateIndex + &&bounds.strateIndex!=passage.lowerStrateIndex) + continue; + if(passage.controlPoints.size()<2)continue; + const std::size_t lowerMouthIndex=std::min( + passage.lowerMouthControlIndex,passage.controlPoints.size()-1); + const std::array mouthIndices={0,lowerMouthIndex}; + double passageDistanceSquared=std::numeric_limits::infinity(); + for(const auto mouthIndex:mouthIndices) + { + double distanceSquared=0.0; + for(std::size_t axis=0;axis<3;++axis) + { + const double center=static_cast(qToMicrometres( + passage.controlPoints[mouthIndex].positionSubvoxels[axis])); + const double gap=centerbounds.boundsMaximumMicrometres[axis] + ?center-bounds.boundsMaximumMicrometres[axis]:0.0; + distanceSquared+=gap*gap; + } + passageDistanceSquared=std::min(passageDistanceSquared,distanceSquared); + } + if(passageDistanceSquared<=nearestSquared) + { + if(bounds.mouthJointPassageStableId.empty() + ||passageDistanceSquared=0) + bounds.mouthJointPassageStableId=mouthJointAtProfileDistance( + room.floorProfileDistanceMicrometres); + if(!bounds.mouthJointPassageStableId.empty()) + bounds.mouthJointRadiusMicrometres= + ResolvedPassageMouthJointRadiusMicrometresV1; + associateSpatialMouthJoint(bounds); + primitiveBounds.push_back(std::move(bounds)); + } + const auto mouthJointForAnchor=[](const std::string& anchor) + { + constexpr std::string_view Lower="/lower-mouth"; + constexpr std::string_view Upper="/upper-mouth"; + if(anchor.ends_with(Lower))return anchor.substr(0,anchor.size()-Lower.size()); + if(anchor.ends_with(Upper))return anchor.substr(0,anchor.size()-Upper.size()); + return std::string{}; + }; + for(const auto& edge:boundedGraph.cavernEdges) + { + ResolvedLayoutPrimitiveBoundsV1 bounds{edge.stableId,edge.strateIndex, + edge.boundsMinimumMicrometres,edge.boundsMaximumMicrometres}; + bounds.mouthJointPassageStableId=mouthJointForAnchor(edge.startAnchorStableId); + if(bounds.mouthJointPassageStableId.empty() + &&edge.floorProfileStableId==backboneFloorProfileId + &&edge.floorProfileStartDistanceMicrometres>=0 + &&edge.floorProfileEndDistanceMicrometres>=0) + { + const auto midpoint=(edge.floorProfileStartDistanceMicrometres + +edge.floorProfileEndDistanceMicrometres)/2; + bounds.mouthJointPassageStableId=mouthJointAtProfileDistance(midpoint); + } + if(!bounds.mouthJointPassageStableId.empty()) + bounds.mouthJointRadiusMicrometres= + ResolvedPassageMouthJointRadiusMicrometresV1; + associateSpatialMouthJoint(bounds); + primitiveBounds.push_back(std::move(bounds)); + } + const auto exclusionAudit=AuditResolvedLayoutPassageExclusionV1(layout,primitiveBounds); + std::printf("[CavernPassageExclusionAudit] strate=%d primitives=%llu outside_joint=%llu mouth_joint=%llu negative_controls=%u/%u first_primitive=%s first_passage=%s status=%s\n", + strateIndex,static_cast(exclusionAudit.primitivesChecked), + static_cast(exclusionAudit.overlapsOutsideJoint), + static_cast(exclusionAudit.mouthJointAssociations), + exclusionAudit.negativeControlsRejected,exclusionAudit.negativeControlsRun, + exclusionAudit.firstOverlappingPrimitive.empty()?"none":exclusionAudit.firstOverlappingPrimitive.c_str(), + exclusionAudit.firstOverlappingPassage.empty()?"none":exclusionAudit.firstOverlappingPassage.c_str(), + exclusionAudit.passed?"PASS":"FAIL"); + if(!exclusionAudit.passed) + { + error="cavern planner primitive bounds overlap a passage exclusion envelope outside its mouth joint: "+ + exclusionAudit.firstOverlappingPrimitive+" -> "+exclusionAudit.firstOverlappingPassage; + return false; + } const double plannerMilliseconds=std::chrono::duration( std::chrono::steady_clock::now()-plannerStarted).count(); std::printf("[CavernPlannerV1] strate=%d seed=%d route=%s->%s route_nodes=%zu room_primitives=%zu shafts=%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", diff --git a/Standalone/Source/StreamBenchmarkPipeline.h b/Standalone/Source/StreamBenchmarkPipeline.h index 48b5a30..80677e3 100644 --- a/Standalone/Source/StreamBenchmarkPipeline.h +++ b/Standalone/Source/StreamBenchmarkPipeline.h @@ -4709,6 +4709,19 @@ namespace StandaloneStreamBenchmark const auto& route=allPassageSmoothRoutes[passageIndex]; if(route.size()<2)return audit; const double totalRouteLength=route.back().distance; + const double mouthJointRadiusMetres= + static_cast(VoxelForgeCore:: + ResolvedPassageMouthJointRadiusMicrometresV1)/1000000.0; + const auto insideMouthJoint=[&](double x,double y,double z) + { + const auto inside=[&](const auto& mouth) + { + const double dx=x-mouth.x,dy=y-mouth.y,dz=z-mouth.z; + return dx*dx+dy*dy+dz*dz + <=mouthJointRadiusMetres*mouthJointRadiusMetres; + }; + return inside(route.front())||inside(route.back()); + }; const double bodyFloorDepth=allPassageBodyFloorDepths[passageIndex]; constexpr std::array,5> floorOffsets{{ {{0.0,0.0}},{{0.30,0.0}},{{-0.30,0.0}}, @@ -4859,11 +4872,6 @@ namespace StandaloneStreamBenchmark } const double tangentLength=std::hypot(dx,dy); if(tangentLength<=1.0e-6)continue; - if(std::min(along,totalRouteLength-along)<5.0) - { - audit.wallMouthBlendSkips+=5; - continue; - } const double nx=-dy/tangentLength,ny=dx/tangentLength; const double floorDepth=passageTubeDepthAt(radius,bodyFloorDepth); double floorZ=passageFloorAtXY(passageIndex,centerX,centerY, @@ -4897,6 +4905,11 @@ namespace StandaloneStreamBenchmark } sampleZ=floorZ+probe.height; } + if(insideMouthJoint(sampleX,sampleY,sampleZ)) + { + ++audit.wallMouthBlendSkips; + continue; + } const VoxelForgeCore::Vec3d wallPosition{sampleX/coreMetres, sampleY/coreMetres,sampleZ/coreMetres}; if(passageInteriorWinsAt(sampleX,sampleY,sampleZ))