diff --git a/Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h b/Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h index 82e5af7..ad4171b 100644 --- a/Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h +++ b/Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h @@ -707,6 +707,7 @@ namespace VoxelForgeCore inline constexpr std::uint32_t ResolvedLayoutSchemaVersionV3 = 3; inline constexpr std::uint32_t ResolvedLayoutStampVocabularyVersionV1 = 1; inline constexpr std::uint32_t ResolvedLayoutStampVocabularyVersionV2 = 2; + inline constexpr std::uint32_t ResolvedLayoutCavernGraphVocabularyVersionV1 = 1; enum class ResolvedLayoutValidationModeV3 : std::uint8_t { compatibility, strict }; // Stable, bounded geometry edits composed into every Core field after the shared @@ -750,6 +751,42 @@ namespace VoxelForgeCore std::uint8_t yawQuarterTurns = 0; }; + // Optional room and swept-edge records for a resolved cavern graph. Geometry is stored + // in integer micrometres; bounds include smooth blending, relief, and floor support. + struct ResolvedCavernRoomV1 + { + std::string stableId; + std::int32_t strateIndex = -1; + std::array centerMicrometres{}; + std::array halfExtentsMicrometres{}; + std::int64_t floorMicrometres = 0; + std::int64_t smoothWidthMicrometres = 0; + std::int64_t irregularityAmplitudeMicrometres = 0; + std::int64_t irregularityWavelengthMicrometres = 0; + std::uint64_t irregularitySeed = 0; + std::array boundsMinimumMicrometres{}; + std::array boundsMaximumMicrometres{}; + }; + + struct ResolvedCavernEdgeV1 + { + std::string stableId; + std::string startAnchorStableId; + std::string endRoomStableId; + std::int32_t strateIndex = -1; + // A single cubic Bezier in XY. Its four Z values are the cubic floor profile. + std::array, 4> controlPointsMicrometres{}; + std::int64_t boreRadiusMicrometres = 0; + std::int64_t boreHeightMicrometres = 0; + std::int64_t floorSupportDepthMicrometres = 0; + std::int64_t smoothWidthMicrometres = 0; + std::int64_t irregularityAmplitudeMicrometres = 0; + std::int64_t irregularityWavelengthMicrometres = 0; + std::uint64_t irregularitySeed = 0; + std::array boundsMinimumMicrometres{}; + std::array boundsMaximumMicrometres{}; + }; + struct ResolvedLayoutAnchorV2 { std::string kind; @@ -918,6 +955,10 @@ namespace VoxelForgeCore // Kept absent from canonical layout identity when no feature requests stamps. std::uint32_t stampVocabularyVersion = 0; std::vector stamps; + // Kept absent from canonical identity when there are no graph records. + std::uint32_t cavernGraphVocabularyVersion = 0; + std::vector cavernRooms; + std::vector cavernEdges; std::string stableIdentity; bool finalize(std::string& error); @@ -970,6 +1011,12 @@ namespace VoxelForgeCore VFCORE_API ResolvedLayoutStampPlacementAuditV1 AuditResolvedLayoutStampPlacementV1( const ResolvedLayoutSnapshotV3& layout); + VFCORE_API bool ResolveResolvedCavernGraphV1( + ResolvedLayoutSnapshotV3& layout, std::int32_t seed, + std::span rooms, + std::span edges, + std::string& error); + struct FloatingIslandBoundsAuditResult { std::uint64_t boxesTested = 0; diff --git a/Standalone/Core/src/ResolvedLayoutInternal.h b/Standalone/Core/src/ResolvedLayoutInternal.h index 5b4feb2..bf0ead6 100644 --- a/Standalone/Core/src/ResolvedLayoutInternal.h +++ b/Standalone/Core/src/ResolvedLayoutInternal.h @@ -404,6 +404,56 @@ namespace } out<<']'; } + if(!s.cavernRooms.empty()||!s.cavernEdges.empty()) + { + out<<",\"cavern_graph_vocabulary_version\":" + <b.interiorTopMicrometres:a.order(i); @@ -870,6 +924,110 @@ bool ResolvedLayoutSnapshotV3::validate(ResolvedLayoutValidationModeV3 mode, if(!LayoutStampSafe(*this,stamp,&safetyReason)) add("stamp_placement",stamp.stableId+" is unsafe: "+safetyReason); } + if(cavernRooms.empty()&&cavernEdges.empty()) + { + if(cavernGraphVocabularyVersion!=0) + add("cavern_graph_vocabulary_version", + "cavern graph vocabulary version is set without graph records"); + } + else if(cavernGraphVocabularyVersion!=ResolvedLayoutCavernGraphVocabularyVersionV1) + add("cavern_graph_vocabulary_version", + "cavern graph records require vocabulary version 1"); + if(cavernGraphVocabularyVersion!=0) + { + if(cavernRooms.empty()||cavernEdges.empty()) + add("cavern_graph_shape","a resolved cavern graph needs at least one room and one edge"); + std::vector graphIds; + const auto graphIdValid=[&](const std::string& id) + { + if(id.empty()||std::find(graphIds.begin(),graphIds.end(),id)!=graphIds.end()) + return false; + graphIds.push_back(id);return true; + }; + for(const auto& room:cavernRooms) + { + if(!graphIdValid(room.stableId)) + add("cavern_graph_identity","room has an empty or duplicate stable identity"); + if(!findStratum(room.strateIndex)) + add("cavern_graph_strate",room.stableId+" names an unknown strate"); + bool validExtents=true; + for(const auto extent:room.halfExtentsMicrometres) + validExtents=validExtents&&extent>=500000&&extent<=100000000; + if(!validExtents||room.smoothWidthMicrometres<0 + ||room.smoothWidthMicrometres>10000000 + ||room.irregularityAmplitudeMicrometres<0 + ||room.irregularityAmplitudeMicrometres>10000000 + ||room.irregularityWavelengthMicrometres<0 + ||room.irregularityWavelengthMicrometres>100000000 + ||(room.irregularityAmplitudeMicrometres>0 + &&room.irregularityWavelengthMicrometres<=0)) + add("cavern_room_parameters",room.stableId+" has invalid or unbounded parameters"); + const std::int64_t pad=room.smoothWidthMicrometres + +room.irregularityAmplitudeMicrometres; + for(std::size_t axis=0;axis<3;++axis) + if(room.boundsMinimumMicrometres[axis] + !=room.centerMicrometres[axis]-room.halfExtentsMicrometres[axis]-pad + ||room.boundsMaximumMicrometres[axis] + !=room.centerMicrometres[axis]+room.halfExtentsMicrometres[axis]+pad) + add("cavern_room_bounds",room.stableId+" conservative bounds do not match its parameters"); + if(room.floorMicrometresroom.centerMicrometres[2]+room.halfExtentsMicrometres[2]) + add("cavern_room_floor",room.stableId+" floor lies outside its room envelope"); + } + for(const auto& edge:cavernEdges) + { + if(!graphIdValid(edge.stableId)) + add("cavern_graph_identity","edge has an empty or duplicate stable identity"); + if(edge.startAnchorStableId.empty()||edge.endRoomStableId.empty()) + add("cavern_edge_binding",edge.stableId+" has a missing endpoint identity"); + const auto room=std::find_if(cavernRooms.begin(),cavernRooms.end(), + [&](const auto& value){return value.stableId==edge.endRoomStableId;}); + if(room==cavernRooms.end()||room->strateIndex!=edge.strateIndex) + add("cavern_edge_binding",edge.stableId+" does not terminate at a room in its strate"); + if(!findStratum(edge.strateIndex)) + add("cavern_graph_strate",edge.stableId+" names an unknown strate"); + if(edge.boreRadiusMicrometres<500000||edge.boreRadiusMicrometres>10000000 + ||edge.boreHeightMicrometres<1000000||edge.boreHeightMicrometres>20000000 + ||edge.floorSupportDepthMicrometres<0 + ||edge.floorSupportDepthMicrometres>2000000 + ||edge.smoothWidthMicrometres<0||edge.smoothWidthMicrometres>10000000 + ||edge.irregularityAmplitudeMicrometres<0 + ||edge.irregularityAmplitudeMicrometres>10000000 + ||edge.irregularityWavelengthMicrometres<0 + ||edge.irregularityWavelengthMicrometres>100000000 + ||(edge.irregularityAmplitudeMicrometres>0 + &&edge.irregularityWavelengthMicrometres<=0)) + add("cavern_edge_parameters",edge.stableId+" has invalid or unbounded parameters"); + std::array low=edge.controlPointsMicrometres[0]; + std::array high=low; + for(const auto& point:edge.controlPointsMicrometres) + for(std::size_t axis=0;axis<3;++axis) + {low[axis]=std::min(low[axis],point[axis]);high[axis]=std::max(high[axis],point[axis]);} + const std::int64_t xyPad=edge.boreRadiusMicrometres + +edge.smoothWidthMicrometres+edge.irregularityAmplitudeMicrometres; + const std::int64_t zPad=edge.smoothWidthMicrometres + +edge.irregularityAmplitudeMicrometres; + for(std::size_t axis=0;axis<3;++axis) + { + const std::int64_t pad=axis==2?zPad:xyPad; + const std::int64_t lowSupport=axis==2?edge.floorSupportDepthMicrometres:0; + const std::int64_t highBore=axis==2?edge.boreHeightMicrometres:0; + if(edge.boundsMinimumMicrometres[axis]!=low[axis]-pad-lowSupport + ||edge.boundsMaximumMicrometres[axis]!=high[axis]+pad+highBore) + add("cavern_edge_bounds",edge.stableId+" conservative bounds do not match its parameters"); + } + if(room!=cavernRooms.end()) + { + const auto& endpoint=edge.controlPointsMicrometres.back(); + if(endpoint[0]centerMicrometres[0]-room->halfExtentsMicrometres[0] + ||endpoint[0]>room->centerMicrometres[0]+room->halfExtentsMicrometres[0] + ||endpoint[1]centerMicrometres[1]-room->halfExtentsMicrometres[1] + ||endpoint[1]>room->centerMicrometres[1]+room->halfExtentsMicrometres[1] + ||std::abs(endpoint[2]-room->floorMicrometres)>500000) + add("cavern_edge_portal",edge.stableId+" endpoint does not meet its room floor portal"); + } + } + } if(strict&&!issues.empty()){error="strict resolved-layout validation rejected "+issues.front().code;return false;} return true; } @@ -886,6 +1044,8 @@ std::optional ResolvedLayoutSnapshotV3::FromJson( s.strateLayoutChunkSizeMicrometres=static_cast(LayoutInt(root,"strate_layout_chunk_size_um")); s.strateLayoutChunkWidthSubvoxels=LayoutInt(root,"strate_layout_chunk_width_subvoxels"); s.stampVocabularyVersion=static_cast(LayoutInt(root,"stamp_vocabulary_version")); + s.cavernGraphVocabularyVersion=static_cast( + LayoutInt(root,"cavern_graph_vocabulary_version")); for(const auto& v:root.Get("strates").AsArray()) { ResolvedLayoutStratumV2 x;x.stableId=LayoutString(v,"stable_id"); @@ -955,6 +1115,57 @@ std::optional ResolvedLayoutSnapshotV3::FromJson( stamp.yawQuarterTurns=static_cast(LayoutInt(v,"yaw_quarter_turns")); s.stamps.push_back(std::move(stamp)); } + for(const auto& v:root.Get("cavern_rooms").AsArray()) + { + ResolvedCavernRoomV1 room;room.stableId=LayoutString(v,"stable_id"); + room.strateIndex=static_cast(LayoutInt(v,"strate_index")); + const auto readTriplet=[&](std::string_view key,std::array& target) + { + const auto& values=v.Get(key).AsArray(); + for(std::size_t axis=0;axis<3&&axis(LayoutInt(v,"irregularity_seed")); + s.cavernRooms.push_back(std::move(room)); + } + for(const auto& v:root.Get("cavern_edges").AsArray()) + { + ResolvedCavernEdgeV1 edge;edge.stableId=LayoutString(v,"stable_id"); + edge.startAnchorStableId=LayoutString(v,"start_anchor_stable_id"); + edge.endRoomStableId=LayoutString(v,"end_room_stable_id"); + edge.strateIndex=static_cast(LayoutInt(v,"strate_index")); + const auto& points=v.Get("control_points_um").AsArray(); + for(std::size_t i=0;i& target) + { + const auto& values=v.Get(key).AsArray(); + for(std::size_t axis=0;axis<3&&axis(LayoutInt(v,"irregularity_seed")); + s.cavernEdges.push_back(std::move(edge)); + } const std::string serializedIdentity=LayoutString(root,"stable_identity"); if(!s.finalize(error))return std::nullopt; if(s.stableIdentity!=serializedIdentity){error="resolved layout stable identity does not match its canonical payload";return std::nullopt;} @@ -1080,6 +1291,67 @@ bool ResolveResolvedLayoutStampsV2(ResolvedLayoutSnapshotV3& layout,std::int32_t layout=std::move(candidate);return true; } +bool ResolveResolvedCavernGraphV1(ResolvedLayoutSnapshotV3& layout,std::int32_t seed, + std::span rooms, + std::span edges,std::string& error) +{ + error.clear(); + ResolvedLayoutSnapshotV3 candidate=layout; + candidate.cavernRooms.clear();candidate.cavernEdges.clear(); + if(rooms.empty()&&edges.empty()) + { + candidate.cavernGraphVocabularyVersion=0; + if(!candidate.finalize(error))return false; + layout=std::move(candidate);return true; + } + candidate.cavernGraphVocabularyVersion=ResolvedLayoutCavernGraphVocabularyVersionV1; + candidate.cavernRooms.assign(rooms.begin(),rooms.end()); + candidate.cavernEdges.assign(edges.begin(),edges.end()); + for(auto& room:candidate.cavernRooms) + { + // The shared JSON value type stores numbers as IEEE-754 doubles. Keep the + // serialized seed in the exact integer range, as the stamp vocabulary does. + room.irregularitySeed=LayoutStampSeed(seed,room.strateIndex,room.stableId) + &0x001fffffffffffffull; + const std::int64_t pad=room.smoothWidthMicrometres + +room.irregularityAmplitudeMicrometres; + for(std::size_t axis=0;axis<3;++axis) + { + room.boundsMinimumMicrometres[axis]=room.centerMicrometres[axis] + -room.halfExtentsMicrometres[axis]-pad; + room.boundsMaximumMicrometres[axis]=room.centerMicrometres[axis] + +room.halfExtentsMicrometres[axis]+pad; + } + } + for(auto& edge:candidate.cavernEdges) + { + edge.irregularitySeed=LayoutStampSeed(seed,edge.strateIndex,edge.stableId) + &0x001fffffffffffffull; + std::array low=edge.controlPointsMicrometres[0]; + std::array high=low; + for(const auto& point:edge.controlPointsMicrometres) + for(std::size_t axis=0;axis<3;++axis) + {low[axis]=std::min(low[axis],point[axis]);high[axis]=std::max(high[axis],point[axis]);} + const std::int64_t xyPad=edge.boreRadiusMicrometres + +edge.smoothWidthMicrometres+edge.irregularityAmplitudeMicrometres; + const std::int64_t zPad=edge.smoothWidthMicrometres + +edge.irregularityAmplitudeMicrometres; + for(std::size_t axis=0;axis<3;++axis) + { + const std::int64_t pad=axis==2?zPad:xyPad; + edge.boundsMinimumMicrometres[axis]=low[axis]-pad + -(axis==2?edge.floorSupportDepthMicrometres:0); + edge.boundsMaximumMicrometres[axis]=high[axis]+pad + +(axis==2?edge.boreHeightMicrometres:0); + } + } + if(!candidate.finalize(error))return false; + std::vector issues;std::string strictError; + if(!candidate.validate(ResolvedLayoutValidationModeV3::strict,issues,strictError)) + {error="strict cavern graph validation failed: "+strictError;return false;} + layout=std::move(candidate);return true; +} + ResolvedLayoutStampPlacementAuditV1 AuditResolvedLayoutStampPlacementV1( const ResolvedLayoutSnapshotV3& layout) { diff --git a/Standalone/Core/src/ShapeWorldInternal.h b/Standalone/Core/src/ShapeWorldInternal.h index 462c990..60a4ba3 100644 --- a/Standalone/Core/src/ShapeWorldInternal.h +++ b/Standalone/Core/src/ShapeWorldInternal.h @@ -169,6 +169,8 @@ public: featureIndices_=shape.BuildPassageFeatureTileList( minimumSubvoxels_,maximumSubvoxels_); stampIndices_=shape.BuildStampTileList(minimumSubvoxels_,maximumSubvoxels_); + cavernFeatureIndices_=shape.BuildCavernFeatureTileList( + minimumSubvoxels_,maximumSubvoxels_); passageRoutes_.assign(shape.ResolvedLayout?shape.ResolvedLayout->passages.size():0,0); passageColumns_=passageRoutes_; passageMouthSupports_=passageRoutes_; @@ -219,12 +221,16 @@ public: std::size_t featureCount() const{return featureIndices_.size();} bool hasStampCandidates() const{return !stampIndices_.empty();} const std::vector& stampCandidates() const{return stampIndices_;} + bool hasCavernCandidates() const{return !cavernFeatureIndices_.empty();} + const std::vector& cavernFeatureCandidates() const + {return cavernFeatureIndices_;} private: const FStandaloneShapeWorld* shape_; std::array minimumSubvoxels_,maximumSubvoxels_; std::vector featureIndices_; std::vector stampIndices_; + std::vector cavernFeatureIndices_; std::vector passageRoutes_,passageColumns_,passageMouthSupports_; bool routeFeatureOverlap_=false,columnZOverlap_=false; bool mayContainResolvedBoundaryBand_=true; @@ -1780,9 +1786,13 @@ public: float ApplyResolvedStrateComposition(float density,float X,float Y,float Z) const { + const float graphDensity=ApplyResolvedCavernGraph(density,X,Y,Z); const float composed=ApplyResolvedStratePassageCarves( - ApplyResolvedStrateBoundaryBand(density,X,Y,Z),X,Y,Z); - return StampRuntime.empty()?composed:ApplyResolvedSdfStamps(composed,X,Y,Z); + ApplyResolvedStrateBoundaryBand(graphDensity,X,Y,Z),X,Y,Z); + const float stamped=StampRuntime.empty()?composed:ApplyResolvedSdfStamps(composed,X,Y,Z); + // Keep the explicit graph floor cap after smooth unions, relief, passages, + // and decorative stamps so nothing can reopen the tunnel floor below profile. + return ApplyResolvedCavernFloorCaps(stamped,X,Y,Z); } bool ResolvedPassageIntersectsBox(const int32 minimum[3],const int32 maximum[3]) const @@ -3198,6 +3208,21 @@ private: std::uint64_t irregularitySeed=0; std::uint8_t quarterTurns=0; }; + struct FCavernRuntimeFeature + { + const VoxelForgeCore::ResolvedCavernRoomV1* room=nullptr; + const VoxelForgeCore::ResolvedCavernEdgeV1* edge=nullptr; + std::array boundsMinimumMicrometres{},boundsMaximumMicrometres{}; + std::array centerVoxels{},halfExtentsVoxels{}; + std::array,4> controlPointsVoxels{}; + float floorVoxels=0.0f; + float boreRadiusVoxels=0.0f,boreHeightVoxels=0.0f; + float floorSupportDepthVoxels=0.0f; + float smoothWidthVoxels=0.0f; + float irregularityAmplitudeVoxels=0.0f; + float irregularityWavelengthVoxels=0.0f; + std::uint64_t irregularitySeed=0; + }; int BuildStampSpatialNode(std::size_t first,std::size_t end) { @@ -3346,6 +3371,104 @@ private: return result; } + void BuildCavernSpatialIndex() + { + CavernRuntime.clear(); + if(!ResolvedLayout||!Owner||Owner->CoreVoxelSizeMicrons==0 + ||ResolvedLayout->cavernGraphVocabularyVersion + !=VoxelForgeCore::ResolvedLayoutCavernGraphVocabularyVersionV1)return; + const float micronsPerVoxel=static_cast(Owner->CoreVoxelSizeMicrons); + for(const auto& room:ResolvedLayout->cavernRooms) + { + FCavernRuntimeFeature runtime;runtime.room=&room; + runtime.boundsMinimumMicrometres=room.boundsMinimumMicrometres; + runtime.boundsMaximumMicrometres=room.boundsMaximumMicrometres; + for(std::size_t axis=0;axis<3;++axis) + { + runtime.centerVoxels[axis]=static_cast( + static_cast(room.centerMicrometres[axis])/micronsPerVoxel); + runtime.halfExtentsVoxels[axis]=static_cast( + static_cast(room.halfExtentsMicrometres[axis])/micronsPerVoxel); + } + runtime.floorVoxels=static_cast( + static_cast(room.floorMicrometres)/micronsPerVoxel); + runtime.smoothWidthVoxels=static_cast( + static_cast(room.smoothWidthMicrometres)/micronsPerVoxel); + runtime.irregularityAmplitudeVoxels=static_cast( + static_cast(room.irregularityAmplitudeMicrometres)/micronsPerVoxel); + runtime.irregularityWavelengthVoxels=static_cast( + static_cast(room.irregularityWavelengthMicrometres)/micronsPerVoxel); + runtime.irregularitySeed=room.irregularitySeed; + CavernRuntime.push_back(runtime); + } + for(const auto& edge:ResolvedLayout->cavernEdges) + { + FCavernRuntimeFeature runtime;runtime.edge=&edge; + runtime.boundsMinimumMicrometres=edge.boundsMinimumMicrometres; + runtime.boundsMaximumMicrometres=edge.boundsMaximumMicrometres; + for(std::size_t point=0;point<4;++point) + for(std::size_t axis=0;axis<3;++axis) + runtime.controlPointsVoxels[point][axis]=static_cast( + static_cast(edge.controlPointsMicrometres[point][axis]) + /micronsPerVoxel); + runtime.boreRadiusVoxels=static_cast( + static_cast(edge.boreRadiusMicrometres)/micronsPerVoxel); + runtime.boreHeightVoxels=static_cast( + static_cast(edge.boreHeightMicrometres)/micronsPerVoxel); + runtime.floorSupportDepthVoxels=static_cast( + static_cast(edge.floorSupportDepthMicrometres)/micronsPerVoxel); + runtime.smoothWidthVoxels=static_cast( + static_cast(edge.smoothWidthMicrometres)/micronsPerVoxel); + runtime.irregularityAmplitudeVoxels=static_cast( + static_cast(edge.irregularityAmplitudeMicrometres)/micronsPerVoxel); + runtime.irregularityWavelengthVoxels=static_cast( + static_cast(edge.irregularityWavelengthMicrometres)/micronsPerVoxel); + runtime.irregularitySeed=edge.irregularitySeed; + CavernRuntime.push_back(runtime); + } + } + + std::vector BuildCavernFeatureTileList( + const std::array& minimumSubvoxels, + const std::array& maximumSubvoxels) const + { + std::vector result; + if(CavernRuntime.empty())return result; + if(!Owner||Owner->CoreVoxelSizeMicrons==0) + { + result.resize(CavernRuntime.size()); + for(std::size_t i=0;i(i); + return result; + } + constexpr long double Q=VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel; + std::array minimumMicrometres{},maximumMicrometres{}; + for(std::size_t axis=0;axis<3;++axis) + { + if(minimumSubvoxels[axis]>maximumSubvoxels[axis]) + { + result.resize(CavernRuntime.size()); + for(std::size_t i=0;i(i); + return result; + } + minimumMicrometres[axis]=static_cast(minimumSubvoxels[axis]) + *Owner->CoreVoxelSizeMicrons/Q; + maximumMicrometres[axis]=static_cast(maximumSubvoxels[axis]) + *Owner->CoreVoxelSizeMicrons/Q; + } + for(std::size_t index=0;index=minimumMicrometres[axis] + &&feature.boundsMinimumMicrometres[axis] + <=maximumMicrometres[axis]; + if(overlap)result.push_back(static_cast(index)); + } + return result; + } + int BuildPassageNearestNode(std::size_t passageIndex,std::vector segments) { const auto& points=ResolvedLayout->passages[passageIndex].controlPoints; @@ -3519,6 +3642,7 @@ private: void BuildPassageSpatialIndices() { BuildStampSpatialIndex(); + BuildCavernSpatialIndex(); PassageTileFeatures.clear();PassageFeatureBounds.clear(); PassageFeatureOrder.clear();PassageFeatureNodes.clear(); PassageFeatureBoundsFailOpen=false; @@ -4459,6 +4583,19 @@ public: static_cast(BMax)*Q}; if(!BuildStampTileList(minimum,maximum).empty())return false; } + if(!CavernRuntime.empty()) + { + constexpr std::int64_t Q=VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel; + const std::array minimum={ + static_cast(Origin.X-Pad)*Q, + static_cast(Origin.Y-Pad)*Q, + static_cast(BMin)*Q}; + const std::array maximum={ + static_cast(Origin.X+(Cells+1)*Step)*Q, + static_cast(Origin.Y+(Cells+1)*Step)*Q, + static_cast(BMax)*Q}; + if(!BuildCavernFeatureTileList(minimum,maximum).empty())return false; + } const float LayoutWidth=static_cast(GetResolvedLayoutChunkWidthVoxels()); const int32 FirstChunk=FMath::FloorToInt(static_cast(BMin)/LayoutWidth); const int32 LastChunk=FMath::FloorToInt(static_cast(BMax)/LayoutWidth); @@ -4563,6 +4700,20 @@ public: if(!BuildStampTileList(minimum,maximum).empty()) return Finish(EVoxelTileClass::Mixed,true); } + if(!CavernRuntime.empty()) + { + constexpr std::int64_t Q=VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel; + const std::array minimum={ + static_cast(BMin[0])*Q, + static_cast(BMin[1])*Q, + static_cast(BMin[2])*Q}; + const std::array maximum={ + static_cast(BMax[0])*Q, + static_cast(BMax[1])*Q, + static_cast(BMax[2])*Q}; + if(!BuildCavernFeatureTileList(minimum,maximum).empty()) + return Finish(EVoxelTileClass::Mixed,true); + } if(ResolvedPassageIntersectsBox(BMin,BMax)) return Finish(EVoxelTileClass::Mixed,true); if(!EditBrushes.empty()&&EditBoundsMin.X<=BMax[0]&&EditBoundsMax.X>=BMin[0] @@ -5727,6 +5878,252 @@ private: apply(static_cast(index)); return composed; } + float ApplyResolvedCavernGraph(float density,float X,float Y,float Z) const + { + if(CavernRuntime.empty()||!Owner||Owner->CoreVoxelSizeMicrons==0)return density; + const auto* tileQueries=ActivePassageTileQueries; + const bool scoped=tileQueries&&tileQueries->matches(*this); + if(scoped&&!tileQueries->hasCavernCandidates())return density; + const auto* bound=FindBoundStratumForManagerZ(Z); + if(!bound)return density; + const double micronsPerVoxel=Owner->CoreVoxelSizeMicrons; + const std::array worldMicrometres={X*micronsPerVoxel, + Y*micronsPerVoxel,Z*micronsPerVoxel}; + float composed=density; + const auto apply=[&](std::uint32_t index) + { + if(index>=CavernRuntime.size())return; + const auto& feature=CavernRuntime[index]; + const std::int32_t strateIndex=feature.room?feature.room->strateIndex + :feature.edge?feature.edge->strateIndex:-1; + if(strateIndex!=bound->StrateIndex)return; + for(std::size_t axis=0;axis<3;++axis) + if(worldMicrometres[axis]feature.boundsMaximumMicrometres[axis])return; + if(feature.room) + { + const float localX=X-feature.centerVoxels[0]; + const float localY=Y-feature.centerVoxels[1]; + const float localZ=Z-feature.centerVoxels[2]; + if(Z0.0f) + { + const float fade=SmoothUnit((Z-feature.floorVoxels)/0.8f); + distance+=StampValueNoise(localX,localY,localZ, + feature.irregularityWavelengthVoxels,feature.irregularitySeed) + *feature.irregularityAmplitudeVoxels*fade; + } + composed=SmoothMaxDistance(composed,-distance,feature.smoothWidthVoxels); + const float horizontal=std::sqrt( + (localX*localX)/(feature.halfExtentsVoxels[0] + *feature.halfExtentsVoxels[0]) + +(localY*localY)/(feature.halfExtentsVoxels[1] + *feature.halfExtentsVoxels[1])); + if(horizontal<=1.0f) + composed=std::min(composed,Z-feature.floorVoxels); + return; + } + if(!feature.edge)return; + const auto bezier=[&](float t,std::size_t axis) + { + const float one=1.0f-t; + return one*one*one*feature.controlPointsVoxels[0][axis] + +3.0f*one*one*t*feature.controlPointsVoxels[1][axis] + +3.0f*one*t*t*feature.controlPointsVoxels[2][axis] + +t*t*t*feature.controlPointsVoxels[3][axis]; + }; + const auto derivative=[&](float t,std::size_t axis) + { + const float one=1.0f-t; + return 3.0f*one*one*(feature.controlPointsVoxels[1][axis] + -feature.controlPointsVoxels[0][axis]) + +6.0f*one*t*(feature.controlPointsVoxels[2][axis] + -feature.controlPointsVoxels[1][axis]) + +3.0f*t*t*(feature.controlPointsVoxels[3][axis] + -feature.controlPointsVoxels[2][axis]); + }; + const auto secondDerivative=[&](float t,std::size_t axis) + { + return 6.0f*(1.0f-t)*(feature.controlPointsVoxels[2][axis] + -2.0f*feature.controlPointsVoxels[1][axis] + +feature.controlPointsVoxels[0][axis]) + +6.0f*t*(feature.controlPointsVoxels[3][axis] + -2.0f*feature.controlPointsVoxels[2][axis] + +feature.controlPointsVoxels[1][axis]); + }; + float t=0.0f,bestDistanceSquared=std::numeric_limits::infinity(); + for(int sample=0;sample<=16;++sample) + { + const float candidate=static_cast(sample)/16.0f; + const float dx=X-bezier(candidate,0),dy=Y-bezier(candidate,1); + const float distanceSquared=dx*dx+dy*dy; + if(distanceSquared0.0f) + { + const float fade=SmoothUnit((Z-floorZ)/0.8f); + distance+=StampValueNoise(X-feature.controlPointsVoxels[0][0], + Y-feature.controlPointsVoxels[0][1],Z-floorZ, + feature.irregularityWavelengthVoxels,feature.irregularitySeed) + *feature.irregularityAmplitudeVoxels*fade; + } + if(Z0.0f&&feature.floorSupportDepthVoxels>0.0f + &&Z>=floorZ-feature.floorSupportDepthVoxels + &&radialcavernFeatureCandidates())apply(index); + else + for(std::size_t index=0;index(index)); + return composed; + } + + float ApplyResolvedCavernFloorCaps(float density,float X,float Y,float Z) const + { + if(CavernRuntime.empty()||!Owner||Owner->CoreVoxelSizeMicrons==0)return density; + const auto* tileQueries=ActivePassageTileQueries; + const bool scoped=tileQueries&&tileQueries->matches(*this); + if(scoped&&!tileQueries->hasCavernCandidates())return density; + const auto* bound=FindBoundStratumForManagerZ(Z); + if(!bound)return density; + const double micronsPerVoxel=Owner->CoreVoxelSizeMicrons; + const std::array worldMicrometres={X*micronsPerVoxel, + Y*micronsPerVoxel,Z*micronsPerVoxel}; + float capped=density; + const auto apply=[&](std::uint32_t index) + { + if(index>=CavernRuntime.size())return; + const auto& feature=CavernRuntime[index]; + const std::int32_t strateIndex=feature.room?feature.room->strateIndex + :feature.edge?feature.edge->strateIndex:-1; + if(strateIndex!=bound->StrateIndex)return; + for(std::size_t axis=0;axis<3;++axis) + if(worldMicrometres[axis]feature.boundsMaximumMicrometres[axis])return; + if(feature.room) + { + if(Z::infinity(); + for(int sample=0;sample<=16;++sample) + { + const float candidate=static_cast(sample)/16.0f; + const float dx=X-bezier(candidate,0),dy=Y-bezier(candidate,1); + const float distanceSquared=dx*dx+dy*dy; + if(distanceSquared0.0f&&feature.floorSupportDepthVoxels>0.0f + &&Z>=floorZ-feature.floorSupportDepthVoxels + &&radialcavernFeatureCandidates())apply(index); + else + for(std::size_t index=0;index(index)); + return capped; + } static float SmoothUnit(float V) { V=std::clamp(V,0.0f,1.0f); @@ -8977,6 +9374,7 @@ private: std::vector StampSpatialNodes; std::vector StampSpatialOrder; std::vector StampRuntime; + std::vector CavernRuntime; std::vector PassageFeatureBounds; std::vector PassageTileFeatures; std::vector PassageFeatureOrder; diff --git a/Standalone/Core/src/VoxelForgeCore.cpp b/Standalone/Core/src/VoxelForgeCore.cpp index 4f2fa9b..43a6af5 100644 --- a/Standalone/Core/src/VoxelForgeCore.cpp +++ b/Standalone/Core/src/VoxelForgeCore.cpp @@ -1334,6 +1334,34 @@ namespace VoxelForgeCore } return false; } + + bool ResolvedCavernGraphTouchesBox(const ResolvedLayoutSnapshotV3& layout, + const FIntVector& minimum,const FIntVector& maximum, + std::uint32_t coreVoxelSizeMicrons) + { + if((layout.cavernRooms.empty()&&layout.cavernEdges.empty()) + ||coreVoxelSizeMicrons==0)return false; + const double tileMinimum[3]={static_cast(minimum.X), + static_cast(minimum.Y),static_cast(minimum.Z)}; + const double tileMaximum[3]={static_cast(maximum.X), + static_cast(maximum.Y),static_cast(maximum.Z)}; + const auto overlaps=[&](const auto& feature) + { + bool overlap=true; + for(std::size_t axis=0;axis<3;++axis) + { + const double low=static_cast(feature.boundsMinimumMicrometres[axis]) + /coreVoxelSizeMicrons; + const double high=static_cast(feature.boundsMaximumMicrometres[axis]) + /coreVoxelSizeMicrons; + overlap=overlap&&high>=tileMinimum[axis]&&low<=tileMaximum[axis]; + } + return overlap; + }; + for(const auto& room:layout.cavernRooms)if(overlaps(room))return true; + for(const auto& edge:layout.cavernEdges)if(overlaps(edge))return true; + return false; + } } bool World::compile(const ShapeBuildRequest& request,const JobScheduler& scheduler,std::string& error) @@ -1418,6 +1446,8 @@ namespace VoxelForgeCore if(RouteTouchesBox(*selectedPlan,subMinimum,subMaximum))return false; if(ResolvedStampTouchesBox(*impl_->resolvedLayout,boundsMin,boundsMax, impl_->owner->CoreVoxelSizeMicrons))return false; + if(ResolvedCavernGraphTouchesBox(*impl_->resolvedLayout,boundsMin,boundsMax, + impl_->owner->CoreVoxelSizeMicrons))return false; if(ResolvedPassageTouchesBox(*impl_->resolvedLayout,boundsMin,boundsMax, impl_->owner->CoreVoxelSizeMicrons))return false; if(OwnerPassageTouchesBox(*impl_->owner,boundsMin,boundsMax))return false; diff --git a/Standalone/Research/WorldAssemblerM3/Linux46/CaptureLandmarkL46.py b/Standalone/Research/WorldAssemblerM3/Linux46/CaptureLandmarkL46.py new file mode 100644 index 0000000..3235e73 --- /dev/null +++ b/Standalone/Research/WorldAssemblerM3/Linux46/CaptureLandmarkL46.py @@ -0,0 +1,384 @@ +#!/usr/bin/env python3 +"""Capture the L46 cavern graph views and audit player-scale built-shape limits.""" +from __future__ import annotations + +import argparse +import importlib.util +import json +import math +import os +from pathlib import Path +import subprocess +import sys +import time + + +ROOT = Path(__file__).resolve().parents[4] +VERIFY = ROOT / "Standalone/Tools/verify" +LINUX37 = ROOT / "Standalone/Research/WorldAssemblerM3/Linux37" +sys.path.insert(0, str(VERIFY)) +from runner import bounded_run, current_stamp, first_stamp + + +def load_module(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot import {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +PROFILE = load_module("l45_ring_profile", ROOT / + "Standalone/Research/WorldAssemblerM3/Linux32/AuditRingTerraceL32.py") +SHAPE = load_module("l45_shape_audit", LINUX37 / "AuditMouthShapeL37.py") + +PARSER = argparse.ArgumentParser(description=__doc__) +PARSER.add_argument("--layout", type=Path, required=True) +PARSER.add_argument("--artifact-dir", type=Path, required=True) +PARSER.add_argument("--capture-dir", type=Path, required=True) +PARSER.add_argument("--data", type=Path, default=ROOT / "Standalone/data/owner_world.json") +OPTIONS = PARSER.parse_args() + +ARTIFACT_DIR = OPTIONS.artifact_dir.resolve() +CAPTURE_DIR = OPTIONS.capture_dir.resolve() +LAYOUT_PATH = OPTIONS.layout.resolve() +ARTIFACT_DIR.mkdir(parents=True, exist_ok=True) +CAPTURE_DIR.mkdir(parents=True, exist_ok=True) +PNG_DIR = ARTIFACT_DIR / "capture-png" +PNG_DIR.mkdir(parents=True, exist_ok=True) +HEADLESS = Path(os.environ["VOXELFORGE_L37_HEADLESS"]) +RENDERER = Path(os.environ["VOXELFORGE_L37_RENDERER"]) +EXPECTED_STAMP = current_stamp() + + +def run_logged(name: str, command: list[str], *, capture: bool = False) -> str: + log = ARTIFACT_DIR / f"{name}.log" + started = time.monotonic() + with log.open("w", encoding="utf-8") as stream: + result = bounded_run(command, timeout=1800, stdout=stream) + elapsed = time.monotonic() - started + output = log.read_text(encoding="utf-8", errors="replace") + if result is None: + raise RuntimeError(f"{name} timed out; see {log}") + print(f"[L45CaptureRun] step={name} elapsed={elapsed:.1f}s " + f"exit={result.returncode} log={log}", flush=True) + if result.returncode != 0: + raise RuntimeError(f"{name} failed; see {log}") + if capture and (first_stamp(output) != EXPECTED_STAMP + or first_stamp(bounded_run( + [str(command[0]), "--print-source-stamp"], timeout=15).stdout or "") + != EXPECTED_STAMP): + raise RuntimeError(f"{name} executable source stamp mismatch") + return output + + +def stamp_by_id(layout: dict, stable_id: str) -> dict: + for stamp in layout.get("stamps", []): + if stamp.get("stable_id") == stable_id: + return stamp + raise RuntimeError(f"missing stamp {stable_id}") + + +def metres(stamp: dict, key: str = "position_um") -> tuple[float, float, float]: + return tuple(value / 1_000_000.0 for value in stamp[key]) + + +def camera_angles(camera, target): + dx, dy, dz = (target[index] - camera[index] for index in range(3)) + return (math.degrees(math.atan2(dx, dy)), + math.degrees(math.atan2(dz, math.hypot(dx, dy)))) + + +def fmt(values) -> str: + return ",".join(f"{value:.6f}" for value in values) + + +def camera_spec(camera, target): + yaw, pitch = camera_angles(camera, target) + return (*camera, yaw, pitch) + + +def edge_point(edge: dict, t: float) -> tuple[float, float, float]: + points = edge["control_points_um"] + one = 1.0 - t + weights = (one ** 3, 3 * one ** 2 * t, 3 * one * t ** 2, t ** 3) + return tuple(sum(weights[index] * points[index][axis] + for index in range(4)) / 1_000_000.0 + for axis in range(3)) + + +def cavern_features(layout: dict): + if (layout.get("cavern_graph_vocabulary_version") != 1 + or len(layout.get("cavern_rooms", [])) != 1 + or len(layout.get("cavern_edges", [])) != 1): + raise RuntimeError("L46 capture needs one resolved cavern room and edge") + room = layout["cavern_rooms"][0] + edge = layout["cavern_edges"][0] + return room, edge + + +layout = json.loads(LAYOUT_PATH.read_text(encoding="utf-8")) +room, edge = cavern_features(layout) +portal_stamp = stamp_by_id(layout, "landmark/sw-arrival-chamber-mouth-approach-v1") +floor_stamp = stamp_by_id(layout, "landmark/sw-arrival-chamber-v1-floor") +dais_stamp = stamp_by_id(layout, "landmark/sw-arrival-chamber-v1-dais") +entrance = edge_point(edge, 0.0) +next_ramp = edge_point(edge, 0.08) +portal = metres(portal_stamp) +chamber = metres(room, "center_um") +floor = metres(floor_stamp) +floor_top = floor[2] + floor_stamp["half_extents_um"][2] / 1_000_000.0 +dais = metres(dais_stamp) +dais_top = dais[2] + dais_stamp["half_extents_um"][2] / 1_000_000.0 +ground_z = entrance[2] +direction_length = math.hypot(next_ramp[0] - entrance[0], next_ramp[1] - entrance[1]) +direction = ((next_ramp[0] - entrance[0]) / direction_length, + (next_ramp[1] - entrance[1]) / direction_length) +outward_length = math.hypot(entrance[0] - chamber[0], entrance[1] - chamber[1]) +outward = ((entrance[0] - chamber[0]) / outward_length, + (entrance[1] - chamber[1]) / outward_length) +mouth_normal = (-direction[1], direction[0]) + +mesh = ARTIFACT_DIR / "landmark-l46.vfmesh" +export_camera = ((entrance[0] + chamber[0]) * 0.5, + (entrance[1] + chamber[1]) * 0.5, + (ground_z + floor_top) * 0.5) +export_target = (chamber[0], chamber[1], floor_top) +run_logged("landmark-export", [str(HEADLESS), str(OPTIONS.data), "--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", "--recipe=surface-world", "--landmark-chamber-demo", + f"--camera={fmt(camera_spec(export_camera, export_target))}", + f"--mesh-export={mesh}", f"--resolved-layout-output={LAYOUT_PATH}"], capture=True) +layout = json.loads(LAYOUT_PATH.read_text(encoding="utf-8")) +room, edge = cavern_features(layout) +portal_stamp = stamp_by_id(layout, "landmark/sw-arrival-chamber-mouth-approach-v1") +floor_stamp = stamp_by_id(layout, "landmark/sw-arrival-chamber-v1-floor") +dais_stamp = stamp_by_id(layout, "landmark/sw-arrival-chamber-v1-dais") +entrance = edge_point(edge, 0.0) +next_ramp = edge_point(edge, 0.08) +portal = metres(portal_stamp) +chamber = metres(room, "center_um") +floor = metres(floor_stamp) +floor_top = floor[2] + floor_stamp["half_extents_um"][2] / 1_000_000.0 +dais = metres(dais_stamp) +dais_top = dais[2] + dais_stamp["half_extents_um"][2] / 1_000_000.0 +ground_z = entrance[2] +direction_length = math.hypot(next_ramp[0] - entrance[0], next_ramp[1] - entrance[1]) +direction = ((next_ramp[0] - entrance[0]) / direction_length, + (next_ramp[1] - entrance[1]) / direction_length) +outward_length = math.hypot(entrance[0] - chamber[0], entrance[1] - chamber[1]) +outward = ((entrance[0] - chamber[0]) / outward_length, + (entrance[1] - chamber[1]) / outward_length) +if not mesh.is_file() or mesh.stat().st_size == 0: + raise RuntimeError(f"missing mesh export {mesh}") + +voxel_um, triangles, tile_count = PROFILE.read_scene(str(mesh), 0, + floor_top - 8.0, entrance[2] + 9.0, + (min(entrance[0], chamber[0]) - 20.0, max(entrance[0], chamber[0]) + 20.0, + min(entrance[1], chamber[1]) - 20.0, max(entrance[1], chamber[1]) + 20.0)) +bounds = (min(entrance[0], chamber[0]) - 20.0, max(entrance[0], chamber[0]) + 20.0, + min(entrance[1], chamber[1]) - 20.0, max(entrance[1], chamber[1]) + 20.0) +buckets = PROFILE.build_buckets(triangles, True, bounds, cell_size=1.0) +ground_candidates = [] +for ix in range(-20, 21): + for iy in range(-20, 21): + x, y = portal[0] + ix * 0.5, portal[1] + iy * 0.5 + dx, dy = x - portal[0], y - portal[1] + distance = math.hypot(dx, dy) + if not 6.0 <= distance <= 10.0: + continue + hit = PROFILE.sample_surface(buckets, x, y, True, 1.0, + preferred_height=ground_z) + if hit is None or abs(hit[0] - ground_z) > 3.0 or hit[1][2] < 0.85: + continue + alignment = max(-1.0, min(1.0, + (dx * mouth_normal[0] + dy * mouth_normal[1]) / distance)) + angle = math.acos(alignment) + score = abs(distance - 7.0) + angle * 4.0 + ground_candidates.append((score, x, y, hit[0])) +if not ground_candidates: + raise RuntimeError("no walkable mesh ground lies 6 to 10 m from the cave mouth") +_, ground_x, ground_y, outside_ground = min(ground_candidates) +inside_floor = PROFILE.sample_surface(buckets, chamber[0], chamber[1], True, 1.0, + preferred_height=floor_top) +if inside_floor is None or abs(inside_floor[0] - floor_top) > 1.5: + raise RuntimeError(f"chamber floor mesh hit {inside_floor} does not match designed floor {floor_top:.3f} m") +floor_top = inside_floor[0] + +ground_camera = (ground_x, ground_y, outside_ground + 1.6) +entry_ground = PROFILE.sample_surface(buckets, entrance[0], entrance[1], True, 1.0, + preferred_height=ground_z) +if entry_ground is None: + raise RuntimeError("cave mouth has no walkable ground surface in the exported mesh") +entry_camera = (entrance[0] + mouth_normal[0] * 3.8, + entrance[1] + mouth_normal[1] * 3.8, ground_z + 1.6) +entry_target_floor = edge_point(edge, 0.30) +entry_target = (entry_target_floor[0], entry_target_floor[1], entry_target_floor[2] + 1.4) +to_dais = (dais[0] - chamber[0], dais[1] - chamber[1]) +to_dais_length = math.hypot(*to_dais) +opposite_dais = (-to_dais[0] / to_dais_length, -to_dais[1] / to_dais_length) +inside_x = chamber[0] +inside_y = chamber[1] +inside_floor = PROFILE.sample_surface(buckets, inside_x, inside_y, True, 1.0, + preferred_height=floor_top) +inside_eye_z = inside_floor[0] + 1.6 +chamber_camera = (chamber[0] + opposite_dais[0] * 5.5, + chamber[1] + opposite_dais[1] * 5.5, inside_eye_z) +chamber_target = (dais[0], dais[1], dais_top - 0.25) +overview_camera = (entrance[0] + outward[0] * 18.0, + entrance[1] + outward[1] * 18.0, outside_ground + 15.0) +overview_target = ((entrance[0] + chamber[0]) * 0.5, + (entrance[1] + chamber[1]) * 0.5, + (ground_z + floor_top) * 0.5) +inward = (-outward[0], -outward[1]) +clip_point = (entrance[0] + outward[0] * 0.5, + entrance[1] + outward[1] * 0.5) +overview_clip = (inward[0], inward[1], 0.0, + -(inward[0] * clip_point[0] + inward[1] * clip_point[1])) + +views = ( + ("ground-entrance", ground_camera, (portal[0], portal[1], portal[2]), + (portal[0], portal[1], ground_z, 20.0), -509.0), + ("entrance-looking-in", entry_camera, entry_target, (entrance[0], entrance[1], entrance[2], 16.0), -507.0), + ("inside-to-dais", chamber_camera, chamber_target, + (chamber[0], chamber[1], chamber[2], 15.0), chamber[2]), + ("overview-entrance-and-chamber", overview_camera, overview_target, + ((entrance[0] + chamber[0]) * 0.5, + (entrance[1] + chamber[1]) * 0.5, + (ground_z + floor_top) * 0.5, 34.0), -509.0), +) + +for name, camera, target, focus, height_center in views: + png = PNG_DIR / f"{name}.png" + jpg = CAPTURE_DIR / f"{name}.jpg" + yaw, pitch = camera_angles(camera, target) + fov = (80.0 if name == "overview-entrance-and-chamber" else + 70.0 if name in ("inside-to-dais", "entrance-looking-in") else 65.0 + if name == "ground-entrance" else 60.0) + if name == "ground-entrance": + fov = 55.0 + print(f"[L46CaptureCamera] view={name} camera_m=({fmt(camera)}) " + f"target_m=({fmt(target)}) yaw_deg={yaw:.3f} pitch_deg={pitch:.3f} " + f"fov_deg={fov:.1f} entrance_distance_m={math.dist(camera, portal):.3f}", flush=True) + render = [str(RENDERER), str(mesh), str(png), "--size=1600x900", f"--fov-deg={fov:.1f}", + f"--camera-m={fmt(camera)}", f"--yaw-deg={yaw:.6f}", f"--pitch-deg={pitch:.6f}", + "--shade=readable", "--headlamp=on", "--height-tint=on", + "--light-direction=0.3,-0.4,0.85", + f"--height-center-m={(floor_top if name == 'inside-to-dais' else height_center):.6f}", + f"--height-range-m={4 if name == 'inside-to-dais' else 16}", + "--tile-skirts=off", "--tile-edges=off", + f"--focus-region={fmt(focus)}"] + if name == "overview-entrance-and-chamber": + render.append(f"--clip-plane={fmt(overview_clip)}") + run_logged(f"{name}-render", render, capture=True) + for quality in (4, 6, 8): + run_logged(f"{name}-jpeg-q{quality}", ["ffmpeg", "-nostdin", "-hide_banner", + "-loglevel", "error", "-y", "-i", str(png), "-frames:v", "1", + "-q:v", str(quality), str(jpg)]) + if jpg.is_file() and 0 < jpg.stat().st_size <= 300_000: + break + if not jpg.is_file() or not 0 < jpg.stat().st_size <= 300_000: + raise RuntimeError(f"{jpg.name} exceeds the 300 KB capture limit") + quality_log = run_logged(f"{name}-quality", [sys.executable, + str(VERIFY / "capture_quality.py"), str(jpg)]) + print(f"[L46CaptureJPEG] file={jpg} bytes={jpg.stat().st_size} " + f"quality_log={quality_log.strip().replace(chr(10), ' ')} status=PASS", flush=True) + + +def region_triangles(center, radius_xy, z_min, z_max): + selected = [] + for triangle in triangles: + points = tuple(vertex[0] for vertex in triangle[2]) + centroid = tuple(sum(point[axis] for point in points) / 3.0 for axis in range(3)) + if (math.hypot(centroid[0] - center[0], centroid[1] - center[1]) <= radius_xy + and z_min <= centroid[2] <= z_max): + selected.append(triangle) + return selected + + +def tunnel_wall_triangles(edge, t, height_above_floor): + center = edge_point(edge, t) + before = edge_point(edge, max(0.0, t - 0.01)) + after = edge_point(edge, min(1.0, t + 0.01)) + tx, ty = after[0] - before[0], after[1] - before[1] + tangent_length = math.hypot(tx, ty) + tx, ty = tx / tangent_length, ty / tangent_length + expected_radius = edge["bore_radius_um"] / 1_000_000.0 * math.sqrt( + 1.0 - (height_above_floor / (edge["bore_height_um"] / 1_000_000.0)) ** 2) + selected = [] + for triangle in triangles: + points = tuple(vertex[0] for vertex in triangle[2]) + centroid = tuple(sum(point[axis] for point in points) / 3.0 for axis in range(3)) + dx, dy = centroid[0] - center[0], centroid[1] - center[1] + longitudinal = dx * tx + dy * ty + lateral = abs(-dy * tx + dx * ty) + vertical = centroid[2] - center[2] + if (abs(longitudinal) <= 1.5 + and abs(lateral - expected_radius) <= 0.7 + and abs(vertical - height_above_floor) <= 0.75): + selected.append(triangle) + print(f"[L46TunnelWallWindow] t={t:.3f} center_m=({center[0]:.3f}," + f"{center[1]:.3f},{center[2]:.3f}) expected_radius_m={expected_radius:.3f} " + f"longitudinal_half_width_m=1.500 vertical_half_width_m=0.750 " + f"triangles={len(selected)}", flush=True) + return selected + + +def audit_shape(name, region, center): + metrics = SHAPE.analyze_triangles(region, center[:2], center[2], + crop_to_center=False, include_sides=True) + passed = (metrics["triangles"] > 0 + and metrics["planar_area"] < SHAPE.PLANE_LIMIT_M2 + and metrics["straight"] <= SHAPE.STRAIGHT_LIMIT_M + and metrics["corners"] == 0) + print(f"[LandmarkBuiltShape] region={name} triangles={metrics['triangles']} " + f"planar_patch_m2={metrics['planar_area']:.4f} " + f"straight_run_m={metrics['straight']:.4f} sharp_corners={metrics['corners']} " + f"corner_turn_max_deg={metrics['corner_turn_max']:.2f} " + f"thresholds=({SHAPE.PLANE_LIMIT_M2:.2f}m2,{SHAPE.STRAIGHT_LIMIT_M:.2f}m," + f"{SHAPE.CORNER_TURN_LIMIT_DEG:.0f}deg) status={'PASS' if passed else 'FAIL'}", + flush=True) + if not passed: + raise RuntimeError(f"L37 built-shape thresholds failed in {name}") + return metrics + + +entrance_metrics = audit_shape("cave-mouth", region_triangles( + portal, 5.0, ground_z + 0.8, ground_z + 4.5), + (portal[0], portal[1], ground_z)) +tunnel_mid = edge_point(edge, 0.58) +tunnel_metrics = audit_shape("tunnel-wall-edge", tunnel_wall_triangles( + edge, 0.58, 1.2), + (tunnel_mid[0], tunnel_mid[1], tunnel_mid[2] + 1.2)) +dais_surface = PROFILE.sample_surface(buckets, dais[0], dais[1], True, 1.0, + preferred_height=dais_top - 0.4) +if dais_surface is None: + raise RuntimeError("dais stamp has no upward-facing surface in the exported mesh") +dais_rise = dais_surface[0] - floor_top +print(f"[LandmarkDaisSurface] center_m=({dais[0]:.3f},{dais[1]:.3f}) " + f"floor_z_m={floor_top:.3f} top_z_m={dais_surface[0]:.3f} " + f"rise_m={dais_rise:.3f} minimum_m=1.000 " + f"status={'PASS' if dais_rise >= 1.0 else 'FAIL'}", flush=True) +if dais_rise < 1.0: + raise RuntimeError(f"dais rises only {dais_rise:.3f} m above the chamber floor") +dais_metrics = audit_shape("dais-platform", region_triangles( + dais, 1.7, floor_top + 0.6, dais_top + 0.4), + (dais[0], dais[1], dais_surface[0])) +metrics_path = ARTIFACT_DIR / "landmark-shape-metrics.json" +metrics_path.write_text(json.dumps({ + "graph_vocabulary_version": layout["cavern_graph_vocabulary_version"], + "entry_m": entrance, "portal_m": portal, "entry_ground_m": ground_z, + "chamber_center_m": chamber, "chamber_floor_m": floor_top, + "tunnel_midpoint_m": tunnel_mid, + "dais_center_m": dais, "dais_top_m": dais_top, + "dais_surface_m": dais_surface[0], "dais_rise_m": dais_rise, + "l37_thresholds": {"planar_patch_m2_lt": SHAPE.PLANE_LIMIT_M2, + "straight_run_m_lte": SHAPE.STRAIGHT_LIMIT_M, + "corner_turn_deg_lt": SHAPE.CORNER_TURN_LIMIT_DEG}, + "entrance_arch": entrance_metrics, "tunnel_wall": tunnel_metrics, + "dais_platform": dais_metrics, + "mesh_tiles": tile_count, "mesh_voxel_um": voxel_um, +}, indent=2) + "\n", encoding="utf-8") +print(f"[L46CaptureShapeMetrics] file={metrics_path} status=PASS", flush=True) diff --git a/Standalone/Research/WorldAssemblerM3/Linux46/REPORT.md b/Standalone/Research/WorldAssemblerM3/Linux46/REPORT.md new file mode 100644 index 0000000..a37b1e8 --- /dev/null +++ b/Standalone/Research/WorldAssemblerM3/Linux46/REPORT.md @@ -0,0 +1,36 @@ +# Linux 46 — cavern graph slice 1 + +## Sweep and resolved graph + +The ramp is one cubic-Bezier sweep. Each field query finds the closest XY point with a 17-point seed and five Newton refinements; the curve's Z coordinates define the cubic floor profile. A continuous rounded bore uses a 2.6 m radius, 4.8 m height and 0.3 m smooth blend. The 20 mm relief is applied to the bore. A separate hard floor cap runs after smooth unions, relief, passages and stamps. It preserves native negative density and adds at most 0.45 m of support only where the field is air. The room and edge conservative bounds feed `World::isTileShapeFree`. + +Resolved snapshots can carry optional cavern graph vocabulary version 1 records: room `landmark/sw-arrival-chamber-v1` and edge `landmark/sw-arrival-chamber-tunnel-v1`, anchored from the surface-ground approach to that room. Graph fields are serialized and included in canonical identity only when records exist. With the demo off, the graph is absent and the L45 identities remain unchanged. + +The 57 ramp carves and 107 support pillars are gone. The room is one graph node and the tunnel one edge. Four resolved stamps remain for the two mouth carves, room floor and dais. + +## Audits + +- Reachability: pass from walkable ground to the chamber floor; 58 path samples, 5,974 capsule samples, no blocked or unsupported samples, maximum grade 14.179°. Buried L43 control: expected fail. +- Headroom: 2.800 m minimum above the capsule, against 0.300 m required. +- Ripple: tunnel floor peak 0.0018 m over 202 samples (0.015 m limit); wall peak 0.0232 m over 138 samples (0.035 m limit, 0.25–1.0 m band). L45 segmented-ramp control: expected floor-ripple fail at 0.0179 m against 0.015 m. +- Built shape (L37 limits: planar patch <0.50 m², straight run ≤0.75 m, no sharp corners): entrance 0.4902 m² / 0.5470 m / 0; tunnel wall 0.3042 m² / 0.6225 m / 0; dais 0.2816 m² / 0.7500 m / 0. +- Inside-tunnel and near-tunnel camera hashes match at W1/W8/W12. Random-camera determinism also passed. + +## Hashes, cost and tiers + +The clean Clang and GCC builds share source stamp `20d1ae654440b22c6fdf9c91dd39c809b3063b54da6f2f4f44acf78894b7cf3b`. Owner JSON SHA-256 remains `7b095f9bcc6b90133d5a463f99f0b571133b2c549ce1ecf2d64fe9175e41e183`. Demo-off FI/SW layout IDs are `b7a7d9ecc48d8f26` / `8a52e84c26ed92d2`; their 12-hash signatures match L45: FI `9820f4f38b189b3f7263810e7b3ff62aa502b21f476ffcfdbc7761a65cb07d58`, SW `a1e9268a4bbcc77d2a1ac54095df6851a70bab65c43ae50b332ed75132617834`. + +Five demo-off cost pairs against committed L45: FI median 16.315 → 16.401 s (+0.53%); SW 1.720 → 1.730 s (+0.58%). Fast passed in 202.7 s, medium in 571.9 s, and final full in 1605.2 s. Four captures are in `captures/`: `ground-entrance.jpg` (39,430 B), `entrance-looking-in.jpg` (45,070 B), `inside-to-dais.jpg` (28,310 B), `overview-entrance-and-chamber.jpg` (62,735 B). Each is below 300 KB. Fourteen of fifteen fast trials were used. + +## Failed runs + +The first medium capture audit sampled a broad tunnel volume and failed the built-shape limit; the final wall annulus at curve parameter 0.58 passed. The first full attempt passed its 30 determinism cells but failed extracting an 806 MB historical archive and filled `/tmp`. The final full run used a temporary Git wrapper to export only the build inputs (Core, Source, Compat and build scripts); its historical build, cost pairs and all full checks passed. No tracked verifier change was needed. + +## Orchestrator review (Claude) + +Accepted. The stamp matches the final Clang and GCC builds (`20d1ae65…`); demo-off owner hashes are identical to L45. +The segmented ramp (57 carves plus 107 pillars) is replaced by one continuous Bezier sweep with a hard floor cap; +headroom rose from 0.085 m to 2.8 m. Both controls fail as they should: the L45 ramp on floor ripple, and the buried +L43 layout on reachability. Follow-up for the verifier: the full tier's baseline export copies the whole HEAD tree +(about 785 MB) into `/tmp`, which is RAM-backed on this machine, and never deletes it. About 6 GB had accumulated. +It must export only the build inputs, into `Saved/Linux/`, and clean up after itself. diff --git a/Standalone/Research/WorldAssemblerM3/Linux46/captures/entrance-looking-in.jpg b/Standalone/Research/WorldAssemblerM3/Linux46/captures/entrance-looking-in.jpg new file mode 100644 index 0000000..3f54447 Binary files /dev/null and b/Standalone/Research/WorldAssemblerM3/Linux46/captures/entrance-looking-in.jpg differ diff --git a/Standalone/Research/WorldAssemblerM3/Linux46/captures/ground-entrance.jpg b/Standalone/Research/WorldAssemblerM3/Linux46/captures/ground-entrance.jpg new file mode 100644 index 0000000..9ccfdf0 Binary files /dev/null and b/Standalone/Research/WorldAssemblerM3/Linux46/captures/ground-entrance.jpg differ diff --git a/Standalone/Research/WorldAssemblerM3/Linux46/captures/inside-to-dais.jpg b/Standalone/Research/WorldAssemblerM3/Linux46/captures/inside-to-dais.jpg new file mode 100644 index 0000000..8c91678 Binary files /dev/null and b/Standalone/Research/WorldAssemblerM3/Linux46/captures/inside-to-dais.jpg differ diff --git a/Standalone/Research/WorldAssemblerM3/Linux46/captures/overview-entrance-and-chamber.jpg b/Standalone/Research/WorldAssemblerM3/Linux46/captures/overview-entrance-and-chamber.jpg new file mode 100644 index 0000000..e0f4f84 Binary files /dev/null and b/Standalone/Research/WorldAssemblerM3/Linux46/captures/overview-entrance-and-chamber.jpg differ diff --git a/Standalone/Research/WorldAssemblerM3/Research13/REPORT.md b/Standalone/Research/WorldAssemblerM3/Research13/REPORT.md new file mode 100644 index 0000000..b84e9e9 --- /dev/null +++ b/Standalone/Research/WorldAssemblerM3/Research13/REPORT.md @@ -0,0 +1,44 @@ +## Key recommendations + +1. **Add one shared curve sweep evaluator** for passage bores, the L45 ramp and R12 graph edges. Evaluate a smooth spline over global arc length, rather than taking a minimum over piecewise-linear capsules or discrete ramp stamps. +2. **Accelerate the global search with a conservative BVH.** Seed each surviving spline span from its chord projection, refine with a safeguarded solver, and compare all candidates whose bounds could beat the current best. One Newton solve from one seed is not a global closest-point solve. +3. **Keep the floor cap separate from tube ownership.** Use an explicit floor profile and R3’s floor-near candidate rule for overlapping bends and coils; keep relief and smooth unions away from the floor zero set. +4. **Use a sphere sweep as the fast, bounded field.** A general framed cross-section sweep is not guaranteed to be a true distance field on tight curves. Use rotation-minimising frames for its orientation, but validate curvature and retain conservative bounds for culling. + +## Math and exactness + +Represent the spine as a C1 or preferably C2 cubic spline \(C(s)\), parameterised by arc length \(s\). Interpolate radius \(r(s)\) and floor height \(h(s)\) as explicit route attributes. The round sweep is: + +\[ +\phi_{\text{tube}}(p)=\min_{s\in[0,L]} \left(\|p-C(s)\|-r(s)\right) +\] + +This gives a continuous field with the correct zero set for the union of swept balls. It is exact distance outside that union; inside overlapping spheres it is only a distance bound, as the swept-volume literature cautions. The exact nearest-point equation for a cubic Bezier has degree five; Iñigo Quílez’s compact Bezier distance function is for a quadratic curve, so it is a useful small-case reference, not a complete cubic-tube solution. [IQ’s 2D distance functions](https://iquilezles.org/articles/distfunctions2d/), [point-to-NURBS distance paper](https://doi.org/10.1016/j.cad.2008.06.008), [swept-volume SDF analysis](https://doi.org/10.1145/3610548.3618196) + +For the floor, define a separate cap from the selected route branch. For a single-valued floor graph \(h(x,y)\), a useful field is \((h(x,y)-z)/\sqrt{1+\lambda^2}\), where \(\lambda\) bounds the floor slope. Its zero set is the authored floor; it is exact distance for a planar floor and a conservative bound otherwise. Combine the tube and cap as an intersection, then carve using Core’s positive-air convention. Do not smooth-blend the floor edge or apply wall relief at the floor: either can shift the walk surface. The cave-network paper uses this slope-bounded height-field form and notes that arbitrary cross-section sweeps along high-curvature curves do not generally yield a continuous SDF. [Paris et al., *Synthesizing Geologically Coherent Cave Networks*](https://diglib.eg.org/items/a4e7985c-af52-4b55-bd1a-eeeff6c47422) + +A circular sweep needs no frame. For an asymmetric cross-section, precompute a rotation-minimising frame along the arc-length table; the double-reflection method is a practical low-cost choice and avoids the Frenet frame’s flips at inflections. [Wang et al., *Computation of Rotation Minimizing Frames*](https://doi.org/10.1145/1330511.1330513) + +## Cost and culling + +Build an arc-length lookup table, spline-span bounds and optional frames once when resolving the layout. At sample time, traverse a BVH ordered by the conservative lower bound `distance(point, spanBounds) - maxRadius`. The current passage BVH already uses the analogous `AABB distance - maximum radius` bound; spline bounds must include each cubic span’s control hull, not just its endpoints. + +Use the chord projection to seed a bracketed Newton or similar local solve on each promising span, include span endpoints, and retain the lowest result globally. A single seed can converge to the wrong local minimum near a bend or helix. For exact refinement, isolate all roots of the quintic stationary equation on the few surviving spans; variable radius adds a derivative term and makes the solve more costly. [The NURBS distance method](https://doi.org/10.1016/j.cad.2008.06.008) is a relevant example of clipping candidate regions before numerical refinement. + +The exact minimum of 1-Lipschitz sphere fields is itself 1-Lipschitz. An *approximate* solver’s sampled value is not automatically a safe culling bound: it may overestimate the minimum. Keep a certified lower bound from the BVH/subdivision search for skip decisions, or stop with a known error gap and subtract that error. Report node visits, candidate spans, refinements and bound gaps per sample; a 256-span curve has about eight BVH levels before pruning, but tight overlapping coils may leave many candidates alive. Unreal’s spline API exposes closest-key queries, distance-along-spline queries and a reparameterisation table, matching the useful precomputed lookup pattern for game workloads. [Epic’s `USplineComponent` API](https://dev.epicgames.com/documentation/unreal-engine/API/Runtime/Engine/USplineComponent) + +## Floor ownership and code fit + +For overlapping coils, do **not** use the closest 3D centerline to choose the floor. R3 recommends considering only floor candidates within half a voxel (0.125 m) vertically and inside the bore radius, then choosing the nearest floor point with a stable ID tie-break. Its synthetic helix showed roughly 3.37 m peak-to-peak error for closest-centerline ownership versus 0.029 m for floor-near ownership; it did not prove the rule fixes the real L38 ripple, so the real FI Style 2 route still needs measurement. [R3 floor ownership]() · [R12 graph-field recommendation]() + +Put a shared evaluator in a Core internal sweep helper, with a resolved curve record carrying stable ID, cubic controls, arc-length attributes, floor/radius profiles and conservative bounds. + +- **Passages:** adapt [`ApplyResolvedStratePassageCarves`]() to call it; retain bridge, mouth and support policy around the shared geometry query. Update [`BuildPassageNearestNode`]() and its lower bounds for spline hulls. +- **L45 ramp:** replace the repeated ramp carve/support requests in [`MakeLandmarkChamberDemoRequests`]() with one floor-capped curve record. Route its evaluation through the shared composition/stamp path in [`ShapeWorldInternal.h`](). +- **R12 graph edges:** store the same curve/floor data on each resolved edge and evaluate it from [`BindResolvedStratumFields`](); include blend, relief and floor-support reach in tile and shape-free bounds. + +## Failure modes and suggested checks + +The main risks are missed local minima, spline overshoot, curvature causing a swept cross-section to fold or overlap, variable radius changing the floor, frame flips, and hard floor-owner changes across nearby branches. Validate radius slope and curvature; use monotone floor interpolation where route height must not overshoot. Sphere-swept canal surfaces also become degenerate when radius changes as fast as the spine advances. [Canal surface conditions](https://mathworld.wolfram.com/CanalSurface.html) + +For a zero-relief tight S-bend with nonuniform knot spacing, sample the floor crossing every 0.125 m of arc length against its analytic \(h(s)\): the analytic ripple should be 0, with measured error only from the solver/voxel tolerance and no peak at the knot spacing. Then run an overlapping helix with floor-near ownership, and the real L35/L36/L45 controls. Track floor peak-to-peak error, the 0.5–8 m ripple spectrum, culling false negatives, solver gaps and candidate counts. L35 and L36 documented the passage wall/floor beats, while L45 records the 57-carve/107-support ramp ribs. [L35]() · [L36]() · [L45]() \ No newline at end of file diff --git a/Standalone/Source/StreamBenchmarkHeadless.cpp b/Standalone/Source/StreamBenchmarkHeadless.cpp index 12287d3..35c8717 100644 --- a/Standalone/Source/StreamBenchmarkHeadless.cpp +++ b/Standalone/Source/StreamBenchmarkHeadless.cpp @@ -56,6 +56,7 @@ namespace int RenderDistanceOverride=0; bool FloatingIslandsRecipe=false; bool LandmarkChamberDemo=false; + bool LandmarkL45RampControl=false; bool AuditLandmarkReachability=false; bool TlsProbeRequested=false; int TlsProbeWorkers=0; @@ -115,10 +116,14 @@ namespace const VoxelForgeCore::WorldAssemblyManifest& worldAssembly, std::int32_t seed, std::vector& requests, + std::vector& cavernRooms, + std::vector& cavernEdges, + bool legacyRampControl, std::string& error) { using namespace VoxelForgeCore; requests.clear(); + cavernRooms.clear();cavernEdges.clear(); const auto surface=std::find_if(layout.strates.begin(),layout.strates.end(), [](const auto& value){return value.generatorKind=="SurfaceWorld";}); if(surface==layout.strates.end()) @@ -444,6 +449,100 @@ namespace return startZ+descent*std::clamp((beforeFirstTurn+afterFirstTurn) /slopedLength,0.0,1.0); }; + // The resolved edge is one cubic sweep. Its floor controls are independent + // of the curved XY centerline, so the floor remains an explicit profile. + const double startFloor=surfaceHeight; + const double endFloor=chamberCenterZ-0.5; + const double firstLength=std::hypot(firstTurn.x-start.x,firstTurn.y-start.y); + const double lastLength=std::hypot(end.x-firstTurn.x,end.y-firstTurn.y); + const double estimatedLength=firstLength+lastLength; + const double handleLength=estimatedLength/3.0; + const double firstDirX=(firstTurn.x-start.x)/firstLength; + const double firstDirY=(firstTurn.y-start.y)/firstLength; + const double lastDirX=(end.x-firstTurn.x)/lastLength; + const double lastDirY=(end.y-firstTurn.y)/lastLength; + std::array,4> curve={{ + {start.x,start.y,startFloor}, + {start.x+firstDirX*handleLength,start.y+firstDirY*handleLength, + startFloor+(endFloor-startFloor)/3.0}, + {end.x-lastDirX*handleLength,end.y-lastDirY*handleLength, + startFloor+2.0*(endFloor-startFloor)/3.0}, + {end.x,end.y,endFloor}}}; + ResolvedCavernRoomV1 room; + room.stableId=chamber.stableId;room.strateIndex=surface->strateIndex; + room.centerMicrometres=chamber.anchorPositionMicrometres; + room.halfExtentsMicrometres=chamber.halfExtentsMicrometres; + room.floorMicrometres=static_cast(std::llround(endFloor*1000000.0)); + room.smoothWidthMicrometres=chamber.smoothWidthMicrometres; + room.irregularityAmplitudeMicrometres=chamber.irregularityAmplitudeMicrometres; + room.irregularityWavelengthMicrometres=chamber.irregularityWavelengthMicrometres; + if(!legacyRampControl)cavernRooms.push_back(room); + ResolvedCavernEdgeV1 edge; + edge.stableId="landmark/sw-arrival-chamber-tunnel-v1"; + edge.startAnchorStableId=passage->stableId+"/surface-ground-approach"; + edge.endRoomStableId=room.stableId;edge.strateIndex=surface->strateIndex; + edge.boreRadiusMicrometres=2600000; + edge.boreHeightMicrometres=4800000; + edge.floorSupportDepthMicrometres=450000; + edge.smoothWidthMicrometres=300000; + edge.irregularityAmplitudeMicrometres=20000; + edge.irregularityWavelengthMicrometres=3200000; + double measuredCurveLength=0.0,arcAtThird=0.0,arcAtTwoThirds=0.0; + std::array previousCurvePoint={curve[0][0],curve[0][1]}; + for(int sample=1;sample<=256;++sample) + { + const double t=static_cast(sample)/256.0,one=1.0-t; + std::array point{}; + for(std::size_t axis=0;axis<3;++axis) + point[axis]=one*one*one*curve[0][axis] + +3.0*one*one*t*curve[1][axis]+3.0*one*t*t*curve[2][axis] + +t*t*t*curve[3][axis]; + const double dx=point[0]-previousCurvePoint[0]; + const double dy=point[1]-previousCurvePoint[1]; + const double horizontal=std::hypot(dx,dy); + measuredCurveLength+=horizontal; + if(sample==85)arcAtThird=measuredCurveLength; + if(sample==171)arcAtTwoThirds=measuredCurveLength; + previousCurvePoint={point[0],point[1]}; + } + const double targetFloorThird=startFloor+(endFloor-startFloor) + *(arcAtThird/measuredCurveLength); + const double targetFloorTwoThirds=startFloor+(endFloor-startFloor) + *(arcAtTwoThirds/measuredCurveLength); + const double firstEquation=targetFloorThird + -(8.0*startFloor+endFloor)/27.0; + const double secondEquation=targetFloorTwoThirds + -(startFloor+8.0*endFloor)/27.0; + curve[1][2]=1.5*(2.0*firstEquation-secondEquation); + curve[2][2]=1.5*(2.0*secondEquation-firstEquation); + double maximumCurveGrade=0.0; + std::array previousCurvePosition=curve[0]; + for(int sample=1;sample<=256;++sample) + { + const double t=static_cast(sample)/256.0,one=1.0-t; + std::array point{}; + for(std::size_t axis=0;axis<3;++axis) + point[axis]=one*one*one*curve[0][axis] + +3.0*one*one*t*curve[1][axis]+3.0*one*t*t*curve[2][axis] + +t*t*t*curve[3][axis]; + const double horizontal=std::hypot(point[0]-previousCurvePosition[0], + point[1]-previousCurvePosition[1]); + maximumCurveGrade=std::max(maximumCurveGrade,std::atan2( + std::abs(point[2]-previousCurvePosition[2]), + std::max(1.0e-6,horizontal))*180.0/3.14159265358979323846); + previousCurvePosition=point; + } + for(std::size_t i=0;i( + std::llround(curve[i][axis]*1000000.0)); + if(!legacyRampControl)cavernEdges.push_back(edge); + std::printf("[CavernGraphPlan] rooms=1 edges=1 room=%s edge=%s curve=cubic-bezier floor=cubic-profile length_m=%.3f max_grade_deg=%.3f bore_radius_m=2.600 bore_height_m=4.800 floor_um=%lld..%lld\n", + room.stableId.c_str(),edge.stableId.c_str(),measuredCurveLength,maximumCurveGrade, + static_cast(room.floorMicrometres), + static_cast(edge.controlPointsMicrometres[0][2])); + if(maximumCurveGrade>15.0) + {error="landmark cavern edge exceeds the 15 degree walk grade";return false;} const double pointSpacing=0.6; const std::size_t rampCount=static_cast(std::ceil(totalLength/pointSpacing)); std::vector rampCarves; @@ -506,12 +605,15 @@ namespace } requests.push_back(std::move(mouthStamp)); requests.push_back(std::move(mouthPortal)); - requests.insert(requests.end(),std::make_move_iterator(rampCarves.begin()), - std::make_move_iterator(rampCarves.end())); - requests.push_back(std::move(chamber)); - requests.insert(requests.end(),std::make_move_iterator(rampSupports.begin()), - std::make_move_iterator(rampSupports.end())); - requests.push_back(std::move(mouthLanding)); + if(legacyRampControl) + { + requests.insert(requests.end(),std::make_move_iterator(rampCarves.begin()), + std::make_move_iterator(rampCarves.end())); + requests.push_back(std::move(chamber)); + requests.insert(requests.end(),std::make_move_iterator(rampSupports.begin()), + std::make_move_iterator(rampSupports.end())); + } + if(legacyRampControl)requests.push_back(std::move(mouthLanding)); requests.push_back(std::move(chamberFloor)); requests.push_back(std::move(dais)); (void)seed; @@ -571,7 +673,8 @@ namespace " --mesh-export=FILE (write the exact generated tile meshes for offline rendering)\n" " --profile-column-overlap --far-horizon-lod8-step=N --force-generate\n" " --seed=N --recipe=surface-world|floating-islands\n" - " --landmark-chamber-demo (opt-in natural SW landmark chamber and ramp)\n" + " --landmark-chamber-demo (opt-in SW cavern room and swept tunnel)\n" + " --landmark-l45-ramp-control (use the buried L45 stamp geometry as an audit control)\n" " --resolved-layout-output=FILE (canonical plan-time layout snapshot)\n" " --resolved-layout-only (exit after plan and snapshot validation)\n" " --audit-resolved-layout-boundaries (query Core at each recorded endpoint)\n" @@ -640,6 +743,7 @@ namespace else {std::fprintf(stderr,"Invalid --recipe value: %.*s\n",static_cast(recipe.size()),recipe.data());return false;} } else if(arg=="--landmark-chamber-demo")options.LandmarkChamberDemo=true; + else if(arg=="--landmark-l45-ramp-control")options.LandmarkL45RampControl=true; else if(arg.starts_with("--resolved-layout-output=")) options.ResolvedLayoutOutputPath=std::string(arg.substr(std::string_view("--resolved-layout-output=").size())); else if(arg.starts_with("--seed=")) @@ -722,6 +826,8 @@ namespace {std::fprintf(stderr,"--tls-probe-touch requires --tls-probe=N\n");return false;} if(options.RegionCrossing&&!options.HasCameraStep) {std::fprintf(stderr,"--region-crossing requires --camera-step=X,Y,Z\n");return false;} + if(options.LandmarkL45RampControl&&!options.LandmarkChamberDemo) + {std::fprintf(stderr,"--landmark-l45-ramp-control requires --landmark-chamber-demo\n");return false;} if(options.TlsProbeRequested)return true; if(!options.WorldAssemblerPrototype||!options.StreamBenchmark||!options.StreamPipeline) { @@ -820,10 +926,15 @@ int main(int argc,char** argv) if(options.LandmarkChamberDemo) { std::vector requests; - if(!MakeLandmarkChamberDemoRequests(resolvedLayout,worldAssembly,seed,requests,error) + std::vector rooms; + std::vector edges; + if(!MakeLandmarkChamberDemoRequests(resolvedLayout,worldAssembly,seed, + requests,rooms,edges,options.LandmarkL45RampControl,error) ||!VoxelForgeCore::ResolveResolvedLayoutStampsV2( - resolvedLayout,seed,requests,error)) - {std::fprintf(stderr,"Landmark chamber stamp resolution failed: %s\n",error.c_str());return 2;} + resolvedLayout,seed,requests,error) + ||!VoxelForgeCore::ResolveResolvedCavernGraphV1( + resolvedLayout,seed,rooms,edges,error)) + {std::fprintf(stderr,"Landmark cavern graph resolution failed: %s\n",error.c_str());return 2;} const auto audit=VoxelForgeCore::AuditResolvedLayoutStampPlacementV1(resolvedLayout); std::printf("[ResolvedStampPlacementAudit] vocabulary=%u records=%zu route_control=rejected band_control=rejected controls=%u/%u status=%s\n", resolvedLayout.stampVocabularyVersion,resolvedLayout.stamps.size(), @@ -849,9 +960,14 @@ int main(int argc,char** argv) stamp.irregularityAmplitudeMicrometres/1000000.0, stamp.irregularityWavelengthMicrometres/1000000.0, static_cast(stamp.irregularitySeed)); - std::printf("[LandmarkStampDemo] enabled=true seed=%d layout_id=%s passage_safe=true walk_route_clear=true version=%u deterministic_hash=seed,strate,stable_id status=PASS\n", - seed,resolvedLayout.stableIdentity.c_str(), - resolvedLayout.stampVocabularyVersion); + if(options.LandmarkL45RampControl) + std::printf("[LandmarkL45RampControl] enabled=true stamps=%zu graph_records=0 expected=segmented status=PASS\n", + resolvedLayout.stamps.size()); + else + std::printf("[LandmarkCavernGraphDemo] enabled=true seed=%d layout_id=%s rooms=%zu edges=%zu support_stamps=%zu passage_safe=true deterministic_hash=seed,strate,stable_id version=%u status=PASS\n", + seed,resolvedLayout.stableIdentity.c_str(), + resolvedLayout.cavernRooms.size(),resolvedLayout.cavernEdges.size(), + resolvedLayout.stamps.size(),resolvedLayout.cavernGraphVocabularyVersion); } const std::string layoutJson=resolvedLayout.ToJson(); const auto roundTrip=VoxelForgeCore::ResolvedLayoutSnapshotV3::FromJson(layoutJson,error); diff --git a/Standalone/Source/StreamBenchmarkPipeline.h b/Standalone/Source/StreamBenchmarkPipeline.h index a91e81e..366eb9b 100644 --- a/Standalone/Source/StreamBenchmarkPipeline.h +++ b/Standalone/Source/StreamBenchmarkPipeline.h @@ -130,6 +130,7 @@ namespace StandaloneStreamBenchmark std::uint64_t supportSamples=0,unsupportedSamples=0,gradeFailures=0; double maximumGradeDegrees=0.0,maximumSupportDepthMetres=0.0; double minimumClearanceMetres=std::numeric_limits::infinity(); + double minimumHeadroomMetres=std::numeric_limits::infinity(); double latestPointMinimumClearanceMetres=std::numeric_limits::infinity(); std::array latestPointMinimumClearancePosition{}; bool passed=false; @@ -153,13 +154,19 @@ namespace StandaloneStreamBenchmark if(surfacePlan==assembly->plan.strates.end())return result; const VoxelForgeCore::Heightfield2D surfaceField(assembly->recipe,*surfacePlan); if(!surfaceField.isValid())return result; - std::vector ramp; - for(const auto& stamp:layout.stamps) - if(stamp.stableId.starts_with("landmark/sw-arrival-chamber-ramp-")) - ramp.push_back(&stamp); - std::sort(ramp.begin(),ramp.end(),[](const auto* a,const auto* b) - {return a->stableIdstableId;}); - if(ramp.size()<2)return result; + const auto tunnel=std::find_if(layout.cavernEdges.begin(),layout.cavernEdges.end(), + [](const auto& edge) + {return edge.stableId=="landmark/sw-arrival-chamber-tunnel-v1";}); + std::vector legacyRamp; + if(tunnel==layout.cavernEdges.end()) + { + for(const auto& stamp:layout.stamps) + if(stamp.stableId.starts_with("landmark/sw-arrival-chamber-ramp-")) + legacyRamp.push_back(&stamp); + std::sort(legacyRamp.begin(),legacyRamp.end(),[](const auto* a,const auto* b) + {return a->stableIdstableId;}); + if(legacyRamp.size()<2)return result; + } const double voxelMetres=static_cast(std::max(1, coreVoxelSizeMicrometres))/1000000.0; @@ -177,12 +184,13 @@ namespace StandaloneStreamBenchmark return static_cast(shape.densityAt({x/voxelMetres, y/voxelMetres,z/voxelMetres}))*voxelMetres; }; - const auto samplePoint=[&](double x,double y,double centerZ,bool tunnel) + const auto samplePoint=[&](double x,double y,double centerZ,bool inTunnel, + bool measureHeadroom=false) { constexpr double FloorStep=0.01; double floorZ=centerZ-5.0; bool foundFloor=false; - if(tunnel) + if(inTunnel) { if(densityMetres(x,y,centerZ)>0.025) for(double offset=-0.10;offset>=-10.0;offset-=FloorStep) @@ -240,6 +248,23 @@ namespace StandaloneStreamBenchmark } } + if(measureHeadroom) + { + constexpr std::array,5> headOffsets={{{0.0,0.0}, + {0.24,0.0},{-0.24,0.0},{0.0,0.24},{0.0,-0.24}}}; + constexpr double HeadStep=0.05,HeadLimit=4.0; + for(const auto& offset:headOffsets) + { + double headroom=HeadLimit; + for(double rise=0.0;rise<=HeadLimit;rise+=HeadStep) + if(densityMetres(x+offset[0],y+offset[1], + floorZ+CapsuleHeight+rise)<0.0) + {headroom=std::max(0.0,rise-HeadStep);break;} + result.minimumHeadroomMetres=std::min( + result.minimumHeadroomMetres,headroom); + } + } + const std::array,5> supportOffsets={{{0.0,0.0}, {0.28,0.0},{-0.28,0.0},{0.0,0.28},{0.0,-0.28}}}; std::uint32_t supported=0; @@ -270,10 +295,16 @@ namespace StandaloneStreamBenchmark double floorZ=0.0,cost=std::numeric_limits::infinity(); int parent=-1; }; + const double edgeStartX=tunnel!=layout.cavernEdges.end() + ?tunnel->controlPointsMicrometres[0][0]/1000000.0 + :legacyRamp.front()->positionMicrometres[0]/1000000.0; + const double edgeStartY=tunnel!=layout.cavernEdges.end() + ?tunnel->controlPointsMicrometres[0][1]/1000000.0 + :legacyRamp.front()->positionMicrometres[1]/1000000.0; const int goalX=GridRadius+static_cast(std::llround( - (ramp.front()->positionMicrometres[0]/1000000.0-arrivalX)/GridStep)); + (edgeStartX-arrivalX)/GridStep)); const int goalY=GridRadius+static_cast(std::llround( - (ramp.front()->positionMicrometres[1]/1000000.0-arrivalY)/GridStep)); + (edgeStartY-arrivalY)/GridStep)); const auto nodeIndex=[](int x,int y){return y*GridWidth+x;}; std::vector surfaceNodes(static_cast(GridWidth*GridWidth)); std::uint64_t exploredNodes=0; @@ -364,7 +395,7 @@ namespace StandaloneStreamBenchmark double surfaceStartDistance=std::numeric_limits::infinity(); if(!surfaceRoute.empty()) { - path.reserve(surfaceRoute.size()+ramp.size()); + path.reserve(surfaceRoute.size()+128); const int startX=surfaceRoute.front()%GridWidth; const int startY=surfaceRoute.front()/GridWidth; surfaceStartDistance=GridStep*std::hypot(startX-GridRadius,startY-GridRadius); @@ -375,10 +406,39 @@ namespace StandaloneStreamBenchmark path.push_back({arrivalX+(ix-GridRadius)*GridStep, arrivalY+(iy-GridRadius)*GridStep,node.floorZ+0.9,false}); } - for(const auto* stamp:ramp) - path.push_back({stamp->positionMicrometres[0]/1000000.0, - stamp->positionMicrometres[1]/1000000.0, - stamp->positionMicrometres[2]/1000000.0,true}); + if(tunnel!=layout.cavernEdges.end()) + { + const auto curveValue=[&](double t,std::size_t axis) + { + const double one=1.0-t; + return (one*one*one*tunnel->controlPointsMicrometres[0][axis] + +3.0*one*one*t*tunnel->controlPointsMicrometres[1][axis] + +3.0*one*t*t*tunnel->controlPointsMicrometres[2][axis] + +t*t*t*tunnel->controlPointsMicrometres[3][axis])/1000000.0; + }; + double curveLength=0.0,previousCurveX=curveValue(0.0,0),previousCurveY=curveValue(0.0,1); + for(int sample=1;sample<=128;++sample) + { + const double t=static_cast(sample)/128.0; + const double x=curveValue(t,0),y=curveValue(t,1); + curveLength+=std::hypot(x-previousCurveX,y-previousCurveY); + previousCurveX=x;previousCurveY=y; + } + const int curveSteps=std::max(2,static_cast(std::ceil(curveLength/0.60))); + for(int sample=0;sample<=curveSteps;++sample) + { + const double t=static_cast(sample)/curveSteps; + path.push_back({curveValue(t,0),curveValue(t,1), + curveValue(t,2)+0.9,true}); + } + } + else + { + for(const auto* stamp:legacyRamp) + path.push_back({stamp->positionMicrometres[0]/1000000.0, + stamp->positionMicrometres[1]/1000000.0, + stamp->positionMicrometres[2]/1000000.0,true}); + } } std::printf("[LandmarkSurfaceRoute] reachable=%s goal_walkable=%s grid_step_m=%.2f start_offset_m=%.2f explored=%llu surface_points=%zu grade_limit_deg=15.000\n", surfaceRoute.empty()?"false":"true",surfaceGoalWalkable?"true":"false",GridStep,surfaceStartDistance, @@ -405,7 +465,7 @@ namespace StandaloneStreamBenchmark const auto capsuleBefore=result.capsuleSamples; const auto supportBefore=result.supportSamples; const bool tunnel=from.tunnel&&to.tunnel; - const auto [floorZ,supportOk]=samplePoint(x,y,centerZ,tunnel); + const auto [floorZ,supportOk]=samplePoint(x,y,centerZ,tunnel,true); allSupport&=supportOk; double grade=0.0; if(havePrior) @@ -421,7 +481,7 @@ namespace StandaloneStreamBenchmark ||!supportOk||grade>15.0)) { std::printf("[LandmarkWalkPoint] field=%.*s mode=%s x=%.3f y=%.3f center_z=%.3f floor_z=%.3f previous_x=%.3f previous_y=%.3f previous_floor_z=%.3f horizontal_step_m=%.3f blocked=%llu/%llu support_probes=%llu grade_deg=%.3f density_center_m=%.3f capsule_min_m=%.3f capsule_min_at_m=(%.3f,%.3f,%.3f)\n", - static_cast(label.size()),label.data(),tunnel?"ramp":"surface",x,y,centerZ,floorZ, + static_cast(label.size()),label.data(),tunnel?"tunnel":"surface",x,y,centerZ,floorZ, priorX,priorY,priorFloor,std::hypot(x-priorX,y-priorY), static_cast(result.blockedCapsuleSamples-blockedBefore), static_cast(result.capsuleSamples-capsuleBefore), @@ -437,7 +497,214 @@ namespace StandaloneStreamBenchmark } result.passed=result.pathSamples>1&&result.capsuleSamples>0 &&result.blockedCapsuleSamples==0&&allSupport&&allGrade - &&result.unsupportedSamples==0&&result.gradeFailures==0; + &&result.unsupportedSamples==0&&result.gradeFailures==0 + &&result.minimumHeadroomMetres>=0.300; + return result; + } + + struct FLandmarkTunnelRippleResult + { + std::size_t floorSamples=0,wallSamples=0; + double floorPeakPeriodMetres=0.0,floorPeakAmplitudeMetres=0.0; + double wallPeakPeriodMetres=0.0,wallPeakAmplitudeMetres=0.0; + bool valid=false,passed=false; + }; + + inline FLandmarkTunnelRippleResult AuditLandmarkTunnelRipples( + const VoxelForgeCore::World& shape, + const VoxelForgeCore::ResolvedLayoutSnapshotV3& layout, + std::uint32_t coreVoxelSizeMicrometres) + { + FLandmarkTunnelRippleResult result; + const double voxelMetres=static_cast(std::max(1, + coreVoxelSizeMicrometres))/1000000.0; + const auto densityMetres=[&](double x,double y,double z) + {return static_cast(shape.densityAt({x/voxelMetres,y/voxelMetres, + z/voxelMetres}))*voxelMetres;}; + struct FRoutePoint { double x=0.0,y=0.0,floor=0.0; }; + std::vector route; + const auto edge=std::find_if(layout.cavernEdges.begin(),layout.cavernEdges.end(), + [](const auto& value) + {return value.stableId=="landmark/sw-arrival-chamber-tunnel-v1";}); + if(edge!=layout.cavernEdges.end()) + { + constexpr int CurveSamples=512; + route.reserve(CurveSamples+1); + for(int index=0;index<=CurveSamples;++index) + { + const double t=static_cast(index)/CurveSamples,one=1.0-t; + const std::array weights={one*one*one,3.0*one*one*t, + 3.0*one*t*t,t*t*t}; + FRoutePoint point; + point.x=point.y=point.floor=0.0; + for(std::size_t control=0;control<4;++control) + { + point.x+=weights[control]*edge->controlPointsMicrometres[control][0]/1e6; + point.y+=weights[control]*edge->controlPointsMicrometres[control][1]/1e6; + point.floor+=weights[control]*edge->controlPointsMicrometres[control][2]/1e6; + } + route.push_back(point); + } + } + else + { + std::vector ramp; + for(const auto& stamp:layout.stamps) + if(stamp.stableId.starts_with("landmark/sw-arrival-chamber-ramp-")) + ramp.push_back(&stamp); + std::sort(ramp.begin(),ramp.end(),[](const auto* a,const auto* b) + {return a->stableIdstableId;}); + for(const auto* stamp:ramp) + route.push_back({stamp->positionMicrometres[0]/1e6, + stamp->positionMicrometres[1]/1e6, + stamp->positionMicrometres[2]/1e6-1.6}); + } + if(route.size()<2)return result; + std::vector arc(route.size(),0.0); + for(std::size_t index=1;index(route.size()-1, + static_cast(upper-arc.begin())); + const std::size_t lo=hi?hi-1:0; + const double span=arc[hi]-arc[lo]; + const double t=span>0.0?(distance-arc[lo])/span:0.0; + return FRoutePoint{std::lerp(route[lo].x,route[hi].x,t), + std::lerp(route[lo].y,route[hi].y,t), + std::lerp(route[lo].floor,route[hi].floor,t)}; + }; + const auto floorRoot=[&](double x,double y,double expected)->std::optional + { + constexpr double Step=0.025,Below=0.65,Above=0.35; + double priorZ=expected-Below; + double priorDensity=densityMetres(x,y,priorZ); + std::optional closest; + double closestError=std::numeric_limits::infinity(); + for(double z=priorZ+Step;z<=expected+Above+1.0e-9;z+=Step) + { + const double density=densityMetres(x,y,z); + if(priorDensity<0.0&&density>=0.0) + { + const double span=density-priorDensity; + const double root=span>0.0?priorZ+Step*(-priorDensity)/span:z; + const double error=std::abs(root-expected); + if(error> floorResiduals; + for(double distance=0.75;distance<=length-0.75;distance+=0.125) + { + const auto point=atDistance(distance); + const auto prior=atDistance(std::max(0.0,distance-0.1)); + const auto next=atDistance(std::min(length,distance+0.1)); + const double tx=next.x-prior.x,ty=next.y-prior.y; + const double tangentLength=std::hypot(tx,ty); + if(tangentLength<1.0e-6)continue; + const double normalX=-ty/tangentLength,normalY=tx/tangentLength; + double offsetSum=0.0;std::size_t offsetCount=0; + for(const double lateral:{-0.35,0.0,0.35}) + { + const auto root=floorRoot(point.x+normalX*lateral, + point.y+normalY*lateral,point.floor); + if(root){offsetSum+=*root-point.floor;++offsetCount;} + } + if(offsetCount) + {floorResiduals.push_back({distance,offsetSum/offsetCount});} + } + result.floorSamples=floorResiduals.size(); + const auto peakPeriod=[&](const std::vector>& samples, + double minimumPeriod,double maximumPeriod, + double& outPeriod) + { + double amplitude=0.0;outPeriod=0.0; + if(samples.size()<16)return amplitude; + for(double period=minimumPeriod;period<=maximumPeriod+1.0e-9;period+=0.125) + { + double cosine=0.0,sine=0.0; + for(const auto& [distance,residual]:samples) + { + const double phase=2.0*3.14159265358979323846*distance/period; + cosine+=residual*std::cos(phase);sine+=residual*std::sin(phase); + } + const double candidate=2.0*std::hypot(cosine,sine)/samples.size(); + if(candidate>amplitude){amplitude=candidate;outPeriod=period;} + } + return amplitude; + }; + result.floorPeakAmplitudeMetres=peakPeriod(floorResiduals,0.375, + std::min(8.0,length/2.0),result.floorPeakPeriodMetres); + + std::vector> wallResiduals; + const double expectedWallRadius=edge!=layout.cavernEdges.end() + ?edge->boreRadiusMicrometres/1e6*std::sqrt(1.0 + -std::pow(2.0/(edge->boreHeightMicrometres/1e6),2.0)) + :2.6*std::sqrt(1.0-std::pow(0.4/2.0,2.0)); + // Keep the edge audit away from the ground portal and the room union; + // those are transitions between features, not tunnel-wall ripple. + const double wallAuditStart=std::max(4.0,length*0.38); + const double wallAuditEnd=std::min(length*0.55,length-4.0); + for(double distance=wallAuditStart;distance<=wallAuditEnd;distance+=0.0625) + { + const auto point=atDistance(distance); + const auto prior=atDistance(std::max(0.0,distance-0.1)); + const auto next=atDistance(std::min(length,distance+0.1)); + const double tx=next.x-prior.x,ty=next.y-prior.y; + const double tangentLength=std::hypot(tx,ty); + if(tangentLength<1.0e-6)continue; + const double normalX=-ty/tangentLength,normalY=tx/tangentLength; + const double height=point.floor+2.0; + for(const double sign:{-1.0,1.0}) + { + double priorRadius=0.0; + double priorDensity=densityMetres(point.x,point.y,height); + bool found=false; + for(double radius=0.025;radius<=8.0;radius+=0.025) + { + const double density=densityMetres(point.x+normalX*sign*radius, + point.y+normalY*sign*radius,height); + if(priorDensity>=0.0&&density<0.0) + { + const double span=priorDensity-density; + const double root=span>0.0?priorRadius+0.025*priorDensity/span:radius; + wallResiduals.push_back({distance,root-expectedWallRadius}); + found=true;break; + } + priorRadius=radius;priorDensity=density; + } + if(!found)continue; + } + } + result.wallSamples=wallResiduals.size(); + // Remove the slow wall envelope from the amplitude profile before testing + // for segment-scale ribs. The moving baseline covers 2 m, while the audit + // band covers the shorter repeated seams seen in the L35/L36 controls. + std::vector> wallRippleResiduals; + wallRippleResiduals.reserve(wallResiduals.size()); + for(const auto& sample:wallResiduals) + { + double baseline=0.0;std::size_t neighbours=0; + for(const auto& other:wallResiduals) + if(std::abs(other.first-sample.first)<=1.0) + {baseline+=other.second;++neighbours;} + if(neighbours>0&&sample.first>=wallAuditStart+1.0 + &&sample.first<=wallAuditEnd-1.0) + wallRippleResiduals.push_back({sample.first, + sample.second-baseline/static_cast(neighbours)}); + } + result.wallPeakAmplitudeMetres=peakPeriod(wallRippleResiduals,0.25, + std::min(1.0,length/2.0),result.wallPeakPeriodMetres); + result.valid=result.floorSamples>=16&&result.wallSamples>=16; + result.passed=result.valid&&result.floorPeakAmplitudeMetres<=0.015 + &&result.wallPeakAmplitudeMetres<=0.035; return result; } @@ -447,6 +714,8 @@ namespace StandaloneStreamBenchmark { using namespace VoxelForgeCore; control=source;control.stamps.clear();control.stampVocabularyVersion=1; + control.cavernGraphVocabularyVersion=0; + control.cavernRooms.clear();control.cavernEdges.clear(); const auto add=[&](std::string id,ResolvedSdfStampKindV1 kind, std::array position, std::array half,std::int64_t smooth, @@ -971,11 +1240,32 @@ namespace StandaloneStreamBenchmark if(bAuditLandmarkReachability) { Streamer.Stop(); - if(ResolvedLayout==nullptr||!CoreSnapshot.resolvedLayout() - ||ResolvedLayout->stamps.empty()) - {std::fprintf(stderr,"[LandmarkReachabilityAudit] missing demo layout and stamps\n");return 2;} + if(ResolvedLayout==nullptr||!CoreSnapshot.resolvedLayout()) + {std::fprintf(stderr,"[LandmarkReachabilityAudit] missing resolved layout\n");return 2;} + if(ResolvedLayout->cavernEdges.empty()) + { + const auto control=AuditLandmarkTunnelRipples(Shape,*ResolvedLayout, + World.CoreVoxelSizeMicrons); + const bool expectedFail=control.valid + &&control.floorPeakAmplitudeMetres>0.015; + std::printf("[CavernTunnelRippleControl] layout=L45-ramp floor_samples=%zu floor_peak_period_m=%.3f floor_peak_amplitude_m=%.4f floor_limit_m=0.015 wall_samples=%zu wall_peak_period_m=%.3f wall_peak_amplitude_m=%.4f wall_limit_m=0.035 observed=%s expected=FAIL status=%s\n", + control.floorSamples,control.floorPeakPeriodMetres, + control.floorPeakAmplitudeMetres,control.wallSamples, + control.wallPeakPeriodMetres,control.wallPeakAmplitudeMetres, + expectedFail?"FAIL":"PASS",expectedFail?"PASS":"FAIL"); + return expectedFail?0:14; + } + if(ResolvedLayout->cavernRooms.empty()) + {std::fprintf(stderr,"[LandmarkReachabilityAudit] missing cavern room node\n");return 2;} + const auto ripples=AuditLandmarkTunnelRipples(Shape,*ResolvedLayout, + World.CoreVoxelSizeMicrons); + std::printf("[CavernTunnelRipple] layout=L46 floor_samples=%zu floor_peak_period_m=%.3f floor_peak_amplitude_m=%.4f floor_limit_m=0.015 wall_samples=%zu wall_peak_period_m=%.3f wall_peak_amplitude_m=%.4f wall_period_band_m=0.250..1.000 wall_detrend_window_m=2.000 wall_limit_m=0.035 status=%s\n", + ripples.floorSamples,ripples.floorPeakPeriodMetres, + ripples.floorPeakAmplitudeMetres,ripples.wallSamples, + ripples.wallPeakPeriodMetres,ripples.wallPeakAmplitudeMetres, + ripples.passed?"PASS":"FAIL"); const auto candidate=AuditLandmarkWalkPath(Shape,Assembly,*ResolvedLayout, - World.CoreVoxelSizeMicrons,"L45"); + World.CoreVoxelSizeMicrons,"L46"); const auto daisStamp=std::find_if(ResolvedLayout->stamps.begin(), ResolvedLayout->stamps.end(),[](const auto& stamp) {return stamp.stableId=="landmark/sw-arrival-chamber-v1-dais";}); @@ -1009,7 +1299,7 @@ namespace StandaloneStreamBenchmark oldBuildError.c_str());return 2;} const auto buried=AuditLandmarkWalkPath(oldShape,Assembly,*ResolvedLayout, World.CoreVoxelSizeMicrons,"L43"); - std::printf("[LandmarkWalkability] layout=L45 path_samples=%llu capsule_samples=%llu blocked=%llu support_samples=%llu unsupported=%llu grade_failures=%llu max_grade_deg=%.3f limit_deg=15.000 min_clearance_m=%.3f max_support_depth_m=%.3f status=%s\n", + std::printf("[LandmarkWalkability] layout=L46 path_samples=%llu capsule_samples=%llu blocked=%llu support_samples=%llu unsupported=%llu grade_failures=%llu max_grade_deg=%.3f limit_deg=15.000 min_clearance_m=%.3f max_support_depth_m=%.3f min_headroom_m=%.3f headroom_required_m=0.300 status=%s\n", static_cast(candidate.pathSamples), static_cast(candidate.capsuleSamples), static_cast(candidate.blockedCapsuleSamples), @@ -1017,14 +1307,18 @@ namespace StandaloneStreamBenchmark static_cast(candidate.unsupportedSamples), static_cast(candidate.gradeFailures), candidate.maximumGradeDegrees,candidate.minimumClearanceMetres, - candidate.maximumSupportDepthMetres,candidate.passed?"PASS":"FAIL"); + candidate.maximumSupportDepthMetres,candidate.minimumHeadroomMetres, + candidate.passed?"PASS":"FAIL"); + std::printf("[LandmarkHeadroom] minimum_m=%.3f required_m=0.300 status=%s\n", + candidate.minimumHeadroomMetres, + candidate.minimumHeadroomMetres>=0.300?"PASS":"FAIL"); std::printf("[LandmarkReachabilityControl] layout=L43-buried path_samples=%llu blocked=%llu unsupported=%llu grade_failures=%llu result=%s expected=FAIL status=%s\n", static_cast(buried.pathSamples), static_cast(buried.blockedCapsuleSamples), static_cast(buried.unsupportedSamples), static_cast(buried.gradeFailures), buried.passed?"PASS":"FAIL",buried.passed?"FAIL":"PASS"); - const bool passed=candidate.passed&&!buried.passed; + const bool passed=ripples.passed&&candidate.passed&&!buried.passed; std::printf("[LandmarkReachabilityAudit] positive=PASS control=%s status=%s\n", buried.passed?"FAILED":"EXPECTED_FAIL",passed?"PASS":"FAIL"); return passed?0:13; diff --git a/Standalone/Tools/verify/verify.py b/Standalone/Tools/verify/verify.py index a7ff4a5..2920cf0 100644 --- a/Standalone/Tools/verify/verify.py +++ b/Standalone/Tools/verify/verify.py @@ -192,7 +192,7 @@ def run_landmark_reachability(runner: VerificationRunner, expected_style=2, expected_strate_styles={"0": 2, "1": 1}, max_level=0, layout_path=OUT / "layouts" / f"{passage_name}.json") runner.require(f"{passage_name}:demo-enabled", - all_match(passage_output, r"^\[LandmarkStampDemo\] enabled=true .*status=PASS$"), + all_match(passage_output, r"^\[LandmarkCavernGraphDemo\] enabled=true .*rooms=1 edges=1 .*status=PASS$"), "landmark demo flag resolved while running passage audits") validate_passage(runner, passage_name, passage_output) @@ -203,7 +203,7 @@ def run_landmark_reachability(runner: VerificationRunner, expected_style=2, expected_strate_styles={"0": 2, "1": 1}, max_level=0, layout_path=OUT / "layouts" / f"{interface_name}.json") runner.require(f"{interface_name}:demo-enabled", - all_match(interface_output, r"^\[LandmarkStampDemo\] enabled=true .*status=PASS$"), + all_match(interface_output, r"^\[LandmarkCavernGraphDemo\] enabled=true .*rooms=1 edges=1 .*status=PASS$"), "landmark demo flag resolved while running interface audits") runner.require(f"{interface_name}:hole-audit", all_match(interface_output, r"^\[StrateTransitionAuditSummary\].*hole_columns=0 " @@ -213,8 +213,14 @@ def run_landmark_reachability(runner: VerificationRunner, all_match(interface_output, r"^\[StrateTransitionNegativeControls\].*status=PASS$"), "demo-on interface audit detects both thickness controls") runner.require(f"{name}:candidate-walkability", - all_match(output, r"^\[LandmarkWalkability\] layout=L45 .*status=PASS$"), - "demo capsule clearance, support and <=15 degree grade") + all_match(output, r"^\[LandmarkWalkability\] layout=L46 .*headroom_required_m=0\.300 status=PASS$"), + "demo capsule clearance, support, headroom and <=15 degree grade") + runner.require(f"{name}:headroom", + all_match(output, r"^\[LandmarkHeadroom\] minimum_m=(?:0\.[3-9][0-9][0-9]|[1-9][0-9]*\.[0-9]{3}) required_m=0\.300 status=PASS$"), + "at least 0.3 m above the capsule along the complete route") + runner.require(f"{name}:continuous-tunnel-ripple", + all_match(output, r"^\[CavernTunnelRipple\] layout=L46 .*floor_limit_m=0\.015 .*wall_limit_m=0\.035 status=PASS$"), + "floor and wall ripple remain below L36/L35 limits") runner.require(f"{name}:buried-l43-control", all_match(output, r"^\[LandmarkReachabilityControl\] layout=L43-buried .*" r"result=FAIL expected=FAIL status=PASS$"), @@ -227,6 +233,18 @@ def run_landmark_reachability(runner: VerificationRunner, all_match(output, r"^\[LandmarkReachabilityAudit\] positive=PASS " r"control=EXPECTED_FAIL status=PASS$"), "candidate passes and buried control fails") + ripple_control = f"landmark-l45-ramp-ripple-control-{compiler}" + _, control_output = runner.run_cpp(ripple_control, binary, OWNER, "surface-world", + ["--landmark-chamber-demo", "--landmark-l45-ramp-control", + "--audit-landmark-reachability", "--max-clip-level=0", + "--render-distance-chunks=0"], + expected_style=2, expected_strate_styles={"0": 2, "1": 1}, + max_level=0, layout_path=OUT / "layouts" / f"{ripple_control}.json") + runner.require(f"{ripple_control}:negative-control", + all_match(control_output, + r"^\[CavernTunnelRippleControl\] layout=L45-ramp .*" + r"floor_limit_m=0\.015 .*observed=FAIL expected=FAIL status=PASS$"), + "the segmented L45 ramp exceeds the floor ripple threshold") def expected_control_row(runner: VerificationRunner, name: str) -> None: @@ -398,6 +416,36 @@ def parse_tile_hashes(output: str) -> dict[tuple[int, int, int, int], tuple[str, return rows +def cavern_audit_cameras(layout_path: Path) -> list[tuple[float, float, float, float, float]]: + layout = json.loads(layout_path.read_text(encoding="utf-8")) + edges = layout.get("cavern_edges", []) + if layout.get("cavern_graph_vocabulary_version") != 1 or len(edges) != 1: + raise VerificationFailure(f"missing one resolved cavern edge in {layout_path}") + points = [[value / 1_000_000.0 for value in point] + for point in edges[0]["control_points_um"]] + + def value(t: float, axis: int) -> float: + one = 1.0 - t + return (one ** 3 * points[0][axis] + 3.0 * one ** 2 * t * points[1][axis] + + 3.0 * one * t ** 2 * points[2][axis] + t ** 3 * points[3][axis]) + + def derivative(t: float, axis: int) -> float: + one = 1.0 - t + return (3.0 * one ** 2 * (points[1][axis] - points[0][axis]) + + 6.0 * one * t * (points[2][axis] - points[1][axis]) + + 3.0 * t ** 2 * (points[3][axis] - points[2][axis])) + + cameras = [] + for t, lateral in ((0.50, 0.0), (0.32, 3.3)): + x, y, floor = value(t, 0), value(t, 1), value(t, 2) + dx, dy = derivative(t, 0), derivative(t, 1) + length = math.hypot(dx, dy) + side_x, side_y = -dy / length, dx / length + cameras.append((x + side_x * lateral, y + side_y * lateral, + floor + 1.6, math.degrees(math.atan2(dx, dy)), 0.0)) + return cameras + + def run_random_camera_sweep(runner: VerificationRunner, binaries: tuple[Path, ...], camera_count: int, configurations: tuple[tuple[int, int, int], ...]) -> None: @@ -464,6 +512,42 @@ def run_random_camera_sweep(runner: VerificationRunner, binaries: tuple[Path, .. runner.require(f"{label}:W{left}-W{right}", left_rows == right_rows, detail) + # Keep a pair of fixed cameras on the current owner graph: one in the bore and + # one beside its curved wall. Their tile hashes join the seeded W1/W8/W12 sweep. + layout_path = OUT / "layouts" / "landmark-reachability-clang.json" + if layout_path.is_file(): + graph_cameras = cavern_audit_cameras(layout_path) + for binary in binaries: + compiler = compiler_name(binary) + for camera_index, camera in enumerate(graph_cameras): + x, y, z, yaw, pitch = camera + camera_flag = f"--camera={x:.6f},{y:.6f},{z:.6f},{yaw:.3f},{pitch:.3f}" + label = f"cavern-camera-{compiler}-s{CAMERA_SWEEP_SEED:06x}-p{camera_index:02d}" + signatures: dict[int, dict[tuple[int, int, int, int], tuple[str, ...]]] = {} + for workers in (1, 8, 12): + name = f"{label}-w{workers}" + row, output = runner.run_cpp(name, binary, OWNER, "surface-world", + ["--stream-hash-tiles", camera_flag, "--landmark-chamber-demo", + "--clip-radius=3", "--max-clip-level=0", "--max-level=0", + "--render-distance-chunks=0"], + expected_style=2, + expected_strate_styles={"0": 2, "1": 1}, workers=workers, + max_level=0, layout_path=OUT / "layouts" / f"{name}.json") + row["camera_position_m"] = [round(value, 6) for value in camera[:3]] + row["camera_region"] = "inside-tunnel" if camera_index == 0 else "near-tunnel-wall" + signatures[workers] = parse_tile_hashes(output) + whole = re.search(r"^\[PipelineWholeViewHash\] tiles=(\d+)", output, re.M) + expected_rows = int(whole.group(1)) if whole else -1 + runner.require(f"{name}:tile-hash-rows", + bool(signatures[workers]) and len(signatures[workers]) == expected_rows, + f"rows={len(signatures[workers])} expected={expected_rows} camera={camera[:3]}") + for left, right in ((1, 8), (1, 12), (8, 12)): + runner.require(f"{label}:W{left}-W{right}", + signatures[left] == signatures[right], + f"camera_m=({x:.3f},{y:.3f},{z:.3f}) " + f"region={'inside' if camera_index == 0 else 'near'}-tunnel " + f"W{left}=W{right} density/mesh/payload/topology tile hashes match") + def run_ring(runner: VerificationRunner, *, full: bool) -> None: script = LINUX37 / "RunRingAuditsL37.py" @@ -611,11 +695,11 @@ def run_captures_and_mesh_audits(runner: VerificationRunner) -> None: script_inputs=[overview_script, VERIFY_DIR / "capture_quality.py", OUT / "owner-fi-ground-mouth.vfmesh"]) - landmark_script = ROOT / "Standalone/Research/WorldAssemblerM3/Linux45/CaptureLandmarkL45.py" + landmark_script = ROOT / "Standalone/Research/WorldAssemblerM3/Linux46/CaptureLandmarkL46.py" landmark_layout = OUT / "layouts" / "landmark-reachability-clang.json" - runner.run_stage("capture-landmark-l45", landmark_script, + runner.run_stage("capture-landmark-l46", landmark_script, ["--layout", str(landmark_layout), "--artifact-dir", str(OUT), - "--capture-dir", str(ROOT / "Standalone/Research/WorldAssemblerM3/Linux45/captures"), + "--capture-dir", str(ROOT / "Standalone/Research/WorldAssemblerM3/Linux46/captures"), "--data", str(OWNER)], binary=CLANG, data=OWNER, recipe="surface-world", style=1, layout_path=landmark_layout, env=env_for(capture=True),